137 lines
6.8 KiB
TypeScript
137 lines
6.8 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { createRequire } from "node:module";
|
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
|
import { RetentionCleanup } from "../../apps/worker/src/retention-cleanup.js";
|
|
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
|
|
|
|
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
|
const Database = requireFromApi("better-sqlite3");
|
|
const retentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
|
const baseNow = Date.now();
|
|
const roots: string[] = [];
|
|
const services: RegistrationService[] = [];
|
|
|
|
function createHarness() {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-06-audit-"));
|
|
roots.push(root);
|
|
const databasePath = join(root, "dada.sqlite3");
|
|
const service = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x41), clock: () => baseNow,
|
|
currentPrivacyNoticeVersion: registrationNotice.version, databasePath,
|
|
invitePepper: Buffer.alloc(32, 0x42), resend: new MockResendAdapter(),
|
|
sessionPepper: Buffer.alloc(32, 0x43),
|
|
});
|
|
services.push(service);
|
|
return { databasePath, service };
|
|
}
|
|
|
|
function insertAuditPair(service: RegistrationService, occurredAt: number) {
|
|
const adminLogId = randomUUID();
|
|
const privateLogId = randomUUID();
|
|
const expiresAt = occurredAt + retentionMilliseconds;
|
|
service.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 (?, 'system', 'backend_secure_config', 'secure_config_apply',
|
|
'secure_config_revision', 'revision:fixture', 'succeeded', ?, ?, ?, ?)
|
|
`).run(adminLogId, JSON.stringify({ revision: 1 }), JSON.stringify({ revision: 2 }), occurredAt, expiresAt);
|
|
service.database.prepare(`
|
|
INSERT INTO private_content_access_logs (
|
|
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
|
) VALUES (?, ?, ?, ?, 'image', ?, ?)
|
|
`).run(privateLogId, randomUUID(), randomUUID(), randomUUID(), occurredAt, expiresAt);
|
|
return { adminLogId, expiresAt, privateLogId };
|
|
}
|
|
|
|
function writeEvidence(file: string, value: unknown) {
|
|
const directory = process.env.DADA_EVIDENCE_DIR_AUDIT;
|
|
if (!directory) return;
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
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-WP1-AUD-001-immutable-triggers", () => {
|
|
it("blocks ordinary and forged-scope mutations but lets the Worker delete both audit classes at expiry", () => {
|
|
const { databasePath, service } = createHarness();
|
|
const pair = insertAuditPair(service, baseNow);
|
|
const ordinary = new Database(databasePath);
|
|
ordinary.pragma("busy_timeout = 5000");
|
|
try {
|
|
expect(() => ordinary.prepare("UPDATE admin_operation_logs SET result = 'failed' WHERE log_id = ?").run(pair.adminLogId)).toThrow();
|
|
expect(() => ordinary.prepare("DELETE FROM admin_operation_logs WHERE log_id = ?").run(pair.adminLogId)).toThrow();
|
|
expect(() => ordinary.prepare("UPDATE private_content_access_logs SET actor_ref = ? WHERE log_id = ?").run(randomUUID(), pair.privateLogId)).toThrow();
|
|
expect(() => ordinary.prepare("DELETE FROM private_content_access_logs WHERE log_id = ?").run(pair.privateLogId)).toThrow();
|
|
|
|
ordinary.function("dada_allow_retention_purge", () => 1);
|
|
expect(() => ordinary.prepare("DELETE FROM private_content_access_logs WHERE log_id = ?").run(pair.privateLogId)).toThrow();
|
|
ordinary.function("dada_allow_privacy_purge", () => 1);
|
|
expect(() => ordinary.prepare(`
|
|
UPDATE private_content_access_logs SET subject_ref = ?, target_ref = ? WHERE log_id = ?
|
|
`).run(randomUUID(), randomUUID(), pair.privateLogId)).toThrow();
|
|
} finally {
|
|
ordinary.close();
|
|
}
|
|
|
|
const early = new RetentionCleanup({ clock: () => pair.expiresAt - 1, databasePath });
|
|
expect(early.purgeExpired()).toEqual({ admin_operation_logs: 0, anonymous_events: 0, private_access_logs: 0 });
|
|
early.close();
|
|
const expired = new RetentionCleanup({ clock: () => pair.expiresAt, databasePath });
|
|
expect(expired.purgeExpired()).toEqual({ admin_operation_logs: 1, anonymous_events: 0, private_access_logs: 1 });
|
|
expired.close();
|
|
|
|
expect(service.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get().count).toBe(0);
|
|
expect(service.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get().count).toBe(0);
|
|
writeEvidence("sql-results.json", {
|
|
forged_scope_delete: "blocked",
|
|
forged_privacy_scope_update: "blocked",
|
|
ordinary_delete: "blocked",
|
|
ordinary_update: "blocked",
|
|
retention_delete_at_expiry: "allowed",
|
|
});
|
|
writeEvidence("db-diff.json", { admin_operation_logs: 0, private_content_access_logs: 0 });
|
|
});
|
|
|
|
it("enforces exact retention and rejects sensitive operation summaries", () => {
|
|
const { service } = createHarness();
|
|
const badExpiry = baseNow + retentionMilliseconds + 1;
|
|
expect(() => service.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(), randomUUID(), randomUUID(), randomUUID(), baseNow, badExpiry)).toThrow();
|
|
expect(() => service.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 (?, 'system', 'backend_secure_config', 'secure_config_apply',
|
|
'secure_config_revision', 'revision:fixture', 'failed', ?, NULL, ?, ?)
|
|
`).run(
|
|
randomUUID(),
|
|
JSON.stringify({ relative_path: "private/fixture", session_token: "sensitive-fixture-token" }),
|
|
baseNow,
|
|
baseNow + retentionMilliseconds,
|
|
)).toThrow();
|
|
expect(service.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get().count).toBe(0);
|
|
expect(service.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get().count).toBe(0);
|
|
|
|
const triggers = service.database.prepare(`
|
|
SELECT name, sql FROM sqlite_master
|
|
WHERE type = 'trigger' AND name LIKE '%logs_%' ORDER BY name
|
|
`).all();
|
|
writeEvidence("trigger-definition.json", { triggers });
|
|
});
|
|
});
|