|
|
|
@@ -2,26 +2,29 @@ import { Test } from '@nestjs/testing';
|
|
|
|
|
import { trace } from '@opentelemetry/api';
|
|
|
|
|
import { ClsService } from 'nestjs-cls';
|
|
|
|
|
import { PrismaService } from 'nestjs-prisma';
|
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import { AuditWriter } from './audit.service';
|
|
|
|
|
import type { AuditEventInput } from './audit.types';
|
|
|
|
|
import { HashUserIdService } from './hash-user-id.service';
|
|
|
|
|
|
|
|
|
|
interface AuditEventCreateCall {
|
|
|
|
|
data: {
|
|
|
|
|
eventType: string;
|
|
|
|
|
audience: 'workforce' | 'customer';
|
|
|
|
|
outcome: 'success' | 'failure' | 'denied';
|
|
|
|
|
subject: string | null;
|
|
|
|
|
actorIdHash: string | null;
|
|
|
|
|
traceId: string | null;
|
|
|
|
|
payload: Prisma.InputJsonValue | typeof Prisma.JsonNull;
|
|
|
|
|
};
|
|
|
|
|
/**
|
|
|
|
|
* Fields of an audit row as captured from the parameterised raw
|
|
|
|
|
* INSERT — same positional order as the SQL placeholders. Anything
|
|
|
|
|
* read from the mock goes through `extractInsertedRow` so the
|
|
|
|
|
* tests assert against named fields rather than raw `$executeRawUnsafe`
|
|
|
|
|
* argument indices.
|
|
|
|
|
*/
|
|
|
|
|
interface InsertedRow {
|
|
|
|
|
eventType: unknown;
|
|
|
|
|
audience: unknown;
|
|
|
|
|
outcome: unknown;
|
|
|
|
|
subject: unknown;
|
|
|
|
|
actorIdHash: unknown;
|
|
|
|
|
traceId: unknown;
|
|
|
|
|
payloadJson: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MockTx {
|
|
|
|
|
$executeRawUnsafe: jest.Mock;
|
|
|
|
|
auditEvent: { create: jest.Mock };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MockPrisma {
|
|
|
|
@@ -32,7 +35,6 @@ interface MockPrisma {
|
|
|
|
|
function buildMocks(): { prisma: MockPrisma; cls: { get: jest.Mock } } {
|
|
|
|
|
const tx: MockTx = {
|
|
|
|
|
$executeRawUnsafe: jest.fn().mockResolvedValue(0),
|
|
|
|
|
auditEvent: { create: jest.fn().mockResolvedValue(undefined) },
|
|
|
|
|
};
|
|
|
|
|
const prisma: MockPrisma = {
|
|
|
|
|
tx,
|
|
|
|
@@ -42,6 +44,25 @@ function buildMocks(): { prisma: MockPrisma; cls: { get: jest.Mock } } {
|
|
|
|
|
return { prisma, cls };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Pull the inserted-row fields out of the `$executeRawUnsafe` mock.
|
|
|
|
|
* `recordEvent` calls `$executeRawUnsafe` twice — first to `SET LOCAL
|
|
|
|
|
* ROLE audit_writer`, then to issue the parameterised INSERT. The
|
|
|
|
|
* INSERT call has args (sql, eventType, audience, outcome, subject,
|
|
|
|
|
* actorIdHash, traceId, payloadJson).
|
|
|
|
|
*/
|
|
|
|
|
function extractInsertedRow(prisma: MockPrisma): InsertedRow {
|
|
|
|
|
const calls = prisma.tx.$executeRawUnsafe.mock.calls;
|
|
|
|
|
const insertCall = calls[1];
|
|
|
|
|
if (!insertCall) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`expected $executeRawUnsafe to be called at least twice (SET ROLE + INSERT), got ${calls.length}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const [, eventType, audience, outcome, subject, actorIdHash, traceId, payloadJson] = insertCall;
|
|
|
|
|
return { eventType, audience, outcome, subject, actorIdHash, traceId, payloadJson };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createSubject(): Promise<{
|
|
|
|
|
writer: AuditWriter;
|
|
|
|
|
prisma: MockPrisma;
|
|
|
|
@@ -75,35 +96,69 @@ describe('AuditWriter', () => {
|
|
|
|
|
await writer.recordEvent(baseInput);
|
|
|
|
|
|
|
|
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(prisma.tx.$executeRawUnsafe).toHaveBeenCalledWith('SET LOCAL ROLE audit_writer');
|
|
|
|
|
// SET ROLE must precede the INSERT, otherwise the runtime
|
|
|
|
|
// privilege check happens with the wrong role.
|
|
|
|
|
const setRoleOrder = prisma.tx.$executeRawUnsafe.mock.invocationCallOrder[0];
|
|
|
|
|
const createOrder = prisma.tx.auditEvent.create.mock.invocationCallOrder[0];
|
|
|
|
|
expect(setRoleOrder).toBeLessThan(createOrder);
|
|
|
|
|
// recordEvent runs two raw statements per call: SET ROLE then
|
|
|
|
|
// INSERT. SET ROLE must come first so the INSERT's privilege
|
|
|
|
|
// check is against audit_writer, not the BFF's connection role.
|
|
|
|
|
const calls = prisma.tx.$executeRawUnsafe.mock.calls;
|
|
|
|
|
expect(calls.length).toBeGreaterThanOrEqual(2);
|
|
|
|
|
expect(calls[0]?.[0]).toBe('SET LOCAL ROLE audit_writer');
|
|
|
|
|
expect(calls[1]?.[0]).toMatch(/INSERT INTO "audit"\."events"/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes the input fields through to the create call', async () => {
|
|
|
|
|
it('does NOT use Prisma ORM create() — the raw INSERT keeps audit_writer free of SELECT', async () => {
|
|
|
|
|
// Bypassing the ORM is the explicit choice that lets ADR-0013's
|
|
|
|
|
// append-only role contract hold. Prisma's create() emits
|
|
|
|
|
// `INSERT … RETURNING *`, and RETURNING requires SELECT. Pin
|
|
|
|
|
// the constraint so a well-meaning refactor can't silently
|
|
|
|
|
// reintroduce the bug.
|
|
|
|
|
const { writer, prisma } = await createSubject();
|
|
|
|
|
await writer.recordEvent(baseInput);
|
|
|
|
|
|
|
|
|
|
// Sanity check: the only writes go through $executeRawUnsafe.
|
|
|
|
|
// No other tx method should be called.
|
|
|
|
|
const allowedTxKeys = new Set(['$executeRawUnsafe']);
|
|
|
|
|
const observedTxKeys = Object.keys(prisma.tx);
|
|
|
|
|
expect(observedTxKeys.every((k) => allowedTxKeys.has(k))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('parameterises the INSERT — never inlines values into SQL', async () => {
|
|
|
|
|
const { writer, prisma } = await createSubject();
|
|
|
|
|
await writer.recordEvent({
|
|
|
|
|
...baseInput,
|
|
|
|
|
eventType: "auth'; DROP TABLE events; --",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const insertCall = prisma.tx.$executeRawUnsafe.mock.calls[1];
|
|
|
|
|
const sql = insertCall?.[0] as string;
|
|
|
|
|
// SQL itself must be the parameterised template; the malicious
|
|
|
|
|
// eventType has to land in the params array, not concatenated
|
|
|
|
|
// into the SQL.
|
|
|
|
|
expect(sql).toContain('$1');
|
|
|
|
|
expect(sql).not.toContain('DROP TABLE');
|
|
|
|
|
expect(insertCall?.[1]).toBe("auth'; DROP TABLE events; --");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes the input fields through positionally to the parameterised INSERT', async () => {
|
|
|
|
|
const { writer, prisma } = await createSubject();
|
|
|
|
|
await writer.recordEvent({
|
|
|
|
|
...baseInput,
|
|
|
|
|
payload: { route: '/auth/login', clientId: 'spa' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.eventType).toBe('auth.login');
|
|
|
|
|
expect(call.data.audience).toBe('workforce');
|
|
|
|
|
expect(call.data.outcome).toBe('success');
|
|
|
|
|
expect(call.data.subject).toBe('user:42');
|
|
|
|
|
expect(call.data.payload).toEqual({ route: '/auth/login', clientId: 'spa' });
|
|
|
|
|
const row = extractInsertedRow(prisma);
|
|
|
|
|
expect(row.eventType).toBe('auth.login');
|
|
|
|
|
expect(row.audience).toBe('workforce');
|
|
|
|
|
expect(row.outcome).toBe('success');
|
|
|
|
|
expect(row.subject).toBe('user:42');
|
|
|
|
|
expect(row.payloadJson).toBe(JSON.stringify({ route: '/auth/login', clientId: 'spa' }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('records Prisma.JsonNull when no payload is provided', async () => {
|
|
|
|
|
it('sends payload as null (JSONB column nullable) when no payload is provided', async () => {
|
|
|
|
|
const { writer, prisma } = await createSubject();
|
|
|
|
|
await writer.recordEvent(baseInput);
|
|
|
|
|
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.payload).toBe(Prisma.JsonNull);
|
|
|
|
|
const row = extractInsertedRow(prisma);
|
|
|
|
|
expect(row.payloadJson).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reads actorIdHash from CLS when not passed explicitly', async () => {
|
|
|
|
@@ -113,8 +168,7 @@ describe('AuditWriter', () => {
|
|
|
|
|
await writer.recordEvent(baseInput);
|
|
|
|
|
|
|
|
|
|
expect(cls.get).toHaveBeenCalledWith('actorIdHash');
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.actorIdHash).toBe('hash-from-cls');
|
|
|
|
|
expect(extractInsertedRow(prisma).actorIdHash).toBe('hash-from-cls');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('prefers an explicit actorIdHash over the CLS-resolved one', async () => {
|
|
|
|
@@ -123,8 +177,7 @@ describe('AuditWriter', () => {
|
|
|
|
|
|
|
|
|
|
await writer.recordEvent({ ...baseInput, actorIdHash: 'hash-from-input' });
|
|
|
|
|
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.actorIdHash).toBe('hash-from-input');
|
|
|
|
|
expect(extractInsertedRow(prisma).actorIdHash).toBe('hash-from-input');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('stores actorIdHash = null when neither input nor CLS has one', async () => {
|
|
|
|
@@ -133,8 +186,7 @@ describe('AuditWriter', () => {
|
|
|
|
|
|
|
|
|
|
await writer.recordEvent(baseInput);
|
|
|
|
|
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.actorIdHash).toBeNull();
|
|
|
|
|
expect(extractInsertedRow(prisma).actorIdHash).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('captures the active OTel trace id', async () => {
|
|
|
|
@@ -145,8 +197,7 @@ describe('AuditWriter', () => {
|
|
|
|
|
const { writer, prisma } = await createSubject();
|
|
|
|
|
await writer.recordEvent(baseInput);
|
|
|
|
|
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.traceId).toBe('abc123');
|
|
|
|
|
expect(extractInsertedRow(prisma).traceId).toBe('abc123');
|
|
|
|
|
} finally {
|
|
|
|
|
getActiveSpanSpy.mockRestore();
|
|
|
|
|
}
|
|
|
|
@@ -159,8 +210,7 @@ describe('AuditWriter', () => {
|
|
|
|
|
const { writer, prisma } = await createSubject();
|
|
|
|
|
await writer.recordEvent(baseInput);
|
|
|
|
|
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.traceId).toBeNull();
|
|
|
|
|
expect(extractInsertedRow(prisma).traceId).toBeNull();
|
|
|
|
|
} finally {
|
|
|
|
|
getActiveSpanSpy.mockRestore();
|
|
|
|
|
}
|
|
|
|
@@ -169,7 +219,8 @@ describe('AuditWriter', () => {
|
|
|
|
|
it('propagates the underlying error — no catch-and-swallow', async () => {
|
|
|
|
|
const { writer, prisma } = await createSubject();
|
|
|
|
|
const dbError = new Error('permission denied for table events');
|
|
|
|
|
prisma.tx.auditEvent.create.mockRejectedValueOnce(dbError);
|
|
|
|
|
// First call (SET ROLE) succeeds, second (INSERT) rejects.
|
|
|
|
|
prisma.tx.$executeRawUnsafe.mockResolvedValueOnce(0).mockRejectedValueOnce(dbError);
|
|
|
|
|
|
|
|
|
|
await expect(writer.recordEvent(baseInput)).rejects.toThrow(
|
|
|
|
|
'permission denied for table events',
|
|
|
|
@@ -186,13 +237,13 @@ describe('AuditWriter — typed event methods', () => {
|
|
|
|
|
sessionId: 'sid-1',
|
|
|
|
|
});
|
|
|
|
|
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.eventType).toBe('auth.sign_in');
|
|
|
|
|
expect(call.data.audience).toBe('workforce');
|
|
|
|
|
expect(call.data.outcome).toBe('success');
|
|
|
|
|
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
|
|
|
|
expect(call.data.subject).toBe('session:sid-1');
|
|
|
|
|
expect(call.data.payload).toEqual({ amr: ['pwd', 'mfa'] });
|
|
|
|
|
const row = extractInsertedRow(prisma);
|
|
|
|
|
expect(row.eventType).toBe('auth.sign_in');
|
|
|
|
|
expect(row.audience).toBe('workforce');
|
|
|
|
|
expect(row.outcome).toBe('success');
|
|
|
|
|
expect(row.actorIdHash).toBe('hash(user-oid)');
|
|
|
|
|
expect(row.subject).toBe('session:sid-1');
|
|
|
|
|
expect(row.payloadJson).toBe(JSON.stringify({ amr: ['pwd', 'mfa'] }));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -201,11 +252,11 @@ describe('AuditWriter — typed event methods', () => {
|
|
|
|
|
const { writer, prisma, hashUserId } = await createSubject();
|
|
|
|
|
await writer.signInFailed({ failureKind: 'state-mismatch' });
|
|
|
|
|
expect(hashUserId.hash).not.toHaveBeenCalled();
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.eventType).toBe('auth.sign_in.failed');
|
|
|
|
|
expect(call.data.outcome).toBe('failure');
|
|
|
|
|
expect(call.data.actorIdHash).toBeNull();
|
|
|
|
|
expect(call.data.payload).toEqual({ failureKind: 'state-mismatch' });
|
|
|
|
|
const row = extractInsertedRow(prisma);
|
|
|
|
|
expect(row.eventType).toBe('auth.sign_in.failed');
|
|
|
|
|
expect(row.outcome).toBe('failure');
|
|
|
|
|
expect(row.actorIdHash).toBeNull();
|
|
|
|
|
expect(row.payloadJson).toBe(JSON.stringify({ failureKind: 'state-mismatch' }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('merges payload with failureKind under the same key', async () => {
|
|
|
|
@@ -214,20 +265,21 @@ describe('AuditWriter — typed event methods', () => {
|
|
|
|
|
failureKind: 'entra-error',
|
|
|
|
|
payload: { entraError: 'access_denied', entraErrorDescription: 'user cancelled' },
|
|
|
|
|
});
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.payload).toEqual({
|
|
|
|
|
failureKind: 'entra-error',
|
|
|
|
|
entraError: 'access_denied',
|
|
|
|
|
entraErrorDescription: 'user cancelled',
|
|
|
|
|
});
|
|
|
|
|
const row = extractInsertedRow(prisma);
|
|
|
|
|
expect(row.payloadJson).toBe(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
failureKind: 'entra-error',
|
|
|
|
|
entraError: 'access_denied',
|
|
|
|
|
entraErrorDescription: 'user cancelled',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('hashes the actor oid when one is provided (rare — identity-after-rejection path)', async () => {
|
|
|
|
|
const { writer, prisma, hashUserId } = await createSubject();
|
|
|
|
|
await writer.signInFailed({ failureKind: 'amr-missing', actor: { oid: 'user-oid' } });
|
|
|
|
|
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
|
|
|
|
expect(extractInsertedRow(prisma).actorIdHash).toBe('hash(user-oid)');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -236,11 +288,11 @@ describe('AuditWriter — typed event methods', () => {
|
|
|
|
|
const { writer, prisma, hashUserId } = await createSubject();
|
|
|
|
|
await writer.signOut({ actor: { oid: 'user-oid' }, sessionId: 'sid-2' });
|
|
|
|
|
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.eventType).toBe('auth.sign_out');
|
|
|
|
|
expect(call.data.outcome).toBe('success');
|
|
|
|
|
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
|
|
|
|
expect(call.data.subject).toBe('session:sid-2');
|
|
|
|
|
const row = extractInsertedRow(prisma);
|
|
|
|
|
expect(row.eventType).toBe('auth.sign_out');
|
|
|
|
|
expect(row.outcome).toBe('success');
|
|
|
|
|
expect(row.actorIdHash).toBe('hash(user-oid)');
|
|
|
|
|
expect(row.subject).toBe('session:sid-2');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -254,11 +306,13 @@ describe('AuditWriter — typed event methods', () => {
|
|
|
|
|
ageMs: 13 * 60 * 60 * 1000,
|
|
|
|
|
});
|
|
|
|
|
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
|
|
|
|
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
|
|
|
|
expect(call.data.eventType).toBe('auth.session.expired');
|
|
|
|
|
expect(call.data.outcome).toBe('success');
|
|
|
|
|
expect(call.data.subject).toBe('session:sid-3');
|
|
|
|
|
expect(call.data.payload).toEqual({ reason: 'absolute', ageMs: 13 * 60 * 60 * 1000 });
|
|
|
|
|
const row = extractInsertedRow(prisma);
|
|
|
|
|
expect(row.eventType).toBe('auth.session.expired');
|
|
|
|
|
expect(row.outcome).toBe('success');
|
|
|
|
|
expect(row.subject).toBe('session:sid-3');
|
|
|
|
|
expect(row.payloadJson).toBe(
|
|
|
|
|
JSON.stringify({ reason: 'absolute', ageMs: 13 * 60 * 60 * 1000 }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|