diff --git a/packages/app/src/cli/api/admin-as-app.test.ts b/packages/app/src/cli/api/admin-as-app.test.ts new file mode 100644 index 0000000000..0142c9e376 --- /dev/null +++ b/packages/app/src/cli/api/admin-as-app.test.ts @@ -0,0 +1,69 @@ +import {adminAsAppRequestDoc} from './admin-as-app.js' +import {graphqlRequestDoc} from '@shopify/cli-kit/node/api/graphql' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {describe, test, expect, vi, beforeEach} from 'vitest' +import {TypedDocumentNode} from '@graphql-typed-document-node/core' + +vi.mock('@shopify/cli-kit/node/api/graphql') + +describe('adminAsAppRequestDoc', () => { + const mockSession: AdminSession = { + token: 'test-app-token', + storeFqdn: 'test-store.myshopify.com', + } + + const mockQuery: TypedDocumentNode<{shop: {name: string}}, {id: string}> = {} as any + const mockVariables = {id: 'gid://shopify/Shop/123'} + const mockResponse = {shop: {name: 'Test Shop'}} + + beforeEach(() => { + vi.mocked(graphqlRequestDoc).mockResolvedValue(mockResponse) + }) + + test('calls graphqlRequestDoc with correct parameters', async () => { + // When + await adminAsAppRequestDoc({ + query: mockQuery, + session: mockSession, + variables: mockVariables, + }) + + // Then + expect(graphqlRequestDoc).toHaveBeenCalledWith({ + query: mockQuery, + token: 'test-app-token', + api: 'Admin', + url: 'https://test-store.myshopify.com/admin/api/unstable/graphql.json', + variables: mockVariables, + }) + }) + + test('returns the response from graphqlRequestDoc', async () => { + // When + const result = await adminAsAppRequestDoc({ + query: mockQuery, + session: mockSession, + variables: mockVariables, + }) + + // Then + expect(result).toEqual(mockResponse) + }) + + test('works without variables', async () => { + // When + await adminAsAppRequestDoc({ + query: mockQuery, + session: mockSession, + }) + + // Then + expect(graphqlRequestDoc).toHaveBeenCalledWith({ + query: mockQuery, + token: 'test-app-token', + api: 'Admin', + url: 'https://test-store.myshopify.com/admin/api/unstable/graphql.json', + variables: undefined, + }) + }) +}) diff --git a/packages/app/src/cli/api/admin-as-app.ts b/packages/app/src/cli/api/admin-as-app.ts new file mode 100644 index 0000000000..28f24d84fc --- /dev/null +++ b/packages/app/src/cli/api/admin-as-app.ts @@ -0,0 +1,47 @@ +import {graphqlRequestDoc} from '@shopify/cli-kit/node/api/graphql' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {Variables} from 'graphql-request' +import {TypedDocumentNode} from '@graphql-typed-document-node/core' + +/** + * @param query - GraphQL query to execute. + * @param session - Admin session. + * @param variables - GraphQL variables to pass to the query. + */ +interface AdminAsAppRequestOptions { + query: TypedDocumentNode + session: AdminSession + variables?: TVariables +} + +/** + * Sets up the request to the Shopify Admin API, on behalf of the app. + * + * @param session - Admin session. + */ +async function setupAdminAsAppRequest(session: AdminSession) { + const api = 'Admin' + const url = adminUrl(session.storeFqdn, 'unstable') + return { + token: session.token, + api, + url, + } +} + +/** + * Executes a GraphQL query against the Shopify Admin API, on behalf of the app. Uses typed documents. + * + * @param options - The options for the request. + * @returns The response of the query of generic type . + */ +export async function adminAsAppRequestDoc( + options: AdminAsAppRequestOptions, +): Promise { + return graphqlRequestDoc({ + query: options.query, + ...(await setupAdminAsAppRequest(options.session)), + variables: options.variables, + }) +} diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index e1e7d162fb..13c6f0fb27 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -1,5 +1,6 @@ import { ensureAuthenticatedAdmin, + ensureAuthenticatedAdminAsApp, ensureAuthenticatedAppManagementAndBusinessPlatform, ensureAuthenticatedBusinessPlatform, ensureAuthenticatedPartners, @@ -8,6 +9,7 @@ import { } from './session.js' import {getPartnersToken} from './environment.js' +import {shopifyFetch} from './http.js' import {ApplicationToken} from '../../private/node/session/schema.js' import {ensureAuthenticated, setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js' import { @@ -29,6 +31,7 @@ vi.mock('../../private/node/session.js') vi.mock('../../private/node/session/exchange.js') vi.mock('../../private/node/session/store.js') vi.mock('./environment.js') +vi.mock('./http.js') describe('ensureAuthenticatedStorefront', () => { test('returns only storefront token if success', async () => { @@ -271,3 +274,48 @@ describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { expect(ensureAuthenticated).not.toHaveBeenCalled() }) }) + +describe('ensureAuthenticatedAdminAsApp', () => { + test('returns admin token if success', async () => { + // Given + vi.mocked(shopifyFetch).mockResolvedValueOnce({ + status: 200, + json: async () => ({access_token: 'app_access_token'}), + } as any) + + // When + const got = await ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456') + + // Then + expect(got).toEqual({token: 'app_access_token', storeFqdn: 'mystore.myshopify.com'}) + }) + + test('throws error if app is not installed', async () => { + // Given + vi.mocked(shopifyFetch).mockResolvedValueOnce({ + status: 400, + text: async () => 'error: app_not_installed', + } as any) + + // When + const got = ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456') + + // Then + await expect(got).rejects.toThrow(/App is not installed/) + }) + + test('throws error on other 400 errors', async () => { + // Given + vi.mocked(shopifyFetch).mockResolvedValueOnce({ + status: 400, + statusText: 'Bad Request', + text: async () => 'invalid credentials', + } as any) + + // When + const got = ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456') + + // Then + await expect(got).rejects.toThrow('Failed to get access token for app client123 on store mystore.myshopify.com') + }) +}) diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 86173ec39f..f7550df0f5 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -1,6 +1,7 @@ -import {BugError} from './error.js' +import {AbortError, BugError} from './error.js' import {getPartnersToken} from './environment.js' import {nonRandomUUID} from './crypto.js' +import {shopifyFetch} from './http.js' import * as sessionStore from '../../private/node/session/store.js' import { exchangeCustomPartnerToken, @@ -281,3 +282,53 @@ ${outputToken.json(scopes)} export function logout(): Promise { return sessionStore.remove() } + +/** + * Ensure that we have a valid Admin session for the given store, with access on behalf of the app. + * + * See `ensureAuthenticatedAdmin` for access on behalf of a user. + * + * @param storeFqdn - Store fqdn to request auth for. + * @param clientId - Client ID of the app. + * @param clientSecret - Client secret of the app. + * @returns The access token for the Admin API. + */ +export async function ensureAuthenticatedAdminAsApp( + storeFqdn: string, + clientId: string, + clientSecret: string, +): Promise { + const bodyData = { + client_id: clientId, + client_secret: clientSecret, + grant_type: 'client_credentials', + } + const tokenResponse = await shopifyFetch( + `https://${storeFqdn}/admin/oauth/access_token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(bodyData), + }, + 'slow-request', + ) + + if (tokenResponse.status === 400) { + const body = await tokenResponse.text() + if (body.includes('app_not_installed')) { + throw new AbortError( + outputContent`App is not installed on ${outputToken.green( + storeFqdn, + )}. Try running ${outputToken.genericShellCommand(`shopify app dev`)} to connect your app to the shop.`, + ) + } + throw new AbortError( + `Failed to get access token for app ${clientId} on store ${storeFqdn}: ${tokenResponse.statusText}`, + ) + } + + const tokenJson = (await tokenResponse.json()) as {access_token: string} + return {token: tokenJson.access_token, storeFqdn} +}