Skip to content

Commit 08c8de2

Browse files
authored
Merge pull request #6126 from Shopify/add-unauthorized-errors
Add error handling for unauthorized errors
2 parents b422c22 + c88369c commit 08c8de2

File tree

8 files changed

+374
-28
lines changed

8 files changed

+374
-28
lines changed

packages/store/src/lib/base-command.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {FlagOptions} from './types.js'
2-
import {checkForUndefinedFieldError} from '../services/store/utils/graphql-errors.js'
32
import Command from '@shopify/cli-kit/node/base-command'
43
import {AbortError} from '@shopify/cli-kit/node/error'
54

@@ -13,11 +12,7 @@ export abstract class BaseBDCommand extends Command {
1312
await this.runCommand()
1413
} catch (error) {
1514
if (error instanceof Error) {
16-
let errorMessage = error.message || 'An unknown error occurred'
17-
if (checkForUndefinedFieldError(error)) {
18-
errorMessage = `This command is in Early Accesss and is not yet available for the requested store(s).`
19-
}
20-
15+
const errorMessage = error.message || 'An unknown error occurred'
2116
throw new AbortError(errorMessage)
2217
} else {
2318
throw error

packages/store/src/services/store/api/api-client.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ export class ApiClient implements ApiClientInterface {
4545
this.createUnauthorizedHandler(),
4646
)
4747
} catch (error) {
48-
throw this.handleError(error, 'startBulkDataStoreCopy')
48+
throw this.handleError(error, 'startBulkDataStoreCopy', {
49+
sourceStoreName: sourceShopDomain,
50+
targetStoreName: targetShopDomain,
51+
})
4952
}
5053
}
5154

@@ -57,7 +60,9 @@ export class ApiClient implements ApiClientInterface {
5760
try {
5861
return await startBulkDataStoreExport(shopId, sourceShopDomain, token, this.createUnauthorizedHandler())
5962
} catch (error) {
60-
throw this.handleError(error, 'startBulkDataStoreExport')
63+
throw this.handleError(error, 'startBulkDataStoreExport', {
64+
storeName: sourceShopDomain,
65+
})
6166
}
6267
}
6368

@@ -78,7 +83,9 @@ export class ApiClient implements ApiClientInterface {
7883
this.createUnauthorizedHandler(),
7984
)
8085
} catch (error) {
81-
throw this.handleError(error, 'startBulkDataStoreImport')
86+
throw this.handleError(error, 'startBulkDataStoreImport', {
87+
storeName: targetShopDomain,
88+
})
8289
}
8390
}
8491

@@ -108,17 +115,64 @@ export class ApiClient implements ApiClientInterface {
108115
}
109116
}
110117

