diff --git a/packages/cli-kit/src/private/node/session/exchange.test.ts b/packages/cli-kit/src/private/node/session/exchange.test.ts index 8c1b576b36f..3d4bce7cd3c 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -65,6 +65,40 @@ afterAll(() => { describe('exchange identity token for application tokens', () => { const scopes = {admin: [], partners: [], storefront: [], businessPlatform: [], appManagement: []} + test('rejects when any application token exchange fails', async () => { + vi.mocked(shopifyFetch).mockRejectedValue(new Error('exchange failed')) + + await expect(exchangeAccessForApplicationTokens(identityToken, scopes, 'storeFQDN')).rejects.toThrow( + 'exchange failed', + ) + }) + + test('sends admin destination and store parameters and uses the store-qualified key', async () => { + const requests: {body?: string}[] = [] + vi.mocked(shopifyFetch).mockImplementation(async (_url, options) => { + requests.push(options as {body?: string}) + return new Response(JSON.stringify(data)) + }) + + const result = await requestAppToken('admin', 'identity-access', ['scope-a', 'scope-b'], 'shop.myshopify.com') + + expect(result).toHaveProperty('shop.myshopify.com-admin') + const params = new URLSearchParams(requests[0]!.body) + expect(params.get('audience')).toBe('admin') + expect(params.get('scope')).toBe('scope-a scope-b') + expect(params.get('subject_token')).toBe('identity-access') + expect(params.get('destination')).toBe('https://shop.myshopify.com/admin') + expect(params.get('store')).toBe('shop.myshopify.com') + }) + + test('uses the application ID as the key for non-admin exchanges', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(data))) + + const result = await requestAppToken('partners', 'identity-access', ['scope']) + + expect(Object.keys(result)).toEqual(['partners']) + }) + test('returns tokens for all APIs if a store is passed', async () => { // Given vi.mocked(shopifyFetch).mockImplementation(async () => Promise.resolve(new Response(JSON.stringify(data)))) @@ -145,6 +179,23 @@ describe('exchange identity token for application tokens', () => { }) describe('refresh access tokens', () => { + test('sends the current access and refresh tokens and preserves user ID and alias', async () => { + let requestBody = '' + vi.mocked(shopifyFetch).mockImplementation(async (_url, options) => { + requestBody = String((options as {body?: string}).body) + return new Response(JSON.stringify({...data, access_token: 'new-access', refresh_token: 'new-refresh'})) + }) + + const result = await refreshAccessToken({...identityToken, alias: 'named account'}) + const params = new URLSearchParams(requestBody) + + expect(params.get('grant_type')).toBe('refresh_token') + expect(params.get('access_token')).toBe(identityToken.accessToken) + expect(params.get('refresh_token')).toBe(identityToken.refreshToken) + expect(params.get('client_id')).toBe('clientId') + expect(result.userId).toBe(identityToken.userId) + expect(result.alias).toBe('named account') + }) test('throws an InvalidGrantError when Identity returns invalid_grant', async () => { // Given const error = {error: 'invalid_grant'} @@ -449,6 +500,12 @@ describe('exchange device code for access token', () => { expect(result).toEqual(err('authorization_pending')) }) + test.each(['access_denied', 'expired_token', 'slow_down'])('passes %s through to the poll loop', async (error) => { + vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify({error}), {status: 400})) + + await expect(exchangeDeviceCodeForAccessToken('device_code')).resolves.toEqual(err(error as any)) + }) + test('maps an unrecognized error code to unknown_failure', async () => { // Given: Identity can return OAuth codes outside the device set, e.g. invalid_client. vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify({error: 'invalid_client'}), {status: 400})) @@ -460,6 +517,28 @@ describe('exchange device code for access token', () => { expect(result).toEqual(err('unknown_failure')) }) + test('computes expiry and scopes from a successful response and reads user ID from the JWT', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(data))) + + const result = await exchangeDeviceCodeForAccessToken('device_code') + + expect(result).toEqual(ok({...identityToken, alias: undefined})) + if (result.isErr()) throw new Error('expected a successful device exchange') + expect(result.value.expiresAt).toEqual(new Date(currentDate.getTime() + 3600 * 1000)) + expect(result.value.scopes).toEqual(['scope', 'scope2']) + expect(result.value.userId).toBe('1234-5678') + }) + + test('fails with BugError when a token has neither a JWT subject nor existing user ID', async () => { + vi.mocked(shopifyFetch).mockResolvedValue( + new Response(JSON.stringify({...data, id_token: undefined}), {status: 200}), + ) + + await expect(exchangeDeviceCodeForAccessToken('device_code')).rejects.toThrow( + 'Error setting userId for session. No id_token or pre-existing user ID provided.', + ) + }) + test('maps a response with no error field to unknown_failure', async () => { // Given: tokenRequest normalizes a missing error field to 'unknown_error', which is not // a device error code and must not leak into the poll loop. diff --git a/packages/cli-kit/src/private/node/session/schema.test.ts b/packages/cli-kit/src/private/node/session/schema.test.ts new file mode 100644 index 00000000000..8c2d0ee4cf3 --- /dev/null +++ b/packages/cli-kit/src/private/node/session/schema.test.ts @@ -0,0 +1,49 @@ +import {SessionsSchema, validateCachedIdentityTokenStructure} from './schema.js' + +import {describe, expect, test} from 'vitest' + +const identity = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: new Date('2030-01-01T00:00:00.000Z'), + scopes: ['openid'], + userId: 'user-1', + alias: 'Work', +} + +const session = { + identity, + applications: {partners: {accessToken: 'app', expiresAt: identity.expiresAt, scopes: ['scope']}}, +} + +describe('SessionsSchema', () => { + test('accepts the documented fqdn to user ID session shape', () => { + const result = SessionsSchema.safeParse({'accounts.shopify.com': {'user-1': session}}) + + expect(result.success).toBe(true) + }) + + test('round-trips dates through JSON as ISO strings', () => { + const serialized = JSON.stringify({'accounts.shopify.com': {'user-1': session}}) + const parsed = SessionsSchema.parse(JSON.parse(serialized)) + + expect(parsed['accounts.shopify.com']!['user-1']!.identity.expiresAt).toEqual(identity.expiresAt) + expect(parsed['accounts.shopify.com']!['user-1']!.applications.partners!.expiresAt).toEqual(identity.expiresAt) + }) + + test('accepts Date instances and ISO strings, but rejects invalid dates', () => { + expect(SessionsSchema.safeParse({fqdn: {user: session}}).success).toBe(true) + expect( + SessionsSchema.safeParse({fqdn: {user: {...session, identity: {...identity, expiresAt: 'not-a-date'}}}}).success, + ).toBe(false) + }) +}) + +describe('validateCachedIdentityTokenStructure', () => { + test('accepts a valid identity token and rejects malformed structures', () => { + expect(validateCachedIdentityTokenStructure(identity)).toBe(true) + expect(validateCachedIdentityTokenStructure({...identity, scopes: ['scope', 1]})).toBe(false) + expect(validateCachedIdentityTokenStructure({...identity, userId: undefined})).toBe(false) + expect(validateCachedIdentityTokenStructure(undefined)).toBe(false) + }) +}) diff --git a/packages/cli-kit/src/private/node/session/scopes.test.ts b/packages/cli-kit/src/private/node/session/scopes.test.ts index 8a42421dda5..d24384a9bd7 100644 --- a/packages/cli-kit/src/private/node/session/scopes.test.ts +++ b/packages/cli-kit/src/private/node/session/scopes.test.ts @@ -47,6 +47,36 @@ describe('allDefaultScopes', () => { }) describe('apiScopes', () => { + test.each([ + [ + 'storefront-renderer', + [ + 'https://api.shopify.com/auth/shop.storefront-renderer.devtools', + 'https://api.shopify.com/auth/shop.admin.graphql', + ], + ], + ['partners', ['https://api.shopify.com/auth/partners.app.cli.access']], + [ + 'business-platform', + [ + 'https://api.shopify.com/auth/destinations.readonly', + 'https://api.shopify.com/auth/organization.store-management', + 'https://api.shopify.com/auth/organization.on-demand-user-access', + ], + ], + ['app-management', ['https://api.shopify.com/auth/organization.apps.manage']], + ] as const)('maps all defaults for %s', (api, expected) => { + expect(apiScopes(api)).toEqual(expected) + }) + + test('deduplicates transformed defaults and custom scopes', () => { + expect(apiScopes('admin', ['graphql', 'https://api.shopify.com/auth/shop.admin.graphql'])).toEqual([ + 'https://api.shopify.com/auth/shop.admin.graphql', + 'https://api.shopify.com/auth/shop.admin.themes', + 'https://api.shopify.com/auth/partners.collaborator-relationships.readonly', + ]) + }) + // WIP test('returns all scopes for the given API including custom ones', async () => { // Given diff --git a/packages/cli-kit/src/private/node/session/validate.test.ts b/packages/cli-kit/src/private/node/session/validate.test.ts index 51df0fc5662..b9676099f5a 100644 --- a/packages/cli-kit/src/private/node/session/validate.test.ts +++ b/packages/cli-kit/src/private/node/session/validate.test.ts @@ -154,6 +154,35 @@ describe('validateSession', () => { expect(got).toBe('needs_full_auth') }) + test('returns needs_refresh when a requested application token is missing', async () => { + const session = { + identity: validIdentity, + applications: validApplications, + } + + const got = await validateSession(requestedScopes, {appManagementApi: {scopes: []}}, session) + + expect(got).toBe('needs_refresh') + }) + + test('treats a token expiring just inside the margin as expired', async () => { + const session = { + identity: {...validIdentity, expiresAt: new Date(currentDate.getTime() + 4 * 60 * 1000 - 1)}, + applications: validApplications, + } + + await expect(validateSession(requestedScopes, {}, session)).resolves.toBe('needs_refresh') + }) + + test('treats a token expiring just outside the margin as valid', async () => { + const session = { + identity: {...validIdentity, expiresAt: new Date(currentDate.getTime() + 4 * 60 * 1000 + 1)}, + applications: validApplications, + } + + await expect(validateSession(requestedScopes, {}, session)).resolves.toBe('ok') + }) + test('returns needs_refresh if identity is expired', async () => { // Given const session = {