Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/service-account-store-access-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': patch
'@shopify/cli': patch
---

Explain which access is missing when a token can't manage dev stores, instead of printing a raw GraphQL 401 with the query and a stack trace.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {getUploadURL, writeManifestToBundle} from '../../../bundle.js'
import {formData} from '@shopify/cli-kit/node/http'
import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest'
import {AbortController} from '@shopify/cli-kit/node/abort'
import {AbortError} from '@shopify/cli-kit/node/error'
import {flushPromises} from '@shopify/cli-kit/node/promises'
import * as outputContext from '@shopify/cli-kit/node/ui/components'
import {readdir} from '@shopify/cli-kit/node/fs'
Expand Down Expand Up @@ -168,6 +169,27 @@ describe('pushUpdatesForDevSession', () => {
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Test error'))
})

test('asks to restart app dev when the upload URL request is still unauthorized after a token refresh', async () => {
// Given: cli-kit reports a 401 that survives the token refresh with its status code.
vi.mocked(getUploadURL).mockRejectedValue(
Object.assign(
new AbortError('The App Management GraphQL API responded unsuccessfully with the HTTP status 401.'),
{
statusCode: 401,
},
),
)

// When
await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options)
const [onStart] = appWatcher.listeners('ready') as ((event: unknown) => Promise<void>)[]

// Then
await expect(onStart!({app, extensionEvents: []})).rejects.toThrow(
new AbortError('Auth session expired. Please run `shopify app dev` again.'),
)
})

