feat: implement TASK-WP1-05 account deletion
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
|
||||
|
||||
const now = Date.parse("2026-07-28T12:00:00.000Z");
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
const storages: ManagedStorage[] = [];
|
||||
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
|
||||
async function harness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-05-api-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "db", "dada.sqlite3");
|
||||
const storage = new ManagedStorage({ dataRoot: root, databasePath });
|
||||
storages.push(storage);
|
||||
const resend = new MockResendAdapter();
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x71), clock: () => now, codeGenerator: () => "539126",
|
||||
currentPrivacyNoticeVersion: registrationNotice.version, databasePath,
|
||||
inviteCodeGenerator: () => "DADA-WP1-05-API", invitePepper: Buffer.alloc(32, 0x72), resend,
|
||||
sessionPepper: Buffer.alloc(32, 0x73),
|
||||
});
|
||||
services.push(registration);
|
||||
const email = "account-api@example.invalid";
|
||||
const invite = registration.createInvite({ expiresAt: now + 86_400_000, maxUses: 1 });
|
||||
const sent = await registration.sendRegistrationCode({ email, inviteCode: invite.code });
|
||||
const completed = registration.completeRegistration({
|
||||
code: resend.readLatestCode(email), creatorName: "Account API", idempotencyKey: `register-${randomUUID()}-${randomUUID()}`,
|
||||
privacyConsentAccepted: true, privacyNoticeVersion: registrationNotice.version,
|
||||
registrationId: sent.registrationId, socialId: "@account_api",
|
||||
});
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||
return { app, completed, email, registration, resend };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const storage of storages.splice(0)) storage.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TDD-WP1-DEL-001 account settings and deletion API", () => {
|
||||
it("serves safe settings and deletes only after CSRF plus a fresh email code", async () => {
|
||||
const { app, completed, email, registration, resend } = await harness();
|
||||
const cookie = `dada_session=${completed.sessionToken}`;
|
||||
const session = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||
const settings = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/account/settings" });
|
||||
expect(settings.statusCode).toBe(200);
|
||||
expect(settings.json()).toMatchObject({
|
||||
account: { email, status: "active" },
|
||||
local_data: { backup_enabled: false, migration_supported: false, location: "configured_local_data_root" },
|
||||
profile: { creator_name: "Account API", social_id: "@account_api" },
|
||||
});
|
||||
expect(JSON.stringify(settings.json())).not.toMatch(/[A-Z]:\\/i);
|
||||
const csrf = settings.json().csrf_token;
|
||||
|
||||
const deletionSend = await app.inject({
|
||||
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "POST", url: "/api/v1/account/deletion/send",
|
||||
});
|
||||
expect(deletionSend.statusCode).toBe(200);
|
||||
const code = resend.readLatestCode(email);
|
||||
const rejected = await app.inject({
|
||||
headers: {
|
||||
...headers, cookie, "idempotency-key": `reject-${randomUUID()}-${randomUUID()}`, "x-csrf-token": csrf,
|
||||
},
|
||||
method: "POST",
|
||||
payload: { confirmation: "注销账号", deletion_id: deletionSend.json().deletion_id, verification_code: "000000" },
|
||||
url: "/api/v1/account/deletion/complete",
|
||||
});
|
||||
expect(rejected.statusCode).toBe(400);
|
||||
expect(registration.database.prepare("SELECT failure_count FROM account_deletion_challenges").get().failure_count).toBe(1);
|
||||
expect(registration.readUserSession(completed.sessionToken)).toBeDefined();
|
||||
const deletion = await app.inject({
|
||||
headers: {
|
||||
...headers, cookie, "idempotency-key": `delete-${randomUUID()}-${randomUUID()}`, "x-csrf-token": csrf,
|
||||
},
|
||||
method: "POST",
|
||||
payload: { confirmation: "注销账号", deletion_id: deletionSend.json().deletion_id, verification_code: code },
|
||||
url: "/api/v1/account/deletion/complete",
|
||||
});
|
||||
expect(deletion.statusCode).toBe(200);
|
||||
expect(deletion.json()).toEqual({ status: "deleted" });
|
||||
expect(deletion.headers["set-cookie"]).toContain("Max-Age=0");
|
||||
expect(JSON.stringify(deletion.json())).not.toContain(email);
|
||||
|
||||
const oldSession = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||
expect(oldSession.statusCode).toBe(401);
|
||||
expect(registration.readUserSession(completed.sessionToken)).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
const settings = {
|
||||
account: { email: "settings@example.invalid", status: "active" },
|
||||
csrf_token: "csrf-fixture-token-0000000000000000000000000000000000000000000",
|
||||
local_data: {
|
||||
backup_enabled: false, capacity_status: "full", hard_limit_bytes: 5368709120,
|
||||
location: "configured_local_data_root", managed_content_bytes: 5368709120, migration_supported: false,
|
||||
},
|
||||
profile: { creator_name: "Settings User", social_id: "@settings_user" },
|
||||
};
|
||||
|
||||
test("TDD-WP1-DEL-001 renders Ws5E0 states and returns to login after deletion", async ({ page }) => {
|
||||
let deletionCalls = 0;
|
||||
await page.route("**/api/v1/account/settings", async (route) => {
|
||||
await new Promise((done) => setTimeout(done, 100));
|
||||
await route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify(settings) });
|
||||
});
|
||||
await page.route("**/api/v1/account/deletion/send", (route) => route.fulfill({
|
||||
contentType: "application/json", status: 200,
|
||||
body: JSON.stringify({ challenge_expires_at: "2026-07-28T12:10:00.000Z", deletion_id: "00000000-0000-4000-8000-000000000051", resend_available_at: "2026-07-28T12:01:00.000Z", status: "verification_sent" }),
|
||||
}));
|
||||
await page.route("**/api/v1/account/deletion/complete", async (route) => {
|
||||
deletionCalls += 1;
|
||||
await new Promise((done) => setTimeout(done, 150));
|
||||
await route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ status: "deleted" }) });
|
||||
});
|
||||
|
||||
await page.goto(`${webUrl}/app/settings`);
|
||||
await expect(page.getByText("正在读取设置")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "设置与本机数据" })).toBeVisible();
|
||||
await expect(page.getByLabel("创作署名")).toHaveValue("Settings User");
|
||||
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
||||
await expect(page.getByText("本机容量已满")).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText("C:\\Users");
|
||||
|
||||
await page.getByRole("button", { name: "注销账号" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "确认注销账号" });
|
||||
await expect(dialog).toContainText("不可恢复");
|
||||
await expect(dialog).toContainText("180 天");
|
||||
await page.getByRole("button", { name: "获取注销验证码" }).click();
|
||||
await page.getByLabel("注销验证码").fill("539126");
|
||||
await page.getByLabel("确认词").fill("注销账号");
|
||||
await page.getByRole("button", { name: "永久注销" }).click();
|
||||
await expect(page.getByRole("button", { name: "注销处理中" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "关闭注销确认" })).toBeDisabled();
|
||||
await page.getByRole("button", { name: "注销处理中" }).click({ force: true });
|
||||
await expect(page).toHaveURL(`${webUrl}/`);
|
||||
expect(deletionCalls).toBe(1);
|
||||
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DELETE;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true });
|
||||
await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "settings-after-delete.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP1-DEL-001 keeps profile input on save failure and exposes unavailable deletion", async ({ page }) => {
|
||||
await page.setViewportSize({ height: 667, width: 375 });
|
||||
await page.route("**/api/v1/account/settings", (route) => route.fulfill({
|
||||
contentType: "application/json", status: 200,
|
||||
body: JSON.stringify({ ...settings, local_data: { ...settings.local_data, capacity_status: "unavailable" } }),
|
||||
}));
|
||||
await page.route("**/api/v1/account/settings/profile", (route) => route.fulfill({
|
||||
contentType: "application/json", status: 503,
|
||||
body: JSON.stringify({ error: { code: "AUTH_SERVICE_UNAVAILABLE", correlation_id: "00000000-0000-4000-8000-000000000052", details: {}, message_key: "auth.service.unavailable" } }),
|
||||
}));
|
||||
await page.goto(`${webUrl}/app/settings`);
|
||||
await page.getByLabel("创作署名").fill("Unsaved Name");
|
||||
await page.getByRole("button", { name: "保存资料" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("保存失败");
|
||||
await expect(page.getByLabel("创作署名")).toHaveValue("Unsaved Name");
|
||||
await expect(page.getByText("本机数据暂不可写")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "注销账号" })).toBeEnabled();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
|
||||
await page.getByRole("button", { name: "注销账号" }).click();
|
||||
const dialogBounds = await page.getByRole("dialog", { name: "确认注销账号" }).boundingBox();
|
||||
expect(dialogBounds).not.toBeNull();
|
||||
expect(dialogBounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(dialogBounds!.x + dialogBounds!.width).toBeLessThanOrEqual(375);
|
||||
await expect(page.getByRole("button", { name: "关闭注销确认" })).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
|
||||
|
||||
const fixedNow = Date.parse("2026-07-28T10:00:00.000Z");
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
const storages: ManagedStorage[] = [];
|
||||
|
||||
function writeEvidence(environmentName: string, file: string, value: unknown) {
|
||||
const directory = process.env[environmentName];
|
||||
if (!directory) return;
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function createRegisteredHarness(email = "delete-me@example.invalid") {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-05-delete-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "db", "dada.sqlite3");
|
||||
const storage = new ManagedStorage({ dataRoot: root, databasePath });
|
||||
storages.push(storage);
|
||||
const resend = new MockResendAdapter();
|
||||
const service = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x51),
|
||||
clock: () => fixedNow,
|
||||
codeGenerator: () => "735104",
|
||||
currentPrivacyNoticeVersion: registrationNotice.version,
|
||||
databasePath,
|
||||
inviteCodeGenerator: () => `DADA-DELETE-${randomUUID()}`,
|
||||
invitePepper: Buffer.alloc(32, 0x52),
|
||||
resend,
|
||||
sessionPepper: Buffer.alloc(32, 0x53),
|
||||
});
|
||||
services.push(service);
|
||||
const invite = service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 });
|
||||
const sent = await service.sendRegistrationCode({ email, inviteCode: invite.code });
|
||||
const completed = service.completeRegistration({
|
||||
code: resend.readLatestCode(email),
|
||||
creatorName: "Delete Me",
|
||||
idempotencyKey: `register-${randomUUID()}-${randomUUID()}`,
|
||||
privacyConsentAccepted: true,
|
||||
privacyNoticeVersion: registrationNotice.version,
|
||||
registrationId: sent.registrationId,
|
||||
socialId: "@delete_me",
|
||||
});
|
||||
return { completed, databasePath, email, resend, root, service, storage };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const storage of storages.splice(0)) storage.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TDD-WP1-DEL-001-account-delete", () => {
|
||||
it("revokes every session, removes identity and queues owned files in one deletion transaction", async () => {
|
||||
const harness = await createRegisteredHarness();
|
||||
const userId = harness.completed.user.userId;
|
||||
const secondSession = harness.service.issueAuthenticatedSession(userId, "user");
|
||||
const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken);
|
||||
const files = await Promise.all([
|
||||
harness.storage.commitStream({
|
||||
content: Readable.from(Buffer.from("reference")), expectedMimeType: "application/octet-stream",
|
||||
fileKind: "reference", fileName: "reference.bin", operationId: randomUUID(), ownerRef: userId, projectedWriteBytes: 9,
|
||||
}),
|
||||
harness.storage.commitStream({
|
||||
content: Readable.from(Buffer.from("generated")), expectedMimeType: "application/octet-stream",
|
||||
fileKind: "generated", fileName: "generated.bin", operationId: randomUUID(), ownerRef: userId, projectedWriteBytes: 9,
|
||||
}),
|
||||
harness.storage.commitStream({
|
||||
content: Readable.from(Buffer.from("export")), expectedMimeType: "application/octet-stream",
|
||||
fileKind: "export", fileName: "export.bin", operationId: randomUUID(), ownerRef: userId, projectedWriteBytes: 6,
|
||||
}),
|
||||
]);
|
||||
|
||||
const sent = await harness.service.sendAccountDeletionCode({
|
||||
csrfToken,
|
||||
sessionToken: harness.completed.sessionToken,
|
||||
});
|
||||
const result = harness.service.completeAccountDeletion({
|
||||
code: harness.resend.readLatestCode(harness.email),
|
||||
confirmation: "注销账号",
|
||||
csrfToken,
|
||||
deletionId: sent.deletionId,
|
||||
idempotencyKey: `delete-${randomUUID()}-${randomUUID()}`,
|
||||
sessionToken: harness.completed.sessionToken,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: "deleted" });
|
||||
expect(harness.service.readUserSession(harness.completed.sessionToken)).toBeUndefined();
|
||||
expect(harness.service.readUserSession(secondSession.sessionToken)).toBeUndefined();
|
||||
const databaseState = {
|
||||
account: harness.service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE user_id = ?").get(userId).count,
|
||||
consents: harness.service.database.prepare("SELECT COUNT(*) AS count FROM privacy_consents WHERE user_id = ?").get(userId).count,
|
||||
credits: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_accounts WHERE user_id = ?").get(userId).count,
|
||||
ledger: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE user_id = ?").get(userId).count,
|
||||
profiles: harness.service.database.prepare("SELECT COUNT(*) AS count FROM user_profiles WHERE user_id = ?").get(userId).count,
|
||||
sessions: harness.service.database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ?").get(userId).count,
|
||||
};
|
||||
expect(databaseState).toEqual({ account: 0, consents: 0, credits: 0, ledger: 0, profiles: 0, sessions: 0 });
|
||||
expect(files.every((file) => harness.storage.resolveManagedFile(file.file_id) === undefined)).toBe(true);
|
||||
expect(harness.storage.inspectCounts().pending_cleanup).toBe(3);
|
||||
expect(harness.service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE normalized_email = ?").get(harness.email).count).toBe(0);
|
||||
|
||||
const freshInvite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 });
|
||||
const freshSent = await harness.service.sendRegistrationCode({ email: harness.email, inviteCode: freshInvite.code });
|
||||
const fresh = harness.service.completeRegistration({
|
||||
code: harness.resend.readLatestCode(harness.email),
|
||||
creatorName: "Fresh Account",
|
||||
idempotencyKey: `fresh-${randomUUID()}-${randomUUID()}`,
|
||||
privacyConsentAccepted: true,
|
||||
privacyNoticeVersion: registrationNotice.version,
|
||||
registrationId: freshSent.registrationId,
|
||||
socialId: "@fresh_account",
|
||||
});
|
||||
expect(fresh.user.userId).not.toBe(userId);
|
||||
expect(fresh.credits).toEqual({ availableBalance: 10, reservedBalance: 0 });
|
||||
const cleanup = await harness.storage.processCleanupQueue();
|
||||
expect(cleanup).toEqual({ completed: 3, failed: 0 });
|
||||
|
||||
writeEvidence("DADA_EVIDENCE_DIR_DELETE", "response.json", {
|
||||
old_access: "revoked",
|
||||
old_resources: "not_found",
|
||||
same_email_registration: "fresh_account",
|
||||
status: result.status,
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_DELETE", "db-diff.json", {
|
||||
...databaseState,
|
||||
cleanup_queued: 3,
|
||||
old_user_id_reused: false,
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_DELETE", "worker-events.json", {
|
||||
cleanup_completed: cleanup.completed,
|
||||
cleanup_failed: cleanup.failed,
|
||||
physical_files_remaining: files.filter((file) => existsSync(resolve(harness.root, file.relative_path))).length,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP1-DEL-002-anonymous-retention", () => {
|
||||
it("projects identity-linked records onto the allowed anonymous fields without extending access audit expiry", async () => {
|
||||
const harness = await createRegisteredHarness("retention@example.invalid");
|
||||
const userId = harness.completed.user.userId;
|
||||
const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken);
|
||||
const accessExpiresAt = fixedNow + 30_000;
|
||||
harness.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(randomUUID(), "admin-fixture", userId, "private-target-fixture", fixedNow - 1_000, accessExpiresAt);
|
||||
|
||||
const sent = await harness.service.sendAccountDeletionCode({ csrfToken, sessionToken: harness.completed.sessionToken });
|
||||
harness.service.completeAccountDeletion({
|
||||
code: harness.resend.readLatestCode(harness.email), confirmation: "注销账号", csrfToken,
|
||||
deletionId: sent.deletionId, idempotencyKey: `retain-${randomUUID()}-${randomUUID()}`,
|
||||
sessionToken: harness.completed.sessionToken,
|
||||
});
|
||||
|
||||
const anonymous = harness.service.database.prepare("SELECT * FROM anonymous_retained_events").all();
|
||||
expect(anonymous).toHaveLength(1);
|
||||
expect(Object.keys(anonymous[0]).sort()).toEqual([
|
||||
"anonymous_subject_id", "credit_delta", "error_category", "event_id", "event_type",
|
||||
"expires_at", "model_id", "occurred_at", "outcome",
|
||||
]);
|
||||
expect(anonymous[0]).toMatchObject({
|
||||
credit_delta: 10,
|
||||
error_category: null,
|
||||
event_type: "registration_grant",
|
||||
expires_at: fixedNow + 180 * 86_400_000,
|
||||
model_id: null,
|
||||
outcome: "succeeded",
|
||||
});
|
||||
expect(JSON.stringify(anonymous)).not.toContain(harness.email);
|
||||
expect(JSON.stringify(anonymous)).not.toContain(userId);
|
||||
expect(JSON.stringify(anonymous)).not.toContain("private-target-fixture");
|
||||
|
||||
const access = harness.service.database.prepare("SELECT * FROM private_content_access_logs").get();
|
||||
expect(access).toMatchObject({ actor_ref: "admin-fixture", expires_at: accessExpiresAt });
|
||||
expect(access.subject_ref).not.toBe(userId);
|
||||
expect(access.target_ref).not.toBe("private-target-fixture");
|
||||
expect(access.subject_ref).not.toBe(anonymous[0].anonymous_subject_id);
|
||||
|
||||
writeEvidence("DADA_EVIDENCE_DIR_RETENTION", "db-diff.json", {
|
||||
access_actor_preserved: true,
|
||||
access_expiry_preserved: access.expires_at === accessExpiresAt,
|
||||
identity_relationships: 0,
|
||||
anonymous_events: anonymous.length,
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_RETENTION", "redaction.json", {
|
||||
forbidden_fields_present: [],
|
||||
original_identifiers_present: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 { RetentionCleanup } from "../../apps/worker/src/retention-cleanup.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
|
||||
|
||||
const baseNow = Date.parse("2026-07-28T11:00:00.000Z");
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
|
||||
function writeEvidence(value: unknown) {
|
||||
const directory = process.env.DADA_EVIDENCE_DIR_RETENTION;
|
||||
if (!directory) return;
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, "retention-events.json"), `${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-DEL-002 retention worker", () => {
|
||||
it("refuses early/manual deletion and removes each row only at its original expiry", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-05-retention-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const service = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x61), clock: () => baseNow, codeGenerator: () => "120984",
|
||||
currentPrivacyNoticeVersion: registrationNotice.version, databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x62), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x63),
|
||||
});
|
||||
services.push(service);
|
||||
const accessExpiry = baseNow + 1_000;
|
||||
const anonymousExpiry = baseNow + 2_000;
|
||||
service.database.prepare(`
|
||||
INSERT INTO private_content_access_logs (log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at)
|
||||
VALUES (?, 'admin-fixture', ?, ?, 'prompt', ?, ?)
|
||||
`).run(randomUUID(), randomUUID(), randomUUID(), baseNow - 10_000, accessExpiry);
|
||||
service.database.prepare(`
|
||||
INSERT INTO anonymous_retained_events (
|
||||
event_id, anonymous_subject_id, event_type, model_id, outcome, error_category,
|
||||
credit_delta, occurred_at, expires_at
|
||||
) VALUES (?, ?, 'generation_commit', 'model-a', 'succeeded', NULL, -1, ?, ?)
|
||||
`).run(randomUUID(), randomUUID(), baseNow - 10_000, anonymousExpiry);
|
||||
|
||||
expect(() => service.database.prepare("DELETE FROM anonymous_retained_events").run()).toThrow();
|
||||
expect(() => service.database.prepare("DELETE FROM private_content_access_logs").run()).toThrow();
|
||||
|
||||
const early = new RetentionCleanup({ clock: () => baseNow, databasePath });
|
||||
expect(early.purgeExpired()).toEqual({ anonymous_events: 0, private_access_logs: 0 });
|
||||
early.close();
|
||||
const firstExpiry = new RetentionCleanup({ clock: () => accessExpiry, databasePath });
|
||||
expect(firstExpiry.purgeExpired()).toEqual({ anonymous_events: 0, private_access_logs: 1 });
|
||||
firstExpiry.close();
|
||||
const secondExpiry = new RetentionCleanup({ clock: () => anonymousExpiry, databasePath });
|
||||
expect(secondExpiry.purgeExpired()).toEqual({ anonymous_events: 1, private_access_logs: 0 });
|
||||
secondExpiry.close();
|
||||
|
||||
writeEvidence({
|
||||
anonymous_deleted_at_expiry: true,
|
||||
early_delete_blocked: true,
|
||||
private_access_deleted_at_original_expiry: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user