Skip to content

Commit 0e7b765

Browse files
committed
implement-ensureAuthenticatedAdminAsApp-for-BulkOps-CLI
1 parent 204edc8 commit 0e7b765

File tree

5 files changed

+114
-6
lines changed

5 files changed

+114
-6
lines changed

packages/app/src/cli/commands/app/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export default class Execute extends AppLinkedCommand {
3636

3737
await executeBulkOperation({
3838
app: appContextResult.app,
39+
remoteApp: appContextResult.remoteApp,
3940
storeFqdn: store.shopDomain,
4041
query: flags.query,
4142
variables: flags.variables,

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,38 @@ import {executeBulkOperation} from './execute-bulk-operation.js'
22
import {runBulkOperationQuery} from './run-query.js'
33
import {runBulkOperationMutation} from './run-mutation.js'
44
import {AppLinkedInterface} from '../../models/app/app.js'
5+
import {OrganizationApp} from '../../models/organization.js'
56
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
6-
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
7+
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
78
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
89
import {joinPath} from '@shopify/cli-kit/node/path'
910
import {describe, test, expect, vi, beforeEach} from 'vitest'
1011

1112
vi.mock('./run-query.js')
1213
vi.mock('./run-mutation.js')
1314
vi.mock('@shopify/cli-kit/node/ui')
14-
vi.mock('@shopify/cli-kit/node/session')
15+
vi.mock('@shopify/cli-kit/node/session', async () => {
16+
const actual = await vi.importActual('@shopify/cli-kit/node/session')
17+
return {
18+
...actual,
19+
ensureAuthenticatedAdminAsApp: vi.fn(),
20+
}
21+
})
1522

1623
describe('executeBulkOperation', () => {
1724
const mockApp = {
1825
name: 'Test App',
26+
configuration: {
27+
client_id: 'test-app-client-id',
28+
},
1929
} as AppLinkedInterface
2030

31+
const mockRemoteApp = {
32+
apiKey: 'test-app-client-id',
33+
apiSecretKeys: [{secret: 'test-api-secret'}],
34+
title: 'Test App',
35+
} as OrganizationApp
36+
2137
const storeFqdn = 'test-store.myshopify.com'
2238
const mockAdminSession = {token: 'test-token', storeFqdn}
2339

@@ -32,7 +48,7 @@ describe('executeBulkOperation', () => {
3248
}
3349

3450
beforeEach(() => {
35-
vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession)
51+
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession)
3652
})
3753

3854
test('runs query operation when GraphQL document starts with query', async () => {
@@ -45,6 +61,7 @@ describe('executeBulkOperation', () => {
4561

4662
await executeBulkOperation({
4763
app: mockApp,
64+
remoteApp: mockRemoteApp,
4865
storeFqdn,
4966
query,
5067
})
@@ -66,6 +83,7 @@ describe('executeBulkOperation', () => {
6683

6784
await executeBulkOperation({
6885
app: mockApp,
86+
remoteApp: mockRemoteApp,
6987
storeFqdn,
7088
query,
7189
})
@@ -87,6 +105,7 @@ describe('executeBulkOperation', () => {
87105

88106
await executeBulkOperation({
89107
app: mockApp,
108+
remoteApp: mockRemoteApp,
90109
storeFqdn,
91110
query: mutation,
92111
})
@@ -110,6 +129,7 @@ describe('executeBulkOperation', () => {
110129

111130
await executeBulkOperation({
112131
app: mockApp,
132+
remoteApp: mockRemoteApp,
113133
storeFqdn,
114134
query: mutation,
115135
variables,
@@ -131,6 +151,7 @@ describe('executeBulkOperation', () => {
131151
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
132152
await executeBulkOperation({
133153
app: mockApp,
154+
remoteApp: mockRemoteApp,
134155
storeFqdn,
135156
query,
136157
})
@@ -154,6 +175,7 @@ describe('executeBulkOperation', () => {
154175

155176
await executeBulkOperation({
156177
app: mockApp,
178+
remoteApp: mockRemoteApp,
157179
storeFqdn,
158180
query,
159181
})
@@ -172,6 +194,7 @@ describe('executeBulkOperation', () => {
172194
await expect(
173195
executeBulkOperation({
174196
app: mockApp,
197+
remoteApp: mockRemoteApp,
175198
storeFqdn,
176199
query: malformedQuery,
177200
}),
@@ -188,6 +211,7 @@ describe('executeBulkOperation', () => {
188211
await expect(
189212
executeBulkOperation({
190213
app: mockApp,
214+
remoteApp: mockRemoteApp,
191215
storeFqdn,
192216
query: multipleOperations,
193217
}),
@@ -208,6 +232,7 @@ describe('executeBulkOperation', () => {
208232
await expect(
209233
executeBulkOperation({
210234
app: mockApp,
235+
remoteApp: mockRemoteApp,
211236
storeFqdn,
212237
query: noOperations,
213238
}),
@@ -236,6 +261,7 @@ describe('executeBulkOperation', () => {
236261

237262
await executeBulkOperation({
238263
app: mockApp,
264+
remoteApp: mockRemoteApp,
239265
storeFqdn,
240266
query: mutation,
241267
variableFile: variableFilePath,
@@ -261,6 +287,7 @@ describe('executeBulkOperation', () => {
261287
await expect(
262288
executeBulkOperation({
263289
app: mockApp,
290+
remoteApp: mockRemoteApp,
264291
storeFqdn,
265292
query: mutation,
266293
variables,
@@ -282,6 +309,7 @@ describe('executeBulkOperation', () => {
282309
await expect(
283310
executeBulkOperation({
284311
app: mockApp,
312+
remoteApp: mockRemoteApp,
285313
storeFqdn,
286314
query: mutation,
287315
variableFile: nonExistentPath,
@@ -300,6 +328,7 @@ describe('executeBulkOperation', () => {
300328
await expect(
301329
executeBulkOperation({
302330
app: mockApp,
331+
remoteApp: mockRemoteApp,
303332
storeFqdn,
304333
query,
305334
variables,
@@ -320,6 +349,7 @@ describe('executeBulkOperation', () => {
320349
await expect(
321350
executeBulkOperation({
322351
app: mockApp,
352+
remoteApp: mockRemoteApp,
323353
storeFqdn,
324354
query,
325355
variableFile: variableFilePath,

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import {runBulkOperationQuery} from './run-query.js'
22
import {runBulkOperationMutation} from './run-mutation.js'
33
import {AppLinkedInterface} from '../../models/app/app.js'
4+
import {OrganizationApp} from '../../models/organization.js'
45
import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui'
56
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
6-
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
7+
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
78
import {AbortError} from '@shopify/cli-kit/node/error'
89
import {parse} from 'graphql'
910
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
1011

1112
interface ExecuteBulkOperationInput {
1213
app: AppLinkedInterface
14+
remoteApp: OrganizationApp
1315
storeFqdn: string
1416
query: string
1517
variables?: string[]
@@ -42,13 +44,16 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string
4244
}
4345

4446
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
45-
const {app, storeFqdn, query, variables, variableFile} = input
47+
const {app, remoteApp, storeFqdn, query, variables, variableFile} = input
4648

4749
renderInfo({
4850
headline: 'Starting bulk operation.',
4951
body: `App: ${app.name}\nStore: ${storeFqdn}`,
5052
})
51-
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
53+
54+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
55+
const apiSecret = remoteApp.apiSecretKeys.find((elm) => elm.secret)!.secret
56+
const adminSession = await ensureAuthenticatedAdminAsApp(storeFqdn, app.configuration.client_id, apiSecret)
5257

5358
validateGraphQLDocument(query, variables)
5459

packages/cli-kit/src/public/node/session.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
ensureAuthenticatedAdmin,
3+
ensureAuthenticatedAdminAsApp,
34
ensureAuthenticatedAppManagementAndBusinessPlatform,
45
ensureAuthenticatedBusinessPlatform,
56
ensureAuthenticatedPartners,
@@ -8,6 +9,7 @@ import {
89
} from './session.js'
910

1011
import {getPartnersToken} from './environment.js'
12+
import {shopifyFetch} from './http.js'
1113
import {ApplicationToken} from '../../private/node/session/schema.js'
1214
import {ensureAuthenticated, setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js'
1315
import {
@@ -29,6 +31,7 @@ vi.mock('../../private/node/session.js')
2931
vi.mock('../../private/node/session/exchange.js')
3032
vi.mock('../../private/node/session/store.js')
3133
vi.mock('./environment.js')
34+
vi.mock('./http.js')
3235

3336
describe('ensureAuthenticatedStorefront', () => {
3437
test('returns only storefront token if success', async () => {
@@ -216,6 +219,39 @@ describe('ensureAuthenticatedBusinessPlatform', () => {
216219
})
217220
})
218221

222+
describe('ensureAuthenticatedAdminAsApp', () => {
223+
test('returns admin token authenticated as app using client credentials', async () => {
224+
// Given
225+
const apiKey = 'test-api-key'
226+
const apiSecret = 'test-api-secret'
227+
const store = 'mystore.myshopify.com'
228+
const mockToken = 'shpat_admin-as-app-token'
229+
230+
vi.mocked(shopifyFetch).mockResolvedValueOnce({
231+
json: async () => ({access_token: mockToken}),
232+
} as any)
233+
234+
// When
235+
const got = await ensureAuthenticatedAdminAsApp(store, apiKey, apiSecret)
236+
237+
// Then
238+
expect(got).toEqual({token: mockToken, storeFqdn: store})
239+
expect(shopifyFetch).toHaveBeenCalledWith(
240+
expect.stringContaining(`https://${store}/admin/oauth/access_token`),
241+
expect.objectContaining({
242+
method: 'POST',
243+
headers: {
244+
'Content-Type': 'application/json',
245+
},
246+
}),
247+
)
248+
const call = vi.mocked(shopifyFetch).mock.calls[0]![0]
249+
expect(call).toContain('client_id=test-api-key')
250+
expect(call).toContain('client_secret=test-api-secret')
251+
expect(call).toContain('grant_type=client_credentials')
252+
})
253+
})
254+
219255
describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => {
220256
test('returns app management and business platform tokens if success', async () => {
221257
// Given

packages/cli-kit/src/public/node/session.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {BugError} from './error.js'
22
import {getPartnersToken} from './environment.js'
33
import {nonRandomUUID} from './crypto.js'
4+
import {normalizeStoreFqdn} from './context/fqdn.js'
5+
import {shopifyFetch} from './http.js'
46
import * as sessionStore from '../../private/node/session/store.js'
57
import {
68
exchangeCustomPartnerToken,
@@ -20,6 +22,7 @@ import {
2022
setLastSeenUserIdAfterAuth,
2123
} from '../../private/node/session.js'
2224
import {isThemeAccessSession} from '../../private/node/api/rest.js'
25+
import {encode as queryStringEncode} from 'node:querystring'
2326

2427
/**
2528
* Session Object to access the Admin API, includes the token and the store FQDN.
@@ -226,6 +229,39 @@ ${outputToken.json(scopes)}
226229
return tokens.admin
227230
}
228231

232+
/**
233+
* Ensure that we have a valid Admin session for the given store, acting on behalf of the app.
234+
*
235+
* This will fail if the app has not already been installed.
236+
*
237+
* @param storeFqdn - Store fqdn to request auth for.
238+
* @param apiKey - API key for the app.
239+
* @param apiSecret - API secret for the app.
240+
* @returns The access token for the Admin API.
241+
*/
242+
export async function ensureAuthenticatedAdminAsApp(
243+
storeFqdn: string,
244+
apiKey: string,
245+
apiSecret: string,
246+
): Promise<AdminSession> {
247+
const queryString = queryStringEncode({
248+
client_id: apiKey,
249+
client_secret: apiSecret,
250+
grant_type: 'client_credentials',
251+
})
252+
const normalised = await normalizeStoreFqdn(storeFqdn)
253+
const tokenResponse = await shopifyFetch(`https://${normalised}/admin/oauth/access_token?${queryString}`, {
254+
method: 'POST',
255+
headers: {
256+
'Content-Type': 'application/json',
257+
},
258+
})
259+
const tokenJson = (await tokenResponse.json()) as {access_token: string}
260+
outputDebug(outputContent`Token: ${outputToken.raw(tokenJson.access_token)}`)
261+
const token = tokenJson.access_token
262+
return {token, storeFqdn: normalised}
263+
}
264+
229265
/**
230266
* Ensure that we have a valid session to access the Theme API.
231267
* If a password is provided, that token will be used against Theme Access API.

0 commit comments

Comments
 (0)