feat(audit): DB-backed audit trail вместо Noop (#496)
Событие comment.suggestion_applied/dismissed раньше улетало в NoopAuditService и молча терялось; при childless-ветке apply/dismiss комментарий hard-delete'ится, поэтому восстановить, кто и что решил, было невозможно. - DatabaseAuditService пишет в уже существующую таблицу `audit` (миграция 20260228T223532); actor/workspace/ip берутся из CLS AuditContext, вне HTTP — из явного контекста (logWithContext). Аудит — побочная запись: сбой записи не ломает исходный запрос, EXCLUDED_AUDIT_EVENTS отбрасываются. - Биндинг AUDIT_SERVICE переключён с Noop на DatabaseAuditService. - payload apply/dismiss дополнен suggestedText/selection/commentAuthor/ decidedBy — на childless-ветке это единственная уцелевшая запись. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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 never throws to the caller (audit is a side-record)', async () => {
|
||||
const { service, failInsert } = makeService(ctx());
|
||||
failInsert();
|
||||
// Must not reject / throw.
|
||||
expect(() => service.log(applyPayload())).not.toThrow();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user