feat: implement sensitive operation audit retention (TASK-WP6-04)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { auditRetentionMilliseconds, serializeAuditSummary } from "../../apps/api/src/audit-policy.js";
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
import { wp604OperationMatrix } from "../fixtures/wp6-04-audit.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
const now = Date.parse("2026-08-04T09:30:00.000Z");
|
||||
const requestHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp6-04-api-"));
|
||||
roots.push(root);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0xa1),
|
||||
clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||
databasePath: join(root, "dada.sqlite3"),
|
||||
invitePepper: Buffer.alloc(32, 0xa2),
|
||||
resend: new MockResendAdapter(),
|
||||
sessionPepper: Buffer.alloc(32, 0xa3),
|
||||
});
|
||||
services.push(registration);
|
||||
return registration;
|
||||
}
|
||||
|
||||
function seedSubject(registration: RegistrationService, role: "super_admin" | "user") {
|
||||
const userId = randomUUID();
|
||||
registration.database.prepare(`
|
||||
INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||
registration_id, created_at
|
||||
) VALUES (?, ?, ?, 'active', ?, ?, ?)
|
||||
`).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now);
|
||||
if (role === "super_admin") {
|
||||
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function seedAuditRows(registration: RegistrationService, adminId: string) {
|
||||
const operationInsert = registration.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'super_admin', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
wp604OperationMatrix.forEach((entry, index) => {
|
||||
const occurredAt = now - index * 1_000;
|
||||
operationInsert.run(
|
||||
randomUUID(), adminId, entry.operation_type, entry.target_type, randomUUID(),
|
||||
entry.operation_type === "asset_cleanup_reference_denied" || entry.operation_type === "asset_cleanup_physical_failed" ? "failed" : "succeeded",
|
||||
serializeAuditSummary({ status: "before" }), serializeAuditSummary({ count: index, status: "after" }),
|
||||
occurredAt, occurredAt + auditRetentionMilliseconds,
|
||||
);
|
||||
});
|
||||
registration.database.prepare(`
|
||||
INSERT INTO private_content_access_logs (
|
||||
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, 'prompt', ?, ?)
|
||||
`).run(randomUUID(), adminId, randomUUID(), randomUUID(), now - 60_000, now - 60_000 + auditRetentionMilliseconds);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TDD-WP6-AUD-001-sensitive-operations", () => {
|
||||
it("keeps operation and private-content audit APIs separate, admin-only, redacted, and cursor-paged", async () => {
|
||||
const registration = fixture();
|
||||
const adminId = seedSubject(registration, "super_admin");
|
||||
const userId = seedSubject(registration, "user");
|
||||
seedAuditRows(registration, adminId);
|
||||
const admin = registration.issueAuthenticatedSession(adminId, "admin");
|
||||
const ordinary = registration.issueAuthenticatedSession(userId, "user");
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||
|
||||
for (const path of ["operations", "private-content"]) {
|
||||
const denied = await app.inject({
|
||||
headers: { ...requestHeaders, cookie: `dada_admin_session=${ordinary.sessionToken}` },
|
||||
method: "GET",
|
||||
url: `/api/v1/admin/audit/${path}?limit=2`,
|
||||
});
|
||||
expect(denied.statusCode).toBe(401);
|
||||
}
|
||||
|
||||
const headers = { ...requestHeaders, cookie: `dada_admin_session=${admin.sessionToken}` };
|
||||
const first = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/operations?limit=2" });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.json().items).toHaveLength(2);
|
||||
expect(first.json().next_cursor).toEqual(expect.any(String));
|
||||
const second = await app.inject({ headers, method: "GET", url: `/api/v1/admin/audit/operations?limit=2&cursor=${first.json().next_cursor}` });
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.json().items[0].log_id).not.toBe(first.json().items[0].log_id);
|
||||
|
||||
const privateAccess = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/private-content?limit=20" });
|
||||
expect(privateAccess.statusCode).toBe(200);
|
||||
expect(privateAccess.json().items).toHaveLength(1);
|
||||
expect(privateAccess.json().items[0]).toMatchObject({ content_type: "prompt" });
|
||||
expect(JSON.stringify(first.json())).not.toMatch(/content_type|subject_ref|prompt|image|email|secret|path/i);
|
||||
expect(JSON.stringify(privateAccess.json())).not.toMatch(/operation_type|before_summary|after_summary|email|secret|path/i);
|
||||
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD;
|
||||
if (evidenceRoot) {
|
||||
mkdirSync(evidenceRoot, { recursive: true });
|
||||
writeFileSync(resolve(evidenceRoot, "redaction.json"), `${JSON.stringify({
|
||||
operation_fields: Object.keys(first.json().items[0]).sort(),
|
||||
private_access_fields: Object.keys(privateAccess.json().items[0]).sort(),
|
||||
sensitive_fields_present: false,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user