/** * Unit tests for src/live/auth.ts — Cookie auth handshake. * * All Directus HTTP calls are intercepted by mocking globalThis.fetch. * No real network calls. * * Covers: * - 200 + valid user payload → returns parsed AuthenticatedUser. * - 401 → returns null and increments `unauthorized` counter. * - 403 → returns null and increments `unauthorized` counter. * - Non-2xx (500) → returns null and increments `error` counter. * - Network error (fetch throws) → returns null and increments `error` counter. * - AbortError (timeout) → returns null and increments `error` counter. * - 200 but missing `data` field → returns null and increments `error` counter. * - 200 with `data: null` (expired session) → returns null and increments `unauthorized`. * - 200 but user object missing `id` → returns null and increments `error`. * - Empty cookie header → returns null immediately (no fetch call). * - Auth latency histogram is observed on success. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Logger } from 'pino'; import type { Config } from '../src/config/load.js'; import type { Metrics } from '../src/core/types.js'; import { createAuthClient } from '../src/live/auth.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeSilentLogger(): Logger { return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn(), trace: vi.fn(), child: vi.fn().mockReturnThis(), level: 'silent', silent: vi.fn(), } as unknown as Logger; } type TestMetrics = Metrics & { readonly incCalls: Array<{ name: string; labels?: Record }>; readonly observeCalls: Array<{ name: string; value: number }>; }; function makeMetrics(): TestMetrics { const incCalls: Array<{ name: string; labels?: Record }> = []; const observeCalls: Array<{ name: string; value: number }> = []; return { incCalls, observeCalls, inc(name, labels) { incCalls.push({ name, labels }); }, observe(name, value) { observeCalls.push({ name, value }); }, }; } function makeConfig(overrides: Partial = {}): Config { return { NODE_ENV: 'test', INSTANCE_ID: 'test-1', LOG_LEVEL: 'silent', REDIS_URL: 'redis://localhost:6379', POSTGRES_URL: 'postgres://localhost:5432/test', REDIS_TELEMETRY_STREAM: 'telemetry:t', REDIS_CONSUMER_GROUP: 'processor', REDIS_CONSUMER_NAME: 'test-consumer', METRICS_PORT: 0, BATCH_SIZE: 100, BATCH_BLOCK_MS: 500, WRITE_BATCH_SIZE: 50, DEVICE_STATE_LRU_CAP: 10_000, LIVE_WS_PORT: 8081, LIVE_WS_HOST: '0.0.0.0', LIVE_WS_PING_INTERVAL_MS: 30_000, LIVE_WS_DRAIN_TIMEOUT_MS: 5_000, LIVE_WS_BACKPRESSURE_THRESHOLD_BYTES: 1_048_576, DIRECTUS_BASE_URL: 'http://directus.test', DIRECTUS_AUTH_TIMEOUT_MS: 5_000, DIRECTUS_AUTHZ_TIMEOUT_MS: 5_000, LIVE_BROADCAST_GROUP_PREFIX: 'live-broadcast', LIVE_BROADCAST_BATCH_SIZE: 100, LIVE_BROADCAST_BATCH_BLOCK_MS: 1_000, LIVE_DEVICE_EVENT_REFRESH_MS: 30_000, ...overrides, }; } const VALID_USER = { id: 'ada60b3d-b29f-4017-b702-cd6b700f9f6c', email: 'driver@example.com', role: 'f6114c7e-1e94-488a-93c3-41060fcb06bc', first_name: 'Test', last_name: 'User', }; function makeOkFetch(data: unknown): typeof fetch { return vi.fn().mockResolvedValue({ status: 200, ok: true, json: () => Promise.resolve({ data }), } as unknown as Response); } function makeStatusFetch(status: number): typeof fetch { return vi.fn().mockResolvedValue({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve({}), } as unknown as Response); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('createAuthClient.validate', () => { let metrics: TestMetrics; let logger: Logger; let originalFetch: typeof globalThis.fetch; beforeEach(() => { metrics = makeMetrics(); logger = makeSilentLogger(); originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); it('returns the parsed user when Directus returns 200 with a valid user payload', async () => { globalThis.fetch = makeOkFetch(VALID_USER); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=abc123'); expect(user).not.toBeNull(); expect(user!.id).toBe(VALID_USER.id); expect(user!.email).toBe(VALID_USER.email); const successCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'success', ); expect(successCalls).toHaveLength(1); }); it('returns null and increments unauthorized counter on 401', async () => { globalThis.fetch = makeStatusFetch(401); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=bad'); expect(user).toBeNull(); const unauthorizedCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'unauthorized', ); expect(unauthorizedCalls).toHaveLength(1); }); it('returns null and increments unauthorized counter on 403', async () => { globalThis.fetch = makeStatusFetch(403); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=forbidden'); expect(user).toBeNull(); const unauthorizedCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'unauthorized', ); expect(unauthorizedCalls).toHaveLength(1); }); it('returns null and increments error counter on 500', async () => { globalThis.fetch = makeStatusFetch(500); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=boom'); expect(user).toBeNull(); const errorCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'error', ); expect(errorCalls).toHaveLength(1); }); it('returns null and increments error counter when fetch throws a network error', async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=abc'); expect(user).toBeNull(); const errorCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'error', ); expect(errorCalls).toHaveLength(1); }); it('returns null when fetch is aborted (simulated timeout)', async () => { const abortErr = new DOMException('The operation was aborted', 'AbortError'); globalThis.fetch = vi.fn().mockRejectedValue(abortErr); const client = createAuthClient(makeConfig({ DIRECTUS_AUTH_TIMEOUT_MS: 50 }), logger, metrics); const user = await client.validate('session=slow'); expect(user).toBeNull(); const errorCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'error', ); expect(errorCalls).toHaveLength(1); }); it('returns null and increments error counter when response body is missing data field', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ status: 200, ok: true, json: () => Promise.resolve({}), // no `data` key at all } as unknown as Response); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=weird'); expect(user).toBeNull(); const errorCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'error', ); expect(errorCalls).toHaveLength(1); }); it('returns null and increments unauthorized counter when data is null (expired session)', async () => { globalThis.fetch = makeOkFetch(null); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=expired'); expect(user).toBeNull(); const unauthorizedCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'unauthorized', ); expect(unauthorizedCalls).toHaveLength(1); }); it('returns null and increments error counter when user object is missing id', async () => { globalThis.fetch = makeOkFetch({ email: 'noId@example.com', role: null }); const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate('session=noid'); expect(user).toBeNull(); const errorCalls = metrics.incCalls.filter( (c) => c.name === 'processor_live_auth_attempts_total' && c.labels?.['result'] === 'error', ); expect(errorCalls).toHaveLength(1); }); it('returns null immediately for an empty cookie header without making a fetch call', async () => { const mockFetch = vi.fn(); globalThis.fetch = mockFetch; const client = createAuthClient(makeConfig(), logger, metrics); const user = await client.validate(''); expect(user).toBeNull(); expect(mockFetch).not.toHaveBeenCalled(); }); it('observes auth latency on a successful call', async () => { globalThis.fetch = makeOkFetch(VALID_USER); const client = createAuthClient(makeConfig(), logger, metrics); await client.validate('session=ok'); const latencyCalls = metrics.observeCalls.filter( (c) => c.name === 'processor_live_auth_latency_ms', ); expect(latencyCalls.length).toBeGreaterThanOrEqual(1); expect(latencyCalls[0]!.value).toBeGreaterThanOrEqual(0); }); });