111-
private handleError(error: unknown, operationName: string): OperationError | ValidationError {
112-
if (error instanceof OperationError || error instanceof ValidationError) {
113-
return error
114-
}
118+
private handleError(
119+
error: unknown,
120+
operationName: string,
121+
params?: {[key: string]: string},
122+
): OperationError | ValidationError {
115123
if (error instanceof ClientError) {
116124
const requestId = this.extractRequestIdFromError(error)
117-
return new OperationError(operationName, ErrorCodes.GRAPHQL_API_ERROR, {}, requestId)
125+
return new OperationError(operationName, ErrorCodes.GRAPHQL_API_ERROR, params, requestId)
118126
}
127+
128+
if (error?.constructor?.name === 'GraphQLClientError') {
129+
const requestId = this.extractRequestIdFromErrorMessage(error)
130+
const errors = (error as {errors?: {extensions?: {code?: string; fieldName?: string}}[]})?.errors
131+
132+
const unauthorizedError = this.handleUnauthorizedError(errors, operationName, params, requestId)
133+
if (unauthorizedError) {
134+
return unauthorizedError
135+
}
136+
137+
if (this.isMissingEAAccess(errors)) {
138+
return new OperationError(operationName, ErrorCodes.MISSING_EA_ACCESS, params, requestId)
139+
}
140+
141+
return new OperationError(operationName, ErrorCodes.GRAPHQL_API_ERROR, params, requestId)
142+
}
143+
119144
throw error
120145
}
121146

147+
private isMissingEAAccess(errors?: {extensions?: {code?: string; fieldName?: string}}[]): boolean {
148+
return (
149+
errors?.some(
150+
(err) => err.extensions?.code === 'undefinedField' && err.extensions?.fieldName?.includes('bulkDataStore'),
151+
) ?? false
152+
)
153+
}
154+
155+
private handleUnauthorizedError(
156+
errors: {extensions?: {code?: string}}[] | undefined,
157+
operationName: string,
158+
params?: {[key: string]: string},
159+
requestId?: string,
160+
): OperationError | null {
161+
const isUnauthorized = errors?.some((err) => err.extensions?.code === 'UNAUTHORIZED') ?? false
162+
if (!isUnauthorized) {
163+
return null
164+
}
165+
166+
if (operationName === 'startBulkDataStoreExport') {
167+
return new OperationError(operationName, ErrorCodes.UNAUTHORIZED_EXPORT, params, requestId)
168+
} else if (operationName === 'startBulkDataStoreImport') {
169+
return new OperationError(operationName, ErrorCodes.UNAUTHORIZED_IMPORT, params, requestId)
170+
} else if (operationName === 'startBulkDataStoreCopy') {
171+
return new OperationError(operationName, ErrorCodes.UNAUTHORIZED_COPY, params, requestId)
172+
}
173+
return new OperationError(operationName, ErrorCodes.UNAUTHORIZED, params, requestId)
174+
}
175+
122176
private extractRequestIdFromError(error: unknown): string | undefined {
123177
if (error && typeof error === 'object' && 'response' in error) {
124178
const response = (error as {response?: {headers?: {get?: (key: string) => string | null}}}).response
@@ -128,4 +182,14 @@ export class ApiClient implements ApiClientInterface {
128182
}
129183
return undefined
130184
}
185+
186+
private extractRequestIdFromErrorMessage(error: unknown): string | undefined {
187+
if (error && typeof error === 'object' && 'message' in error) {
188+
const message = (error as {message?: string}).message
189+
if (message) {
190+
return message.match(/Request ID: ([\w-]+)/)?.[1] ?? undefined
191+
}
192+
}
193+
return undefined
194+
}
131195
}

packages/store/src/services/store/errors/errors.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ describe('Error message generation', () => {
8282
'Network error',
8383
)
8484
expect(new OperationError('upload', ErrorCodes.STAGED_UPLOAD_FAILED).message).toBe('Failed to create staged upload')
85+
86+
// Unauthorized errors
87+
expect(
88+
new OperationError('export', ErrorCodes.UNAUTHORIZED_EXPORT, {storeName: 'test-store.myshopify.com'}).message,
89+
).toBe(
90+
"You are not authorized to export bulk data from store \"test-store.myshopify.com\"\n\nTo export you'll need the 'bulk data > export' permission",
91+
)
92+
expect(
93+
new OperationError('import', ErrorCodes.UNAUTHORIZED_IMPORT, {storeName: 'test-store.myshopify.com'}).message,
94+
).toBe(
95+
"You are not authorized to import bulk data to store \"test-store.myshopify.com\"\n\nTo import you'll need the 'bulk data > import' permission",
96+
)
97+
expect(
98+
new OperationError('copy', ErrorCodes.UNAUTHORIZED_COPY, {
99+
sourceStoreName: 'source.myshopify.com',
100+
targetStoreName: 'target.myshopify.com',
101+
}).message,
102+
).toBe(
103+
"You are not authorized to copy data between these stores\n\nTo export data from \"source.myshopify.com\"\n• You'll need the 'bulk data > export' permission\n\nTo import data to \"target.myshopify.com\"\n• You'll need the 'bulk data > import' permission",
104+
)
85105
})
86106

87107
test('should generate correct GRAPHQL_API_ERROR messages with request IDs', () => {
@@ -113,3 +133,96 @@ describe('OperationError with Request ID', () => {
113133
expect(error.message).toContain('Request Id: unknown')
114134
})
115135
})
136+
137+
describe('Unauthorized Error Codes', () => {
138+
test('should create UNAUTHORIZED_EXPORT error with store name', () => {
139+
const error = new OperationError('startBulkDataStoreExport', ErrorCodes.UNAUTHORIZED_EXPORT, {
140+
storeName: 'test-store.myshopify.com',
141+
})
142+
143+
expect(error).toBeInstanceOf(OperationError)
144+
expect(error.operation).toBe('startBulkDataStoreExport')
145+
expect(error.code).toBe(ErrorCodes.UNAUTHORIZED_EXPORT)
146+
expect(error.params).toEqual({storeName: 'test-store.myshopify.com'})
147+
expect(error.message).toBe(
148+
"You are not authorized to export bulk data from store \"test-store.myshopify.com\"\n\nTo export you'll need the 'bulk data > export' permission",
149+
)
150+
})
151+
152+
test('should create UNAUTHORIZED_IMPORT error with store name', () => {
153+
const error = new OperationError('startBulkDataStoreImport', ErrorCodes.UNAUTHORIZED_IMPORT, {
154+
storeName: 'target-store.myshopify.com',
155+
})
156+
157+
expect(error).toBeInstanceOf(OperationError)
158+
expect(error.operation).toBe('startBulkDataStoreImport')
159+
expect(error.code).toBe(ErrorCodes.UNAUTHORIZED_IMPORT)
160+
expect(error.params).toEqual({storeName: 'target-store.myshopify.com'})
161+
expect(error.message).toBe(
162+
"You are not authorized to import bulk data to store \"target-store.myshopify.com\"\n\nTo import you'll need the 'bulk data > import' permission",
163+
)
164+
})
165+
166+
test('should create UNAUTHORIZED_COPY error with source and target store names', () => {
167+
const error = new OperationError('startBulkDataStoreCopy', ErrorCodes.UNAUTHORIZED_COPY, {
168+
sourceStoreName: 'source.myshopify.com',
169+
targetStoreName: 'target.myshopify.com',
170+
})
171+
172+
expect(error).toBeInstanceOf(OperationError)
173+
expect(error.operation).toBe('startBulkDataStoreCopy')
174+
expect(error.code).toBe(ErrorCodes.UNAUTHORIZED_COPY)
175+
expect(error.params).toEqual({
176+
sourceStoreName: 'source.myshopify.com',
177+
targetStoreName: 'target.myshopify.com',
178+
})
179+
expect(error.message).toBe(
180+
"You are not authorized to copy data between these stores\n\nTo export data from \"source.myshopify.com\"\n• You'll need the 'bulk data > export' permission\n\nTo import data to \"target.myshopify.com\"\n• You'll need the 'bulk data > import' permission",
181+
)
182+
})
183+
184+
test('should create unauthorized errors with request IDs', () => {
185+
const exportError = new OperationError(
186+
'startBulkDataStoreExport',
187+
ErrorCodes.UNAUTHORIZED_EXPORT,
188+
{
189+
storeName: 'test-store.myshopify.com',
190+
},
191+
'export-request-123',
192+
)
193+
194+
expect(exportError.requestId).toBe('export-request-123')
195+
expect(exportError.message).toBe(
196+
"You are not authorized to export bulk data from store \"test-store.myshopify.com\"\n\nTo export you'll need the 'bulk data > export' permission",
197+
)
198+
199+
const importError = new OperationError(
200+
'startBulkDataStoreImport',
201+
ErrorCodes.UNAUTHORIZED_IMPORT,
202+
{
203+
storeName: 'test-store.myshopify.com',
204+
},
205+
'import-request-456',
206+
)
207+
208+
expect(importError.requestId).toBe('import-request-456')
209+
expect(importError.message).toBe(
210+
"You are not authorized to import bulk data to store \"test-store.myshopify.com\"\n\nTo import you'll need the 'bulk data > import' permission",
211+
)
212+
213+
const copyError = new OperationError(
214+
'startBulkDataStoreCopy',
215+
ErrorCodes.UNAUTHORIZED_COPY,
216+
{
217+
sourceStoreName: 'source.myshopify.com',
218+
targetStoreName: 'target.myshopify.com',
219+
},
220+
'copy-request-789',
221+
)
222+
223+
expect(copyError.requestId).toBe('copy-request-789')
224+
expect(copyError.message).toBe(
225+
"You are not authorized to copy data between these stores\n\nTo export data from \"source.myshopify.com\"\n• You'll need the 'bulk data > export' permission\n\nTo import data to \"target.myshopify.com\"\n• You'll need the 'bulk data > import' permission",
226+
)
227+
})
228+
})

packages/store/src/services/store/errors/errors.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export const ErrorCodes = {
2323
FILE_DOWNLOAD_FAILED: 'FILE_DOWNLOAD_FAILED',
2424
STAGED_UPLOAD_FAILED: 'STAGED_UPLOAD_FAILED',
2525
GRAPHQL_API_ERROR: 'GRAPHQL_API_ERROR',
26+
27+
// Unauthorized errors
28+
UNAUTHORIZED: 'UNAUTHORIZED',
29+
UNAUTHORIZED_EXPORT: 'UNAUTHORIZED_EXPORT',
30+
UNAUTHORIZED_IMPORT: 'UNAUTHORIZED_IMPORT',
31+
UNAUTHORIZED_COPY: 'UNAUTHORIZED_COPY',
32+
MISSING_EA_ACCESS: 'MISSING_EA_ACCESS',
2633
} as const
2734

