import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createTestClient } from './helpers/createClient.js'; import { RudderNetworkError, RudderAuthError } from '../src/client/RudderError.js'; function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200 }); } describe('AuthService', () => { beforeEach(() => { // Mock crypto.randomUUID for deterministic device ID. vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-a000-000000000001', }); localStorage.clear(); }); it('loginWithDevice sends correct request body', async () => { const client = createTestClient(); const fetchMock = vi.fn().mockImplementation((url: string) => { if (url.includes('/sdk/v1/remote-configs')) { return Promise.resolve(jsonResponse({ configs: {} })); } return Promise.resolve(jsonResponse({ accessToken: 'access-token-123', refreshToken: 'refresh-token-456', })); }); vi.stubGlobal('fetch', fetchMock); const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' }); expect(fetchMock).toHaveBeenCalledTimes(8); const [url, init] = fetchMock.mock.calls[0]; expect(url).toContain('/sdk/v1/authorization/device'); const body = JSON.parse(init.body); expect(body.key).toBe('test-project-key'); expect(body.deviceId).toBe('00000000-0000-4000-a000-000000000001'); expect(body.region).toBe('us'); expect(body.language).toBe('en'); expect(response.accessToken).toBe('access-token-123'); expect(response.refreshToken).toBe('refresh-token-456'); expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([ '/sdk/v1/authorization/device', '/sdk/v1/remote-configs', '/sdk/v1/player/information', '/sdk/v1/stores', '/sdk/v1/catalog', '/sdk/v1/scenarios/trigger', // The login event invalidates the warmed profile → refetch. '/sdk/v1/player/information', // Sync engine baseline poll, fired right after login. '/sdk/v1/sync', ]); }); it('loginWithDevice saves tokens on success', async () => { const client = createTestClient(); vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { if (url.includes('/sdk/v1/remote-configs')) { return Promise.resolve(jsonResponse({ configs: {} })); } return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt', })); })); await client.auth.loginWithDevice(); expect(client.options.tokenStore.getAccessToken()).toBe('at'); expect(client.options.tokenStore.getRefreshToken()).toBe('rt'); }); it('loginWithDevice throws RudderNetworkError on network failure', async () => { const client = createTestClient(); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Offline'))); await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderNetworkError); }); it('loginWithDevice throws RudderAuthError on 401', async () => { const client = createTestClient(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }), )); await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderAuthError); }); it('logout clears tokens', () => { const client = createTestClient(); client.options.tokenStore.saveTokens('at', 'rt'); client.auth.logout(); expect(client.options.tokenStore.getAccessToken()).toBeNull(); expect(client.options.tokenStore.getRefreshToken()).toBeNull(); }); it('loginWithDevice sends token in Authorization header after login', async () => { const client = createTestClient(); const fetchMock = vi.fn().mockImplementation((url: string) => { if (url.includes('/sdk/v1/authorization/device')) { return Promise.resolve(jsonResponse({ accessToken: 'at2', refreshToken: 'rt2', })); } if (url.includes('/sdk/v1/remote-configs')) { return Promise.resolve(jsonResponse({ configs: {} })); } return Promise.resolve(jsonResponse({ player: null, wallets: [] })); }); vi.stubGlobal('fetch', fetchMock); await client.auth.loginWithDevice(); // Subsequent authenticated calls should include the token. const response = await client.player.reload(); expect(response).toEqual({ player: null, wallets: [] }); const playerCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/sdk/v1/player/information'), ); const headers = playerCall?.[1].headers; expect(headers['Authorization']).toBe('Bearer at2'); }); it('loginWithDevice only fires player_login from the client runtime', async () => { const client = createTestClient(); const fetchMock = vi.fn().mockImplementation((url: string) => { if (url.includes('/sdk/v1/authorization/device')) { return Promise.resolve(jsonResponse({ accessToken: 'at-new', refreshToken: 'rt-new', })); } if (url.includes('/sdk/v1/remote-configs')) { return Promise.resolve(jsonResponse({ configs: {} })); } if (url.includes('/sdk/v1/scenarios/trigger')) { return Promise.resolve(jsonResponse({ plans: [] })); } if (url.includes('/sdk/v1/stores')) { return Promise.resolve(jsonResponse({ stores: [], total: 0 })); } return Promise.resolve(jsonResponse({ player: null, wallets: [] })); }); vi.stubGlobal('fetch', fetchMock); await client.auth.loginWithDevice(); const triggerBodies = fetchMock.mock.calls .filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger')) .map(([, init]) => JSON.parse(init.body)); expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']); }); it('loginWithDevice isolates post-login scenario trigger failures', async () => { const client = createTestClient(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { if (url.includes('/sdk/v1/authorization/device')) { return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt', })); } if (url.includes('/sdk/v1/remote-configs')) { return Promise.resolve(jsonResponse({ configs: {} })); } if (url.includes('/sdk/v1/scenarios/trigger')) { return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' })); } if (url.includes('/sdk/v1/stores')) { return Promise.resolve(jsonResponse({ stores: [], total: 0 })); } return Promise.resolve(jsonResponse({ player: null, wallets: [] })); })); await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' }); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); it('loginWithDevice defaults to region global and language en', async () => { const client = createTestClient(); const fetchMock = vi.fn().mockImplementation((url: string) => { if (url.includes('/sdk/v1/remote-configs')) { return Promise.resolve(jsonResponse({ configs: {} })); } return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' })); }); vi.stubGlobal('fetch', fetchMock); await client.auth.loginWithDevice(); const [, init] = fetchMock.mock.calls[0]; const body = JSON.parse(init.body); expect(body.region).toBe('global'); expect(body.language).toBe('en'); }); }); describe('auth state', () => { beforeEach(() => { vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-a000-000000000001', }); localStorage.clear(); }); function stubLoginFlow(): void { vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { if (url.includes('/sdk/v1/remote-configs')) { return Promise.resolve(jsonResponse({ configs: {} })); } if (url.includes('/sdk/v1/scenarios/trigger')) { return Promise.resolve(jsonResponse({ plans: [] })); } return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' })); })); } it('onAuthStateChange fires immediately, then on login and logout', async () => { const client = createTestClient(); stubLoginFlow(); const states: string[] = []; const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state)); expect(states).toEqual(['signed-out']); expect(client.auth.isAuthenticated).toBe(false); await client.auth.loginWithDevice(); expect(states).toEqual(['signed-out', 'signed-in']); expect(client.auth.isAuthenticated).toBe(true); client.auth.logout(); expect(states).toEqual(['signed-out', 'signed-in', 'signed-out']); expect(client.auth.isAuthenticated).toBe(false); unsubscribe(); client.auth.logout(); expect(states).toHaveLength(3); }); it('starts signed-in when the token store already holds a token', () => { const client = createTestClient(); client.options.tokenStore.saveTokens('at', 'rt'); const states: string[] = []; client.auth.onAuthStateChange((state) => states.push(state)); expect(states).toEqual(['signed-in']); }); it('emits signed-out when the transport session expires (refresh fails)', async () => { const client = createTestClient(); client.options.tokenStore.saveTokens('expired-at', 'expired-rt'); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }), )); const states: string[] = []; client.auth.onAuthStateChange((state) => states.push(state)); expect(states).toEqual(['signed-in']); await expect(client.player.reload()).rejects.toThrow(RudderAuthError); expect(states).toEqual(['signed-in', 'signed-out']); expect(client.auth.isAuthenticated).toBe(false); }); });