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
69 changes: 69 additions & 0 deletions packages/app/src/cli/api/admin-as-app.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
47 changes: 47 additions & 0 deletions packages/app/src/cli/api/admin-as-app.ts
Original file line number Diff line number Diff line change
@@ -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<TResult, TVariables extends Variables> {
query: TypedDocumentNode<TResult, TVariables>
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 <T>.
*/
export async function adminAsAppRequestDoc<TResult, TVariables extends Variables>(
options: AdminAsAppRequestOptions<TResult, TVariables>,
): Promise<TResult> {
return graphqlRequestDoc<TResult, TVariables>({
query: options.query,
...(await setupAdminAsAppRequest(options.session)),
variables: options.variables,
})
}
48 changes: 48 additions & 0 deletions packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ensureAuthenticatedAdmin,
ensureAuthenticatedAdminAsApp,
ensureAuthenticatedAppManagementAndBusinessPlatform,
ensureAuthenticatedBusinessPlatform,
ensureAuthenticatedPartners,
Expand All @@ -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 {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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')
})
})
53 changes: 52 additions & 1 deletion packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -281,3 +282,53 @@ ${outputToken.json(scopes)}
export function logout(): Promise<void> {
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<AdminSession> {
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}
}