2835
interface ErrorParams {
@@ -110,6 +117,22 @@ function generateErrorMessage(code: string, params?: ErrorParams, requestId?: st
110117
return `Copy could not complete due to an API request failure\n\nRequest Id: ${finalRequestId}`
111118
}
112119

120+
// Unauthorized errors
121+
case ErrorCodes.UNAUTHORIZED:
122+
return 'You are not authorized to perform this operation'
123+
case ErrorCodes.UNAUTHORIZED_EXPORT:
124+
return `You are not authorized to export bulk data from store "${params?.storeName}"\n\nTo export you'll need the 'bulk data > export' permission`
125+
case ErrorCodes.UNAUTHORIZED_IMPORT:
126+
return `You are not authorized to import bulk data to store "${params?.storeName}"\n\nTo import you'll need the 'bulk data > import' permission`
127+
case ErrorCodes.UNAUTHORIZED_COPY:
128+
return (
129+
`You are not authorized to copy data between these stores\n\nTo export data from "${params?.sourceStoreName}"\n` +
130+
`• You'll need the 'bulk data > export' permission\n\nTo import data to "${params?.targetStoreName}"\n` +
131+
`• You'll need the 'bulk data > import' permission`
132+
)
133+
case ErrorCodes.MISSING_EA_ACCESS:
134+
return `This command is in Early Access and is not yet available for the requested store(s).`
135+
113136
default:
114137
return 'An error occurred'
115138
}

packages/store/src/services/store/operations/store-copy.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,5 +242,60 @@ describe('StoreCopyOperation', () => {
242242
expect(error.requestId).toBeUndefined()
243243
expect(error.code).toBe(ErrorCodes.GRAPHQL_API_ERROR)
244244
})
245+
246+
test('should get copy-specific unauthorized error from API client', async () => {
247+
const unauthorizedError = new OperationError(
248+
'startBulkDataStoreCopy',
249+
ErrorCodes.UNAUTHORIZED_COPY,
250+
{sourceStoreName: 'source.myshopify.com', targetStoreName: 'target.myshopify.com'},
251+
'copy-request-unauthorized-789',
252+
)
253+
mockApiClient.startBulkDataStoreCopy.mockRejectedValue(unauthorizedError)
254+
255+
vi.mocked(renderTasks).mockImplementationOnce(async (tasks: any[]) => {
256+
const ctx: any = {}
257+
for (const task of tasks) {
258+
// eslint-disable-next-line no-await-in-loop
259+
await task.task(ctx, task)
260+
}
261+
return ctx
262+
})
263+
264+
const promise = operation.execute('source.myshopify.com', 'target.myshopify.com', {'no-prompt': true})
265+
await expect(promise).rejects.toThrow(OperationError)
266+
await expect(promise).rejects.toMatchObject({
267+
operation: 'startBulkDataStoreCopy',
268+
code: ErrorCodes.UNAUTHORIZED_COPY,
269+
params: {sourceStoreName: 'source.myshopify.com', targetStoreName: 'target.myshopify.com'},
270+
requestId: 'copy-request-unauthorized-789',
271+
})
272+
})
273+
274+
test('should get missing EA access error from API client', async () => {
275+
const missingEAError = new OperationError(
276+
'startBulkDataStoreCopy',
277+
ErrorCodes.MISSING_EA_ACCESS,
278+
{},
279+
'copy-request-ea-789',
280+
)
281+
mockApiClient.startBulkDataStoreCopy.mockRejectedValue(missingEAError)
282+
283+
vi.mocked(renderTasks).mockImplementationOnce(async (tasks: any[]) => {
284+
const ctx: any = {}
285+
for (const task of tasks) {
286+
// eslint-disable-next-line no-await-in-loop
287+
await task.task(ctx, task)
288+
}
289+
return ctx
290+
})
291+
292+
const promise = operation.execute('source.myshopify.com', 'target.myshopify.com', {'no-prompt': true})
293+
await expect(promise).rejects.toThrow(OperationError)
294+
await expect(promise).rejects.toMatchObject({
295+
operation: 'startBulkDataStoreCopy',
296+
code: ErrorCodes.MISSING_EA_ACCESS,
297+
requestId: 'copy-request-ea-789',
298+
})
299+
})
245300
})
246301
})

0 commit comments

Comments
 (0)