102 lines
5.2 KiB
TypeScript
102 lines
5.2 KiB
TypeScript
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();
|
|
});
|
|
});
|