Files
rudder-js-sdk/test/AuthService.test.ts
T
edmand46 1491b5e357 Server-side scenario execution: thin effects client replaces local engine
- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph
- trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled
  (30s jittered heartbeat paused on hidden tab + wait-deadline timers)
- client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest)
- planStateStore removed; loginEvent kept; reconcile() makes server the source of truth
- generated models regenerated; skills/README/CHANGELOG updated
2026-09-04 14:08:48 +03:00

264 lines
9.8 KiB
TypeScript

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(9);
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/pending',
'/sdk/v1/scenarios/trigger',
'/sdk/v1/player/information',
'/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);
});
});