20ebd9b473
Authenticate WebSocket upgrade requests via Directus's /users/me: - src/live/auth.ts: createAuthClient factory; validate() forwards the raw Cookie: header to Directus, parses the user with zod, and returns AuthenticatedUser or null. Handles 401/403 (unauthorized), non-2xx (error), network failures, AbortError (timeout), null data (expired session), and missing data key (malformed Directus response). - src/live/server.ts: upgrade handler now calls authClient.validate() before completing the WS handshake; on null user, writes HTTP 401 and destroys the socket. LiveConnection gains user: AuthenticatedUser and cookieHeader: string (needed for per-subscription authz in task 1.5.3). authClient is an optional parameter so tests without auth still work. - src/main.ts: wires createAuthClient and passes it to createLiveServer. - test/live-auth.test.ts: 11 unit tests covering all validate() code paths including the empty-cookie fast-path, latency histogram observation, and distinction between unauthorized (401/expired) and error (malformed) results.
274 lines
9.9 KiB
TypeScript
274 lines
9.9 KiB
TypeScript
/**
|
|
* 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<string, string> }>;
|
|
readonly observeCalls: Array<{ name: string; value: number }>;
|
|
};
|
|
|
|
function makeMetrics(): TestMetrics {
|
|
const incCalls: Array<{ name: string; labels?: Record<string, string> }> = [];
|
|
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> = {}): 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);
|
|
});
|
|
});
|