mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
8e5fdaa877
Permissions cached an empty map before it had loaded, so a DB error during the load cached permissive defaults and every later check answered from that cache - fail-open, and it never expired. The rate limiter grew its per-IP map forever; the GC the legacy version had was dropped in the fold. Idempotency did a check-then-insert, so two concurrent duplicates both ran the handler. Audit rows recorded whatever X-Forwarded-For said, which the client writes, and the request-body debug logger redacted by exact key match - it missed refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect from the client's Host header. CSP asked for unsafe-eval on top of the wasm-unsafe-eval it actually needs. The OAuth deny path redirected to an unvalidated URI, ws-token minting had no rate limit at all, and PKCE was skipped when code_verifier was absent rather than refused. An MCP session resumed with a token whose scopes had since been reduced kept the capability set it was created with, and the session cap was checked before the async registration that increments it. Plugin frame containment was lexical, so a symlink out of the directory was never noticed, and instance config accepted keys the manifest never declared.
216 lines
9.7 KiB
TypeScript
216 lines
9.7 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native audit domain — AUDIT-SVC-001 through
|
||
* AUDIT-SVC-017 (001–007 are the getClientIp cases moved 1:1 from the legacy
|
||
* tests/unit/services/auditLog.test.ts, which had no case IDs — the IDs are
|
||
* introduced with the move; 008–014 cover writeAudit over a real in-memory
|
||
* SQLite DB, which the legacy suite never exercised; 015–017 pin the
|
||
* audit.bridge delegation). The logger module is mocked (it replaces the old
|
||
* suite's fs mock: the import-time mkdir lives there now, and mocking it lets
|
||
* the exact log-line formats be asserted).
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
|
||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
|
||
LOG_LEVEL: 'error',
|
||
logInfo: vi.fn(),
|
||
logDebug: vi.fn(),
|
||
logError: vi.fn(),
|
||
logWarn: vi.fn(),
|
||
}));
|
||
|
||
import type { Request } from 'express';
|
||
import { createTables } from '../../../src/db/schema';
|
||
import { runMigrations } from '../../../src/db/migrations';
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||
import { getClientIp } from '../../../src/nest/audit/client-ip';
|
||
import { logInfo, logDebug, logError } from '../../../src/nest/audit/audit-log.logger';
|
||
|
||
const svc = new AuditService(new DatabaseService(testDb));
|
||
|
||
beforeAll(() => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
testDb.prepare('DELETE FROM audit_log').run();
|
||
testDb.prepare('DELETE FROM users').run();
|
||
});
|
||
|
||
afterAll(() => {
|
||
testDb.close();
|
||
});
|
||
|
||
function makeReq(options: {
|
||
ip?: string;
|
||
xff?: string | string[];
|
||
remoteAddress?: string;
|
||
} = {}): Request {
|
||
return {
|
||
ip: options.ip,
|
||
headers: {
|
||
...(options.xff !== undefined ? { 'x-forwarded-for': options.xff } : {}),
|
||
},
|
||
socket: { remoteAddress: options.remoteAddress ?? undefined },
|
||
} as unknown as Request;
|
||
}
|
||
|
||
// ── getClientIp (pure, moved 1:1) ─────────────────────────────────────────────
|
||
|
||
describe('getClientIp', () => {
|
||
it('AUDIT-SVC-001: returns req.ip, which Express resolved through the trust-proxy hop count', () => {
|
||
expect(getClientIp(makeReq({ ip: '1.2.3.4', xff: '9.9.9.9, 1.2.3.4' }))).toBe('1.2.3.4');
|
||
});
|
||
|
||
it('AUDIT-SVC-002: ignores a hand-written X-Forwarded-For — the leftmost entry is whatever the caller typed', () => {
|
||
expect(getClientIp(makeReq({ ip: '203.0.113.7', xff: '10.0.0.1' }))).toBe('203.0.113.7');
|
||
});
|
||
|
||
it('AUDIT-SVC-003: ignores an array-valued X-Forwarded-For too', () => {
|
||
expect(getClientIp(makeReq({ ip: '203.0.113.7', xff: ['203.0.113.1', '10.0.0.1'] }))).toBe('203.0.113.7');
|
||
});
|
||
|
||
it('AUDIT-SVC-004: trims whitespace from the resolved IP', () => {
|
||
expect(getClientIp(makeReq({ ip: ' 192.168.1.1 ' }))).toBe('192.168.1.1');
|
||
});
|
||
|
||
it('AUDIT-SVC-005: falls back to req.socket.remoteAddress when req.ip is unset', () => {
|
||
expect(getClientIp(makeReq({ remoteAddress: '172.16.0.1' }))).toBe('172.16.0.1');
|
||
});
|
||
|
||
it('AUDIT-SVC-006: returns null when there is neither a resolved IP nor a socket address', () => {
|
||
expect(getClientIp(makeReq({}))).toBeNull();
|
||
});
|
||
|
||
it('AUDIT-SVC-007: returns null for an empty req.ip with no socket address', () => {
|
||
const req = {
|
||
ip: '',
|
||
headers: { 'x-forwarded-for': '203.0.113.9' },
|
||
socket: { remoteAddress: undefined },
|
||
} as unknown as Request;
|
||
expect(getClientIp(req)).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── writeAudit (real DB) ──────────────────────────────────────────────────────
|
||
|
||
function seedUser(id: number, email: string): void {
|
||
testDb.prepare(
|
||
"INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, 'x', 'user')"
|
||
).run(id, `u${id}`, email);
|
||
}
|
||
|
||
describe('writeAudit', () => {
|
||
it('AUDIT-SVC-008: inserts the row and logs the labeled summary line', () => {
|
||
seedUser(1, 'a@b.c');
|
||
svc.writeAudit({ userId: 1, action: 'trip.create', resource: 'trip', details: { title: 'Rome' }, ip: '1.2.3.4' });
|
||
const row = testDb.prepare('SELECT user_id, action, resource, details, ip FROM audit_log').get();
|
||
expect(row).toEqual({ user_id: 1, action: 'trip.create', resource: 'trip', details: '{"title":"Rome"}', ip: '1.2.3.4' });
|
||
expect(logInfo).toHaveBeenCalledWith('a@b.c created trip "Rome" ip=1.2.3.4');
|
||
});
|
||
|
||
it('AUDIT-SVC-009: empty details object stores NULL details and skips the debug fallback', () => {
|
||
seedUser(1, 'a@b.c');
|
||
svc.writeAudit({ userId: 1, action: 'user.login', details: {}, ip: '1.2.3.4' });
|
||
const row = testDb.prepare('SELECT details FROM audit_log').get() as { details: string | null };
|
||
expect(row.details).toBeNull();
|
||
expect(logDebug).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('AUDIT-SVC-010: unknown action keeps the raw key; empty-email/zero/null userIds resolve', () => {
|
||
seedUser(1, 'a@b.c');
|
||
svc.writeAudit({ userId: 1, action: 'custom.thing', ip: '9.9.9.9' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('a@b.c custom.thing ip=9.9.9.9');
|
||
seedUser(42, ''); // falsy email → the `row?.email || uid:N` fallback
|
||
svc.writeAudit({ userId: 42, action: 'user.login', ip: '9.9.9.9' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('uid:42 logged in ip=9.9.9.9');
|
||
seedUser(0, 'zero@b.c'); // since the quirk fix, a real id 0 resolves via the DB
|
||
svc.writeAudit({ userId: 0, action: 'user.login', ip: '9.9.9.9' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('zero@b.c logged in ip=9.9.9.9');
|
||
svc.writeAudit({ userId: null, action: 'user.login', ip: '9.9.9.9' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('anonymous logged in ip=9.9.9.9');
|
||
});
|
||
|
||
it('AUDIT-SVC-019: a trip title with a line break cannot forge a second log line', () => {
|
||
seedUser(1, 'a@b.c');
|
||
svc.writeAudit({
|
||
userId: 1,
|
||
action: 'trip.create',
|
||
details: { title: 'Rome\nadmin@b.c changed user role' },
|
||
ip: '1.2.3.4',
|
||
});
|
||
const logged = String((logInfo as unknown as { mock: { calls: string[][] } }).mock.calls.at(-1)?.[0]);
|
||
expect(logged).not.toContain('\n');
|
||
expect(logged).toBe('a@b.c created trip "Rome admin@b.c changed user role" ip=1.2.3.4');
|
||
});
|
||
|
||
it('AUDIT-SVC-011: omitted resource/ip store NULL and the log line ends ip=-', () => {
|
||
seedUser(1, 'a@b.c');
|
||
svc.writeAudit({ userId: 1, action: 'user.login' });
|
||
const row = testDb.prepare('SELECT resource, ip FROM audit_log').get();
|
||
expect(row).toEqual({ resource: null, ip: null });
|
||
expect(logInfo).toHaveBeenCalledWith('a@b.c logged in ip=-');
|
||
});
|
||
|
||
it('AUDIT-SVC-012: debugDetails wins the debug line; detailsJson is the fallback', () => {
|
||
seedUser(1, 'a@b.c');
|
||
svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true }, debugDetails: { raw: 1 } });
|
||
expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"raw":1}');
|
||
svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true } });
|
||
expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"require_mfa":true}');
|
||
});
|
||
|
||
it('AUDIT-SVC-013: never throws — a failed insert reduces to a logError line', () => {
|
||
testDb.prepare('ALTER TABLE audit_log RENAME TO audit_log_gone').run();
|
||
expect(() => svc.writeAudit({ userId: 1, action: 'user.login' })).not.toThrow();
|
||
expect(logError).toHaveBeenCalledWith(expect.stringMatching(/^Audit write failed: /));
|
||
testDb.prepare('ALTER TABLE audit_log_gone RENAME TO audit_log').run();
|
||
});
|
||
|
||
it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', () => {
|
||
seedUser(1, 'admin@b.c');
|
||
svc.writeAudit({
|
||
userId: 1,
|
||
action: 'settings.api_keys_update',
|
||
resource: 'api_keys',
|
||
details: { changed: ['maps_api_key', 'unsplash_api_key'] },
|
||
ip: '1.2.3.4',
|
||
});
|
||
expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys (maps_api_key, unsplash_api_key) ip=1.2.3.4');
|
||
// A details blob without the array (or with an empty one) leaves the brief off
|
||
// rather than stringifying whatever else is in there.
|
||
svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { changed: [] }, ip: '1.2.3.4' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4');
|
||
svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { other: 'x' }, ip: '1.2.3.4' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4');
|
||
});
|
||
|
||
it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', () => {
|
||
seedUser(1, 'a@b.c');
|
||
svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('a@b.c updated settings (channel=smtp, mfa=false) ip=1.1.1.1');
|
||
svc.writeAudit({ userId: 1, action: 'user.login', details: { anything: true }, ip: '1.1.1.1' });
|
||
expect(logInfo).toHaveBeenLastCalledWith('a@b.c logged in ip=1.1.1.1');
|
||
});
|
||
});
|
||
|