test('handles receiving an event before session is ready', async () => {
// When
await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,10 @@ export class DevSession {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
// cli-kit reports a 401 that survives the token refresh with its `statusCode`; a raw graphql-request error
// carries the status on its response.
if (error.statusCode === 401) {
throw new Error('Unauthorized')
throw new AbortError('Auth session expired. Please run `shopify app dev` again.')
} else if (error instanceof ClientError) {
if (error.response.status === 401 || error.response.status === 403) {
throw new AbortError('Auth session expired. Please run `shopify app dev` again.')
Expand Down
3 changes: 3 additions & 0 deletions packages/cli-kit/src/private/node/api/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ export function errorHandler(api: string): (error: unknown, requestId?: string)
errorMessage = extractedMessages
} else if (extractedMessages && status >= 500) {
errorMessage = `The ${api} GraphQL API responded with HTTP status ${status}: ${extractedMessages}`
} else if (error.response.errors === undefined) {
// A 401 from an API gateway, for example, has no GraphQL body to quote.
errorMessage = `The ${api} GraphQL API responded unsuccessfully with the HTTP status ${status}.`
} else {
errorMessage = stringifyMessage(outputContent`
The ${outputToken.raw(api)} GraphQL API responded unsuccessfully with${
Expand Down
73 changes: 73 additions & 0 deletions packages/cli-kit/src/public/node/api/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ const handlers = [
},
)
}),
mockApi.query('FailsWithUnauthorizedAndNoBody', () => {
// A gateway's 401 has no GraphQL body, so the typed `HttpResponse.json` doesn't fit here.
return new HttpResponse(null, {
status: 401,
headers: {
'x-request-id': 'unauthorized-request-id',
},
})
}),
mockApi.query('FailsWithBadRequest', () => {
return HttpResponse.json(
{
Expand Down Expand Up @@ -390,6 +399,70 @@ describe('graphqlRequest', () => {
expect(requestCount).toBe(2)
})

describe('a 401 that survives the token refresh', () => {
const query = 'query FailsWithUnauthorizedAndNoBody { example(secret: $secret) }'
const variables = {secret: 'do-not-print-me'}

test('reports the status without the request when no token refresh function is provided', async () => {
const res = graphqlRequest({query, api: 'mockApi', url: mockedAddress, token: mockToken, variables})

await expect(res).rejects.toMatchObject({
statusCode: 401,
message:
'The mockApi GraphQL API responded unsuccessfully with the HTTP status 401.\n\nRequest ID: unauthorized-request-id',
})
await expect(res).rejects.not.toThrow(/do-not-print-me|example\(secret/)
})

test('reports the status without the request when the token cannot be refreshed', async () => {
const res = graphqlRequest({
query,
api: 'mockApi',
url: mockedAddress,
token: mockToken,
variables,
unauthorizedHandler: {type: 'token_refresh', handler: async () => ({token: undefined})},
})

await expect(res).rejects.toMatchObject({statusCode: 401})
await expect(res).rejects.not.toThrow(/do-not-print-me|example\(secret/)
})

test('reports the status without the request when the refreshed token is rejected too', async () => {
const res = graphqlRequest({
query,
api: 'mockApi',
url: mockedAddress,
token: mockToken,
variables,
unauthorizedHandler: {type: 'token_refresh', handler: async () => ({token: 'refreshed-token'})},
})

let requestCount = 0
server.events.on('request:start', () => {
requestCount++
})

await expect(res).rejects.toMatchObject({statusCode: 401})
await expect(res).rejects.not.toThrow(/do-not-print-me|example\(secret/)
expect(requestCount).toBe(2)
})

test('keeps the raw error when the caller handles errors itself', async () => {
const res = graphqlRequest({
query,
api: 'mockApi',
url: mockedAddress,
token: mockToken,
variables,
responseOptions: {handleErrors: false},
unauthorizedHandler: {type: 'token_refresh', handler: async () => ({token: 'refreshed-token'})},
})

await expect(res).rejects.toMatchObject({response: {status: 401}})
})
})

test('Fails without retry when bad request when unauthorized handler is provided', async () => {
const res = graphqlRequest({
query: 'query FailsWithBadRequest { example }',
Expand Down
36 changes: 29 additions & 7 deletions packages/cli-kit/src/public/node/api/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,18 @@ async function performGraphQLRequest<TResult>(options: PerformGraphQLRequestOpti
}
: undefined

const handleErrors = responseOptions?.handleErrors !== false

const request = () =>
retryAwareRequest(
{request: rawGraphQLRequest, url, ...behaviour},
responseOptions?.handleErrors === false ? undefined : errorHandler(api),
)
retryAwareRequest({request: rawGraphQLRequest, url, ...behaviour}, handleErrors ? errorHandler(api) : undefined)

// `retryAwareRequest` hands a 401 back raw so the token can be refreshed and the request repeated. Once that is
// settled, the error goes through the same handler as every other failed response: a raw graphql-request error
// carries the whole request, query text included, and would otherwise reach the user as a CLI bug.
const unauthorizedError = (error: unknown): unknown => {
if (!handleErrors || !(error instanceof ClientError) || error.response.status !== 401) return error
return errorHandler(api)(error, requestIdFromClientError(error))
}

const executeWithTimer = () =>
runWithTimer('cmd_all_timing_network_ms')(async () => {
Expand All @@ -229,12 +236,16 @@ async function performGraphQLRequest<TResult>(options: PerformGraphQLRequestOpti
} catch (error) {
if (error instanceof ClientError && error.response.status === 401 && tokenRefreshUnauthorizedHandlerFunction) {
if (await tokenRefreshUnauthorizedHandlerFunction()) {
response = await request()
try {
response = await request()
} catch (retryError) {
throw unauthorizedError(retryError)
}
} else {
throw error
throw unauthorizedError(error)
}
} else {
throw error
throw unauthorizedError(error)
}
}

Expand Down Expand Up @@ -274,6 +285,17 @@ async function performGraphQLRequest<TResult>(options: PerformGraphQLRequestOpti
return JSON.parse(result) as TResult
}

function requestIdFromClientError(error: ClientError): string | undefined {
try {
// graphql-request types the response without headers, but it carries the fetch Response's headers.
const headers = (error.response as {headers?: {get?: (name: string) => string | null}}).headers
return headers?.get?.('x-request-id') ?? undefined
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
return undefined
}
}

function graphQLCachePrincipalHash(url: string, token: string | undefined): string {
return sha256(JSON.stringify([url, token ?? ''])).toString('hex')
}
Expand Down
125 changes: 125 additions & 0 deletions packages/organizations/src/cli/services/business-platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
isOrganizationAccessError,
OrganizationStoreAccessError,
organizationStoresRequestDoc,
} from './business-platform.js'
import {describe, expect, test, vi, beforeEach} from 'vitest'
import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
import {getAppAutomationToken} from '@shopify/cli-kit/node/environment'
import {isServiceAccountTokenSet} from '@shopify/cli-kit/node/session'
import {AbortError} from '@shopify/cli-kit/node/error'

vi.mock('@shopify/cli-kit/node/environment')
vi.mock('@shopify/cli-kit/node/api/business-platform')
vi.mock('@shopify/cli-kit/node/session')

const organization = {id: '299069', businessName: 'A Better Looking Shop'}

// The shape cli-kit gives a handled 4xx response: a `GraphQLClientError`, which carries `statusCode`.
function handledClientError(statusCode: number): Error {
return Object.assign(new Error(`The Business Platform GraphQL API responded with the HTTP status ${statusCode}.`), {
statusCode,
})
}

// The shape graphql-request throws when the caller handles errors itself.
function rawClientError(status: number): Error {
return Object.assign(new Error('GraphQL Error (Code: 401)'), {response: {status, errors: undefined}, request: {}})
}

beforeEach(() => {
vi.mocked(isServiceAccountTokenSet).mockReturnValue(false)
vi.mocked(getAppAutomationToken).mockReturnValue(undefined)
})

describe('isOrganizationAccessError', () => {
test('recognises a handled 401 and 403', () => {
expect(isOrganizationAccessError(handledClientError(401))).toBe(true)
expect(isOrganizationAccessError(handledClientError(403))).toBe(true)
})

test('recognises a raw graphql-request 401', () => {
expect(isOrganizationAccessError(rawClientError(401))).toBe(true)
})

test('ignores other statuses and other errors', () => {
expect(isOrganizationAccessError(handledClientError(404))).toBe(false)
expect(isOrganizationAccessError(handledClientError(500))).toBe(false)
expect(isOrganizationAccessError(new Error('boom'))).toBe(false)
expect(isOrganizationAccessError(undefined)).toBe(false)
})
})

describe('OrganizationStoreAccessError', () => {
test('tells a service account which access it lacks and where to grant it', () => {
vi.mocked(isServiceAccountTokenSet).mockReturnValue(true)

const error = new OrganizationStoreAccessError(organization)

expect(error).toBeInstanceOf(AbortError)
expect(error.message).toBe("This service account can't manage dev stores in A Better Looking Shop.")
// The guidance lives in nextSteps, the field the store commands' --json output carries.
expect(error.tryMessage).toBeNull()
expect(error.nextSteps).toEqual([
'Give the service account Dev store management access in the Shopify admin under Settings > Users > Security.',
'Or use a token from a service account that has that access.',
])
})

test('points a legacy automation token at service accounts', () => {
vi.mocked(getAppAutomationToken).mockReturnValue('legacy-token')

const error = new OrganizationStoreAccessError(organization)

expect(error.message).toBe("This automation token can't manage dev stores in A Better Looking Shop.")
expect(error.nextSteps?.join(' ')).toContain('SHOPIFY_SERVICE_ACCOUNT_TOKEN')
})

test('talks about permission for a logged-in user', () => {
const error = new OrganizationStoreAccessError(organization)

expect(error.message).toBe("You don't have permission to manage dev stores in A Better Looking Shop.")
expect(error.nextSteps).toEqual([
"Check that you're logged in to the right account.",
'Or ask an organization admin for access.',
])
})

test('falls back to the organization ID when the name is unknown', () => {
const error = new OrganizationStoreAccessError({id: '299069'})

expect(error.message).toBe("You don't have permission to manage dev stores in organization 299069.")
})
})

describe('organizationStoresRequestDoc', () => {
const request = {
query: {} as never,
token: 'token',
variables: {first: 1},
unauthorizedHandler: {type: 'token_refresh' as const, handler: async () => ({token: 'refreshed'})},
}

test("sends the request to the organization's Organizations API and returns the result", async () => {
vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue({ok: true})

await expect(organizationStoresRequestDoc(organization, request)).resolves.toEqual({ok: true})
expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith({...request, organizationId: '299069'})
})

test('replaces a refused request with the access error', async () => {
vi.mocked(isServiceAccountTokenSet).mockReturnValue(true)
vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(handledClientError(401))

await expect(organizationStoresRequestDoc(organization, request)).rejects.toThrow(
"This service account can't manage dev stores in A Better Looking Shop.",
)
})

test('rethrows every other error unchanged', async () => {
const notFound = handledClientError(404)
vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue(notFound)

await expect(organizationStoresRequestDoc(organization, request)).rejects.toBe(notFound)
})
})
Loading
Loading