fix(audit): preserve source event timestamps

This commit is contained in:
Saravana Achu Mac 2026-04-04 11:27:21 -07:00
parent eee122506c
commit fdf9286e34
4 changed files with 39 additions and 5 deletions

View File

@ -31,7 +31,9 @@ async function request<T = unknown>(opts: FetchOptions): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`platform-service ${opts.method} ${opts.path}${res.status}: ${text.slice(0, 200)}`);
throw new Error(
`platform-service ${opts.method} ${opts.path}${res.status}: ${text.slice(0, 200)}`
);
}
return res.json() as Promise<T>;
@ -43,11 +45,14 @@ export interface AuditEntry {
userId: string;
action: string;
category?: string;
timestamp?: string;
details?: Record<string, unknown>;
}
/** POST /audit — fire-and-forget audit write. Returns { accepted: true }. */
export async function postAuditEvents(entries: AuditEntry[]): Promise<{ posted: number; errors: number }> {
export async function postAuditEvents(
entries: AuditEntry[]
): Promise<{ posted: number; errors: number }> {
let posted = 0;
let errors = 0;
for (const entry of entries) {
@ -73,7 +78,7 @@ export interface TelemetryEvent {
/** POST /telemetry/events — batch ingest telemetry events. */
export async function postTelemetryEvents(
events: TelemetryEvent[],
events: TelemetryEvent[]
): Promise<{ accepted: number; rejected: number }> {
if (events.length === 0) return { accepted: 0, rejected: 0 };
return request<{ accepted: number; rejected: number }>({
@ -102,7 +107,9 @@ export async function postUsageRecord(record: UsageRecord): Promise<unknown> {
}
/** Post multiple usage records (budget flush). */
export async function postUsageRecords(records: UsageRecord[]): Promise<{ posted: number; errors: number }> {
export async function postUsageRecords(
records: UsageRecord[]
): Promise<{ posted: number; errors: number }> {
let posted = 0;
let errors = 0;
for (const record of records) {
@ -120,7 +127,7 @@ export async function postUsageRecords(records: UsageRecord[]): Promise<{ posted
/** GET /flags/poll — poll current flag values for this product. */
export async function pollFlags(
platform?: string,
platform?: string
): Promise<{ flags: Record<string, boolean>; productId: string }> {
const params = new URLSearchParams();
if (platform) params.set('platform', platform);

View File

@ -52,6 +52,30 @@ describe('auditRoutes', () => {
expect(repoMock.create).toHaveBeenCalled();
});
it('POST /audit preserves source timestamp when provided', async () => {
repoMock.create.mockResolvedValue(undefined);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/audit',
payload: {
userId: 'user_1',
action: 'tool_executed',
category: 'agent',
timestamp: '2026-04-04T17:45:00.000Z',
details: { eventId: 'evt_cowork_1', taskId: 'task_1' },
},
});
expect(res.statusCode).toBe(202);
expect(repoMock.create).toHaveBeenCalledWith(
expect.objectContaining({
timestamp: '2026-04-04T17:45:00.000Z',
})
);
});
it('POST /audit returns 400 on invalid payload', async () => {
const app = await buildApp();

View File

@ -25,6 +25,7 @@ export async function auditRoutes(app: FastifyInstance) {
id: `aud_${crypto.randomUUID()}`,
productId,
...input,
timestamp: input.timestamp ?? new Date().toISOString(),
createdAt: new Date().toISOString(),
};
// Fire-and-forget — don't await

View File

@ -11,6 +11,7 @@ export interface AuditDoc {
action: string;
category: string;
details: Record<string, unknown>;
timestamp?: string;
ipAddress?: string;
userAgent?: string;
createdAt: string;
@ -22,6 +23,7 @@ export const CreateAuditSchema = z.object({
action: z.string().min(1),
category: z.string().default('general'),
details: z.record(z.unknown()).default({}),
timestamp: z.string().datetime().optional(),
ipAddress: z.string().optional(),
userAgent: z.string().optional(),
});