diff --git a/packages/cli-kit/src/private/node/session/device-authorization.test.ts b/packages/cli-kit/src/private/node/session/device-authorization.test.ts index df6902a77a9..d591c28160f 100644 --- a/packages/cli-kit/src/private/node/session/device-authorization.test.ts +++ b/packages/cli-kit/src/private/node/session/device-authorization.test.ts @@ -14,7 +14,7 @@ import {AbortError} from '../../../public/node/error.js' import {isCI, openURL} from '../../../public/node/system.js' import * as output from '../../../public/node/output.js' -import {beforeEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {Response} from 'node-fetch' vi.mock('../../../public/node/context/fqdn.js') @@ -221,6 +221,59 @@ describe('requestDeviceAuthorization', () => { ) }) + test('when device_code is missing, throw a BugError', async () => { + // Given + const response = new Response(JSON.stringify({...data, device_code: undefined})) + vi.mocked(shopifyFetch).mockResolvedValue(response) + vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') + vi.mocked(clientId).mockReturnValue('clientId') + + // When/Then + await expect(requestDeviceAuthorization(['scope1'])).rejects.toThrowError('Failed to start authorization process') + }) + + test('when verification_uri_complete is missing, throw a BugError', async () => { + // Given + const response = new Response(JSON.stringify({...data, verification_uri_complete: undefined})) + vi.mocked(shopifyFetch).mockResolvedValue(response) + vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') + vi.mocked(clientId).mockReturnValue('clientId') + + // When/Then + await expect(requestDeviceAuthorization(['scope1'])).rejects.toThrowError('Failed to start authorization process') + }) + + test('in CI, abort before opening the browser', async () => { + // Given + vi.mocked(isCI).mockReturnValue(true) + vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(data))) + vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') + vi.mocked(clientId).mockReturnValue('clientId') + + // When/Then + await expect(requestDeviceAuthorization(['scope1'])).rejects.toThrowError( + 'Authorization is required to continue, but the current environment does not support interactive prompts.', + ) + expect(openURL).not.toHaveBeenCalled() + }) + + test('when the browser does not open, output the verification link', async () => { + // Given + vi.mocked(openURL).mockResolvedValue(false) + const outputInfo = vi.spyOn(output, 'outputInfo') + outputInfo.mockClear() + vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(data))) + vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') + vi.mocked(clientId).mockReturnValue('clientId') + + // When + await requestDeviceAuthorization(['scope1']) + + // Then + const lastInfo = outputInfo.mock.calls.at(-1)?.[0] + expect(lastInfo).toHaveProperty('value', expect.stringContaining('Open this link to start the auth process')) + }) + test('when response.text() fails, throw an error about network/streaming issue', async () => { // Given const response = new Response('some content') @@ -249,6 +302,46 @@ describe('pollForDeviceAuthorization', () => { alias: '1234-5678', } + afterEach(() => { + vi.useRealTimers() + }) + + test('respects the polling interval', async () => { + // Given + vi.useFakeTimers() + vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValue(ok(identityToken)) + + // When + const polling = pollForDeviceAuthorization('device_code', 5) + await vi.advanceTimersByTimeAsync(4999) + expect(exchangeDeviceCodeForAccessToken).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + // Then + await expect(polling).resolves.toEqual(identityToken) + expect(exchangeDeviceCodeForAccessToken).toHaveBeenCalledWith('device_code') + }) + + test('adds five seconds after a slow_down response', async () => { + // Given + vi.useFakeTimers() + vi.mocked(exchangeDeviceCodeForAccessToken) + .mockResolvedValueOnce(err('slow_down')) + .mockResolvedValueOnce(ok(identityToken)) + + // When + const polling = pollForDeviceAuthorization('device_code', 5) + await vi.advanceTimersByTimeAsync(5000) + expect(exchangeDeviceCodeForAccessToken).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(9999) + expect(exchangeDeviceCodeForAccessToken).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + + // Then + await expect(polling).resolves.toEqual(identityToken) + expect(exchangeDeviceCodeForAccessToken).toHaveBeenCalledTimes(2) + }) + test('poll until a valid token is received', async () => { // Given vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending')) 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 = {