/** * Unit tests for src/live/authz.ts — per-event authorization. * * Covers: * - canAccessEvent returns { allowed: true } when /items/events/:id returns 200. * - Returns { allowed: false, reason: 'forbidden' } on 403. * - Returns { allowed: false, reason: 'not-found' } on 404. * - Returns { allowed: false, reason: 'error' } on network failure (never throws). * - Returns { allowed: false, reason: 'error' } on 500. * - Authz latency histogram is observed on every call. */ 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 { createAuthzClient } from '../src/live/authz.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 observeCalls: Array<{ name: string; value: number }>; }; function makeMetrics(): TestMetrics { const observeCalls: Array<{ name: string; value: number }> = []; return { observeCalls, inc: vi.fn(), observe(name, value) { observeCalls.push({ name, value }); }, }; } function makeConfig(): 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, }; } const EVENT_ID = 'ada60b3d-b29f-4017-b702-cd6b700f9f6c'; function makeStatusFetch(status: number): typeof fetch { return vi.fn().mockResolvedValue({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve({ data: { id: EVENT_ID } }), } as unknown as Response); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('createAuthzClient.canAccessEvent', () => { let originalFetch: typeof globalThis.fetch; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); it('returns { allowed: true } when Directus returns 200', async () => { globalThis.fetch = makeStatusFetch(200); const client = createAuthzClient(makeConfig(), makeSilentLogger(), makeMetrics()); const result = await client.canAccessEvent('cookie=abc', EVENT_ID); expect(result.allowed).toBe(true); }); it('returns { allowed: false, reason: "forbidden" } on 403', async () => { globalThis.fetch = makeStatusFetch(403); const client = createAuthzClient(makeConfig(), makeSilentLogger(), makeMetrics()); const result = await client.canAccessEvent('cookie=abc', EVENT_ID); expect(result.allowed).toBe(false); if (!result.allowed) expect(result.reason).toBe('forbidden'); }); it('returns { allowed: false, reason: "not-found" } on 404', async () => { globalThis.fetch = makeStatusFetch(404); const client = createAuthzClient(makeConfig(), makeSilentLogger(), makeMetrics()); const result = await client.canAccessEvent('cookie=abc', EVENT_ID); expect(result.allowed).toBe(false); if (!result.allowed) expect(result.reason).toBe('not-found'); }); it('returns { allowed: false, reason: "error" } on 500', async () => { globalThis.fetch = makeStatusFetch(500); const client = createAuthzClient(makeConfig(), makeSilentLogger(), makeMetrics()); const result = await client.canAccessEvent('cookie=abc', EVENT_ID); expect(result.allowed).toBe(false); if (!result.allowed) expect(result.reason).toBe('error'); }); it('returns { allowed: false, reason: "error" } when fetch throws (never throws itself)', async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); const client = createAuthzClient(makeConfig(), makeSilentLogger(), makeMetrics()); const result = await client.canAccessEvent('cookie=abc', EVENT_ID); expect(result.allowed).toBe(false); if (!result.allowed) expect(result.reason).toBe('error'); }); it('observes authz latency on every call', async () => { globalThis.fetch = makeStatusFetch(200); const metrics = makeMetrics(); const client = createAuthzClient(makeConfig(), makeSilentLogger(), metrics); await client.canAccessEvent('cookie=abc', EVENT_ID); const latencyCalls = metrics.observeCalls.filter( (c) => c.name === 'processor_live_authz_latency_ms', ); expect(latencyCalls.length).toBeGreaterThanOrEqual(1); expect(latencyCalls[0]!.value).toBeGreaterThanOrEqual(0); }); });