Skip to content
Draft
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
79 changes: 79 additions & 0 deletions packages/cli-kit/src/private/node/session/exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
Expand Down Expand Up @@ -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'}
Expand Down Expand Up @@ -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}))
Expand All @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions packages/cli-kit/src/private/node/session/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
30 changes: 30 additions & 0 deletions packages/cli-kit/src/private/node/session/scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/cli-kit/src/private/node/session/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading