Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
42 changes: 41 additions & 1 deletion packages/organizations/src/cli/services/business-platform.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<TResult, TVariables extends GraphQLVariables>(
options: BusinessPlatformOrganizationsRequestOptions<TResult, TVariables>,
): Promise<TResult> {
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
}
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'},
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,7 +59,7 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise<st
const token = await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true})
const unauthorizedHandler = businessPlatformTokenRefreshHandler()

const mutationResult = await businessPlatformOrganizationsRequestDoc({
const mutationResult = await storeManagementRequestDoc({
query: CreateAppDevelopmentStore,
token,
organizationId: org.id,
Expand Down Expand Up @@ -99,7 +98,7 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise<st
}

// eslint-disable-next-line no-await-in-loop
const pollResult = await businessPlatformOrganizationsRequestDoc({
const pollResult = await storeManagementRequestDoc({
query: PollStoreCreation,
token,
organizationId: org.id,
Expand Down
2 changes: 1 addition & 1 deletion packages/organizations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export {selectOrg} from './cli/services/select.js'
export {selectOrganizationPrompt} from './cli/prompts/organization.js'
export type {Organization, OrganizationWithDetails, OrganizationStatus} from './cli/models/organization.js'
export {organizationStatusValues} from './cli/models/organization.js'
export {businessPlatformTokenRefreshHandler} from './cli/services/business-platform.js'
export {businessPlatformTokenRefreshHandler, storeManagementRequestDoc} from './cli/services/business-platform.js'
export {createDevStore, devStorePlanHandles} from './cli/services/dev/create-dev-store.js'
export type {CreateDevStoreOptions, DevStorePlan} from './cli/services/dev/create-dev-store.js'
export {devStoreNamePrompt, devStorePlanPrompt, devStoreDemoDataPrompt} from './cli/prompts/dev.js'
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export {businessPlatformTokenRefreshHandler} from '@shopify/organizations'
export {businessPlatformTokenRefreshHandler, storeManagementRequestDoc} from '@shopify/organizations'
27 changes: 26 additions & 1 deletion packages/store/src/cli/services/store/delete/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session
import {renderSingleTask, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
import {outputResult} from '@shopify/cli-kit/node/output'
import {sleep} from '@shopify/cli-kit/node/system'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/api/business-platform', () => ({
businessPlatformOrganizationsRequestDoc: vi.fn(),
Expand Down Expand Up @@ -320,3 +320,28 @@ function requestsWithVariable(variableName: string) {
.mock.calls.map(([request]) => request)
.filter((request) => variableName in (request.variables as Record<string, unknown>))
}

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'},
})
}
7 changes: 3 additions & 4 deletions packages/store/src/cli/services/store/delete/dev.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'
Expand Down Expand Up @@ -39,7 +38,7 @@ export async function deleteDevStore(options: DeleteDevStoreOptions): Promise<vo
const unauthorizedHandler = businessPlatformTokenRefreshHandler()
let shopifyShopId = await fetchShopifyShopId({store, organizationId: org.id, token})

const mutationResult = await businessPlatformOrganizationsRequestDoc({
const mutationResult = await storeManagementRequestDoc({
query: DeleteAppDevelopmentStore,
token,
organizationId: org.id,
Expand Down Expand Up @@ -123,7 +122,7 @@ async function waitForStoreDeletionConfirmation(options: StoreDeletionConfirmati
}

// eslint-disable-next-line no-await-in-loop
const pollResult = await businessPlatformOrganizationsRequestDoc<
const pollResult = await storeManagementRequestDoc<
OrganizationAccessibleShopQuery,
OrganizationAccessibleShopQueryVariables
>({
Expand Down
25 changes: 24 additions & 1 deletion packages/store/src/cli/services/store/list/bp-source.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'},
})
}
5 changes: 2 additions & 3 deletions packages/store/src/cli/services/store/list/bp-source.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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 {
ListAccessibleShops,
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'

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<StoreInfoShopQuery, StoreInfoShopQueryVariables>({
const response = await storeManagementRequestDoc<StoreInfoShopQuery, StoreInfoShopQueryVariables>({
query: StoreInfoShop,
token,
organizationId: options.organizationId,
Expand Down
Loading