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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user