feat(live): task 1.5.3 — subscription registry & per-event authorization
Subscribe/unsubscribe with per-event authorization via Directus delegation: - src/live/authz.ts: createAuthzClient factory; canAccessEvent(cookieHeader, eventId) calls GET /items/events/<id>?fields=id, delegates row-level security to Directus (200=allow, 403=forbidden, 404=not-found, else error). - src/live/registry.ts: createSubscriptionRegistry with bidirectional indexes (WeakMap<conn, topics> + Map<topic, conns>); subscribe/unsubscribe/ onConnectionClose/connectionsForTopic/topicsForConnection/stats. Authorization runs once at subscribe time. Snapshot is stubbed as [] until task 1.5.5. Includes pluggable SnapshotProvider interface for task 1.5.5 injection. - src/live/protocol.ts: adds 'error' to ErrorCode union for transient authz failures. - src/main.ts: wires createAuthzClient + createSubscriptionRegistry; replaces the stub message handler with the real subscribe/unsubscribe router; passes registry.onConnectionClose as the server's onClose callback. - test/live-authz.test.ts: 6 unit tests for all canAccessEvent outcomes. - test/live-registry.test.ts: 9 unit tests for subscribe/unsubscribe semantics, idempotency, gauge correctness, and onConnectionClose cleanup.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user