diff --git a/packages/organizations/src/cli/services/business-platform.test.ts b/packages/organizations/src/cli/services/business-platform.test.ts new file mode 100644 index 00000000000..983b9d04e14 --- /dev/null +++ b/packages/organizations/src/cli/services/business-platform.test.ts @@ -0,0 +1,75 @@ +import {businessPlatformTokenRefreshHandler, storeManagementRequestDoc} from './business-platform.js' +import {describe, expect, test, vi} from 'vitest' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' +import {AbortError} from '@shopify/cli-kit/node/error' +import type {TypedDocumentNode} from '@graphql-typed-document-node/core' + +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/environment') + +const query = {} as TypedDocumentNode<{organization: {id: string}}, {id: string}> +const requestOptions = { + query, + token: 'bp-token', + organizationId: '123', + variables: {id: 'shop-id'}, + unauthorizedHandler: businessPlatformTokenRefreshHandler(), +} + +// Structural fake of graphql-request's `ClientError`, as cli-kit rethrows it when a 401 survives the refresh retry. +function clientErrorLike(status: number): Error { + return Object.assign(new Error(`GraphQL Error (Code: ${status})`), { + response: {status, headers: {}}, + request: {query: 'query { organization { id } }'}, + }) +} + +describe('storeManagementRequestDoc', () => { + test('sends the request to the Organizations API and returns its response', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue({organization: {id: '123'}}) + + const result = await storeManagementRequestDoc(requestOptions) + + expect(result).toEqual({organization: {id: '123'}}) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith(requestOptions) + }) + + test('explains that the token lacks Dev store management when an automation token gets a 401', async () => { + vi.mocked(getAppAutomationToken).mockReturnValue('service-account-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(clientErrorLike(401)) + + const error = await storeManagementRequestDoc(requestOptions).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(AbortError) + expect(error).toMatchObject({ + message: "This token can't manage dev stores.", + tryMessage: + "Use a token from a service account with Dev store management. You can create one in your organization's settings in the Shopify admin.", + }) + }) + + test('rethrows a 401 unchanged when no automation token is in use', async () => { + const unauthorized = clientErrorLike(401) + vi.mocked(getAppAutomationToken).mockReturnValue(undefined) + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(unauthorized) + + await expect(storeManagementRequestDoc(requestOptions)).rejects.toBe(unauthorized) + }) + + test('rethrows a 403 unchanged, because the Organizations API only answers 403 for a locked organization', async () => { + const forbidden = clientErrorLike(403) + vi.mocked(getAppAutomationToken).mockReturnValue('service-account-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(forbidden) + + await expect(storeManagementRequestDoc(requestOptions)).rejects.toBe(forbidden) + }) + + test('rethrows other errors unchanged while an automation token is in use', async () => { + const failure = new AbortError("The custom token provided can't be used for the Business Platform API.") + vi.mocked(getAppAutomationToken).mockReturnValue('service-account-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(failure) + + await expect(storeManagementRequestDoc(requestOptions)).rejects.toBe(failure) + }) +}) diff --git a/packages/organizations/src/cli/services/business-platform.ts b/packages/organizations/src/cli/services/business-platform.ts index d52cf98ef21..08c82bdbb7b 100644 --- a/packages/organizations/src/cli/services/business-platform.ts +++ b/packages/organizations/src/cli/services/business-platform.ts @@ -1,4 +1,10 @@ -import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' +import { + businessPlatformOrganizationsRequestDoc, + type BusinessPlatformOrganizationsRequestOptions, +} from '@shopify/cli-kit/node/api/business-platform' +import {type GraphQLVariables, type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' +import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' +import {AbortError} from '@shopify/cli-kit/node/error' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' interface BusinessPlatformTokenRefreshHandlerOptions { @@ -15,3 +21,37 @@ export function businessPlatformTokenRefreshHandler( }), } } + +/** + * Sends a store command's request to the Business Platform Organizations API. + * + * With an automation token, the Organizations API answers 401 when the token has no store management scope. The + * token exchange still succeeds, because the CLI requests no explicit scopes, so a 401 that persists after the + * refresh retry means the token's service account doesn't have Dev store management. Expired, revoked and invalid + * tokens fail earlier, at the token exchange. This turns that 401 into an explanation instead of a raw GraphQL error. + * + * @param options - The options for the request. + * @returns The response of the query. + */ +export async function storeManagementRequestDoc( + options: BusinessPlatformOrganizationsRequestOptions, +): Promise { + try { + return await businessPlatformOrganizationsRequestDoc(options) + } catch (error) { + if (isUnauthorizedResponse(error) && getAppAutomationToken()) { + throw new AbortError( + "This token can't manage dev stores.", + "Use a token from a service account with Dev store management. You can create one in your organization's settings in the Shopify admin.", + ) + } + throw error + } +} + +// Matched structurally, like the store package's Admin API error checks, so this module doesn't import graphql-request. +function isUnauthorizedResponse(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('response' in error)) return false + const response = (error as {response?: {status?: unknown}}).response + return typeof response === 'object' && response !== null && response.status === 401 +} diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.test.ts b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts index eb32b2ab673..d4e9fecd616 100644 --- a/packages/organizations/src/cli/services/dev/create-dev-store.test.ts +++ b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts @@ -1,6 +1,6 @@ import {createDevStoreJsonOutputSchema} from './types.js' import {createDevStore} from './create-dev-store.js' -import {describe, expect, test, vi, beforeEach} from 'vitest' +import {describe, expect, test, vi, afterEach, beforeEach} from 'vitest' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' @@ -115,3 +115,30 @@ test.each([undefined, null, 'https://admin.shopify.com/store/test-store'])( expect(renderSuccess).not.toHaveBeenCalled() }, ) + +describe('createDevStore with a token that lacks Dev store management', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + test('explains that the token lacks Dev store management instead of showing the raw 401', async () => { + vi.stubEnv('SHOPIFY_SERVICE_ACCOUNT_TOKEN', 'app-only-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(unauthorizedClientErrorLike()) + + await expect( + createDevStore({name: 'App-only store', organization: defaultOrg, plan: 'basic', json: false}), + ).rejects.toThrow("This token can't manage dev stores.") + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledOnce() + expect(renderSingleTask).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() + }) +}) + +// Structural fake of graphql-request's `ClientError`, as cli-kit rethrows it when a 401 survives the refresh retry. +function unauthorizedClientErrorLike(): Error { + return Object.assign(new Error('GraphQL Error (Code: 401)'), { + response: {status: 401, headers: {}}, + request: {query: 'mutation CreateAppDevelopmentStore'}, + }) +} diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.ts b/packages/organizations/src/cli/services/dev/create-dev-store.ts index db9d5eaa238..2becdb47bf4 100644 --- a/packages/organizations/src/cli/services/dev/create-dev-store.ts +++ b/packages/organizations/src/cli/services/dev/create-dev-store.ts @@ -6,8 +6,7 @@ import { PollStoreCreationQuery, } from '../../api/graphql/business-platform-organizations/generated/poll_store_creation.js' import {Organization} from '../../models/organization.js' -import {businessPlatformTokenRefreshHandler} from '../business-platform.js' -import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {businessPlatformTokenRefreshHandler, storeManagementRequestDoc} from '../business-platform.js' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputResult} from '@shopify/cli-kit/node/output' import {sleep} from '@shopify/cli-kit/node/system' @@ -60,7 +59,7 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise ({ businessPlatformOrganizationsRequestDoc: vi.fn(), @@ -320,3 +320,28 @@ function requestsWithVariable(variableName: string) { .mock.calls.map(([request]) => request) .filter((request) => variableName in (request.variables as Record)) } + +describe('deleteDevStore with a token that lacks Dev store management', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + test('explains that the token lacks Dev store management before requesting deletion', async () => { + vi.stubEnv('SHOPIFY_SERVICE_ACCOUNT_TOKEN', 'app-only-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(unauthorizedClientErrorLike()) + + await expect(deleteDevStore(defaultOptions)).rejects.toThrow("This token can't manage dev stores.") + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledOnce() + expect(requestsWithVariable('storeFqdn')).toHaveLength(0) + expect(renderSuccess).not.toHaveBeenCalled() + }) +}) + +// Structural fake of graphql-request's `ClientError`, as cli-kit rethrows it when a 401 survives the refresh retry. +function unauthorizedClientErrorLike(): Error { + return Object.assign(new Error('GraphQL Error (Code: 401)'), { + response: {status: 401, headers: {}}, + request: {query: 'query StoreInfoShop'}, + }) +} diff --git a/packages/store/src/cli/services/store/delete/dev.ts b/packages/store/src/cli/services/store/delete/dev.ts index bc23bc90c03..e543665911c 100644 --- a/packages/store/src/cli/services/store/delete/dev.ts +++ b/packages/store/src/cli/services/store/delete/dev.ts @@ -1,5 +1,5 @@ import {deleteDevStoreJsonOutputSchema} from './types.js' -import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {businessPlatformTokenRefreshHandler, storeManagementRequestDoc} from '../business-platform.js' import {fetchOptionalOrganizationShop} from '../../../utilities/store-lookup/organization-shop.js' import {DeleteAppDevelopmentStore} from '../../../api/graphql/business-platform-organizations/generated/delete_app_development_store.js' import { @@ -8,7 +8,6 @@ import { type OrganizationAccessibleShopQueryVariables, } from '../../../api/graphql/business-platform-organizations/generated/organization_accessible_shop.js' import {type Organization} from '@shopify/organizations' -import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' import {renderSingleTask, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' @@ -39,7 +38,7 @@ export async function deleteDevStore(options: DeleteDevStoreOptions): Promise({ diff --git a/packages/store/src/cli/services/store/list/bp-source.test.ts b/packages/store/src/cli/services/store/list/bp-source.test.ts index 66dde45dae9..9fba41d8ccb 100644 --- a/packages/store/src/cli/services/store/list/bp-source.test.ts +++ b/packages/store/src/cli/services/store/list/bp-source.test.ts @@ -1,6 +1,6 @@ import {listBusinessPlatformStores} from './bp-source.js' import {type ListAccessibleShopsQuery} from '../../../api/graphql/business-platform-organizations/generated/list_accessible_shops.js' -import {beforeEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' @@ -275,3 +275,26 @@ describe('listBusinessPlatformStores', () => { }) }) }) + +describe('listBusinessPlatformStores with a token that lacks Dev store management', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + test('explains that the token lacks Dev store management instead of showing the raw 401', async () => { + vi.stubEnv('SHOPIFY_SERVICE_ACCOUNT_TOKEN', 'app-only-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(unauthorizedClientErrorLike()) + + await expect(listBusinessPlatformStores({token: 'bp-token', organization})).rejects.toThrow( + "This token can't manage dev stores.", + ) + }) +}) + +// Structural fake of graphql-request's `ClientError`, as cli-kit rethrows it when a 401 survives the refresh retry. +function unauthorizedClientErrorLike(): Error { + return Object.assign(new Error('GraphQL Error (Code: 401)'), { + response: {status: 401, headers: {}}, + request: {query: 'query ListAccessibleShops'}, + }) +} diff --git a/packages/store/src/cli/services/store/list/bp-source.ts b/packages/store/src/cli/services/store/list/bp-source.ts index ada4a7831f5..35e8162583d 100644 --- a/packages/store/src/cli/services/store/list/bp-source.ts +++ b/packages/store/src/cli/services/store/list/bp-source.ts @@ -1,6 +1,6 @@ import {STORE_LIST_LIMIT} from './constants.js' import {type StoreListEntry} from './types.js' -import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {businessPlatformTokenRefreshHandler, storeManagementRequestDoc} from '../business-platform.js' import {planHandle} from '../plan.js' import {storeTypeFilterValue, storeTypeHandle, type StoreTypeFilter} from '../store-type.js' import { @@ -8,7 +8,6 @@ import { type ListAccessibleShopsQuery, } from '../../../api/graphql/business-platform-organizations/generated/list_accessible_shops.js' import {type ShopFilterInput} from '../../../api/graphql/business-platform-organizations/generated/types.js' -import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {extractHost} from '@shopify/cli-kit/common/url' import {type Organization} from '@shopify/organizations' @@ -44,7 +43,7 @@ async function fetchOrganizationStores( ): Promise<{entries: StoreListEntry[]; hasMore: boolean}> { const unauthorizedHandler = businessPlatformTokenRefreshHandler() - const result = await businessPlatformOrganizationsRequestDoc({ + const result = await storeManagementRequestDoc({ query: ListAccessibleShops, token, organizationId: organization.id, diff --git a/packages/store/src/cli/utilities/store-lookup/organization-shop.ts b/packages/store/src/cli/utilities/store-lookup/organization-shop.ts index 6bbc281feac..24dd0ab856a 100644 --- a/packages/store/src/cli/utilities/store-lookup/organization-shop.ts +++ b/packages/store/src/cli/utilities/store-lookup/organization-shop.ts @@ -1,7 +1,6 @@ -import {businessPlatformTokenRefreshHandler} from '../../services/store/business-platform.js' +import {businessPlatformTokenRefreshHandler, storeManagementRequestDoc} from '../../services/store/business-platform.js' import {StoreInfoShop} from '../../api/graphql/business-platform-organizations/generated/store-info-shop.js' import {AbortError} from '@shopify/cli-kit/node/error' -import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' import {extractHost} from '@shopify/cli-kit/common/url' import type { @@ -38,7 +37,7 @@ export async function fetchOptionalOrganizationShop( (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt, allowAutomationToken: true})) const unauthorizedHandler = businessPlatformTokenRefreshHandler({noPrompt: options.noPrompt}) - const response = await businessPlatformOrganizationsRequestDoc({ + const response = await storeManagementRequestDoc({ query: StoreInfoShop, token, organizationId: options.organizationId,