6d36c03675
DO1: тест audit-swallow был вакуумен (log() — fire-and-forget void persist(), .not.toThrow() зелен независимо от try/catch). Теперь: после failInsert() шпионим Logger.warn + слушаем unhandledRejection, флашим микро/макротаски, ассертим warn=1 и 0 floating-rejection. Mutation: убрать try/catch у persist → красный. DO2: dominant-run тест не отличал longest от first (самый длинный ран был и первым). Перестроено: короткий plain ран впереди, длинный bold — следом → ассерт bold:true держится только при genuine longest; +тест tie→first. Mutation: reduce→segments[0] → красный. DO3: CHANGELOG [Unreleased] — 3 записи по #496. DO4: убран no-op override insertAttrs.comment (=dominant.markAttrs, а он и есть attributes['comment'] — сегменты фильтруются по нему; {...attributes,comment: attributes.comment}={...attributes}); коммент про 'defensive re-assert' исправлен. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
6.7 KiB
TypeScript
199 lines
6.7 KiB
TypeScript
import { Logger } from '@nestjs/common';
|
|
import { DatabaseAuditService } from './database-audit.service';
|
|
import { AUDIT_CONTEXT_KEY } from '../../common/middlewares/audit-context.middleware';
|
|
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
|
|
|
/**
|
|
* Observable-property coverage for the DB-backed audit trail (#496): every
|
|
* assertion pins what actually reaches the `audit` table (or that nothing does),
|
|
* driven through a chainable Kysely mock that captures the inserted rows.
|
|
*/
|
|
describe('DatabaseAuditService', () => {
|
|
function makeService(clsContext: any) {
|
|
const inserted: any[] = [];
|
|
const updated: any[] = [];
|
|
let failNextInsert = false;
|
|
|
|
const db: any = {
|
|
insertInto: jest.fn(() => ({
|
|
values: jest.fn((rows: any) => ({
|
|
execute: jest.fn(async () => {
|
|
if (failNextInsert) {
|
|
failNextInsert = false;
|
|
throw new Error('boom');
|
|
}
|
|
inserted.push(rows);
|
|
}),
|
|
})),
|
|
})),
|
|
updateTable: jest.fn(() => ({
|
|
set: jest.fn((patch: any) => ({
|
|
where: jest.fn(() => ({
|
|
execute: jest.fn(async () => {
|
|
updated.push(patch);
|
|
}),
|
|
})),
|
|
})),
|
|
})),
|
|
};
|
|
|
|
const store: Record<string, any> = { [AUDIT_CONTEXT_KEY]: clsContext };
|
|
const cls: any = {
|
|
get: jest.fn((key: string) => store[key]),
|
|
set: jest.fn((key: string, val: any) => {
|
|
store[key] = val;
|
|
}),
|
|
};
|
|
|
|
const service = new DatabaseAuditService(db, cls);
|
|
return {
|
|
service,
|
|
inserted,
|
|
updated,
|
|
cls,
|
|
store,
|
|
failInsert: () => {
|
|
failNextInsert = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
const applyPayload = () => ({
|
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
|
resourceType: AuditResource.COMMENT,
|
|
resourceId: 'c-1',
|
|
spaceId: 'space-1',
|
|
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
|
|
});
|
|
|
|
const ctx = (over?: any) => ({
|
|
workspaceId: 'ws-1',
|
|
actorId: 'u-2',
|
|
actorType: 'user',
|
|
ipAddress: '10.0.0.1',
|
|
userAgent: 'jest',
|
|
...over,
|
|
});
|
|
|
|
it('log() persists a row with the CLS context merged onto the payload', async () => {
|
|
const { service, inserted } = makeService(ctx());
|
|
service.log(applyPayload());
|
|
// log() is fire-and-forget; flush the microtask queue.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(inserted).toHaveLength(1);
|
|
expect(inserted[0]).toMatchObject({
|
|
workspaceId: 'ws-1',
|
|
actorId: 'u-2',
|
|
actorType: 'user',
|
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
|
resourceType: AuditResource.COMMENT,
|
|
resourceId: 'c-1',
|
|
spaceId: 'space-1',
|
|
ipAddress: '10.0.0.1',
|
|
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
|
|
});
|
|
});
|
|
|
|
it('log() is a no-op when there is no workspace in scope', async () => {
|
|
const { service, inserted } = makeService(ctx({ workspaceId: null }));
|
|
service.log(applyPayload());
|
|
await Promise.resolve();
|
|
expect(inserted).toHaveLength(0);
|
|
});
|
|
|
|
it('log() drops EXCLUDED_AUDIT_EVENTS (e.g. comment.created)', async () => {
|
|
const { service, inserted } = makeService(ctx());
|
|
service.log({
|
|
event: AuditEvent.COMMENT_CREATED,
|
|
resourceType: AuditResource.COMMENT,
|
|
resourceId: 'c-1',
|
|
spaceId: 'space-1',
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(inserted).toHaveLength(0);
|
|
});
|
|
|
|
it('a failed insert is swallowed with a warn and floats no rejection (audit is a side-record)', async () => {
|
|
// This pins the load-bearing swallow inside persist(). Because log() is
|
|
// fire-and-forget (`void this.persist(...)`), it always returns synchronously
|
|
// without throwing — so `not.toThrow()` alone would stay green even if the
|
|
// try/catch were removed. We instead observe the two effects the catch is
|
|
// responsible for: a warn IS emitted, and NO unhandled rejection floats.
|
|
// Removing persist()'s try/catch reddens both assertions (warn count 0 + a
|
|
// captured rejection).
|
|
const warnSpy = jest
|
|
.spyOn(Logger.prototype, 'warn')
|
|
.mockImplementation(() => undefined as any);
|
|
const rejections: unknown[] = [];
|
|
const onRejection = (err: unknown) => rejections.push(err);
|
|
process.on('unhandledRejection', onRejection);
|
|
try {
|
|
const { service, failInsert } = makeService(ctx());
|
|
failInsert();
|
|
expect(() => service.log(applyPayload())).not.toThrow();
|
|
// Flush microtasks so the rejected insert settles, then give any floated
|
|
// rejection a macrotask tick to be reported by the runtime.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(String(warnSpy.mock.calls[0][0])).toContain(
|
|
'Failed to persist audit event',
|
|
);
|
|
expect(rejections).toHaveLength(0);
|
|
} finally {
|
|
process.off('unhandledRejection', onRejection);
|
|
warnSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('logWithContext() persists with an explicit (non-request) context', async () => {
|
|
const { service, inserted } = makeService(undefined);
|
|
service.logWithContext(applyPayload(), ctx({ actorType: 'system' }) as any);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(inserted).toHaveLength(1);
|
|
expect(inserted[0].actorType).toBe('system');
|
|
expect(inserted[0].workspaceId).toBe('ws-1');
|
|
});
|
|
|
|
it('logBatchWithContext() inserts only non-excluded events', async () => {
|
|
const { service, inserted } = makeService(undefined);
|
|
service.logBatchWithContext(
|
|
[
|
|
applyPayload(),
|
|
{
|
|
event: AuditEvent.COMMENT_CREATED,
|
|
resourceType: AuditResource.COMMENT,
|
|
resourceId: 'c-2',
|
|
},
|
|
],
|
|
ctx() as any,
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
// Batch is a single insert call carrying only the applied event.
|
|
expect(inserted).toHaveLength(1);
|
|
expect(inserted[0]).toHaveLength(1);
|
|
expect(inserted[0][0].event).toBe(AuditEvent.COMMENT_SUGGESTION_APPLIED);
|
|
});
|
|
|
|
it('setActorId / setActorType mutate the ambient CLS context', () => {
|
|
const { service, store } = makeService(ctx({ actorId: null }));
|
|
service.setActorId('u-9');
|
|
service.setActorType('api_key');
|
|
expect(store[AUDIT_CONTEXT_KEY].actorId).toBe('u-9');
|
|
expect(store[AUDIT_CONTEXT_KEY].actorType).toBe('api_key');
|
|
});
|
|
|
|
it('updateRetention() writes the workspace retention window', async () => {
|
|
const { service, updated } = makeService(ctx());
|
|
await service.updateRetention('ws-1', 30);
|
|
expect(updated).toEqual([{ auditRetentionDays: 30 }]);
|
|
});
|
|
});
|