207 lines
10 KiB
TypeScript
207 lines
10 KiB
TypeScript
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 accessActorRef = randomUUID();
|
|
const accessTargetRef = randomUUID();
|
|
const accessExpiresAt = fixedNow + 180 * 86_400_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(), accessActorRef, userId, accessTargetRef, fixedNow, 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(accessTargetRef);
|
|
|
|
const access = harness.service.database.prepare("SELECT * FROM private_content_access_logs").get();
|
|
expect(access).toMatchObject({ actor_ref: accessActorRef, expires_at: accessExpiresAt });
|
|
expect(access.subject_ref).not.toBe(userId);
|
|
expect(access.target_ref).not.toBe(accessTargetRef);
|
|
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,
|
|
});
|
|
});
|
|
});
|