Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
177 lines
9.9 KiB
TypeScript
177 lines
9.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import React from "react";
|
|
|
|
import { AccountDeletionGate } from "../src/components/account-deletion-gate";
|
|
import { AccountDeletionSection } from "../src/components/account-deletion-section";
|
|
import {
|
|
ACCOUNT_DELETION_KEPT_TABLES,
|
|
accountDeletionRequestErrorCode,
|
|
formatAccountDeletionDate,
|
|
isAccountDeletionConfirmation,
|
|
parseAccountDeletionStatus,
|
|
readPendingAccountDeletionWith,
|
|
} from "../src/lib/account-deletion";
|
|
import {
|
|
ACCOUNT_DELETION_MAX_ATTEMPTS,
|
|
purgeErrorCode,
|
|
runAccountDeletionTick,
|
|
} from "../src/lib/account-deletion-worker-core";
|
|
import { LEGAL_ENTITY } from "../src/lib/legal-entity";
|
|
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
|
|
|
// Self-service account deletion with a 7-day cooling-off period (2026-09-30).
|
|
Object.assign(globalThis, { React });
|
|
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
|
const migration = read("../supabase/migrations/20260930020000_account_deletion_requests.sql");
|
|
|
|
test("status parsing, confirmation word, date and error codes", () => {
|
|
assert.deepEqual(parseAccountDeletionStatus(null), { status: "none" });
|
|
assert.deepEqual(parseAccountDeletionStatus({ status: "none" }), { status: "none" });
|
|
assert.deepEqual(parseAccountDeletionStatus({ status: "pending", scheduledFor: "not a date" }), { status: "none" });
|
|
const pending = parseAccountDeletionStatus({ status: "pending", requestedAt: "2026-09-30T02:00:00Z", scheduledFor: "2026-10-07T02:00:00Z" });
|
|
assert.deepEqual(pending, { status: "pending", requestedAt: "2026-09-30T02:00:00.000Z", scheduledFor: "2026-10-07T02:00:00.000Z" });
|
|
assert.equal(formatAccountDeletionDate("2026-10-07T02:00:00Z"), "10 月 7 日");
|
|
assert.equal(formatAccountDeletionDate("2026-10-06T17:00:00Z"), "10 月 7 日", "Beijing date, not UTC");
|
|
assert.equal(isAccountDeletionConfirmation(" 注销 "), true);
|
|
assert.equal(isAccountDeletionConfirmation("注 销"), false);
|
|
assert.equal(isAccountDeletionConfirmation(undefined), false);
|
|
assert.equal(accountDeletionRequestErrorCode("ERROR: account_deletion_admin_account"), "admin_account");
|
|
assert.equal(accountDeletionRequestErrorCode("connection refused"), "unavailable");
|
|
});
|
|
|
|
test("the pending-deletion read never locks anyone out on an error", async () => {
|
|
const ok = { rpc: async () => ({ data: "2026-10-07T02:00:00Z", error: null }) };
|
|
assert.equal(await readPendingAccountDeletionWith(ok, "u"), "2026-10-07T02:00:00.000Z");
|
|
assert.equal(await readPendingAccountDeletionWith({ rpc: async () => ({ data: null, error: null }) }, "u"), null);
|
|
assert.equal(await readPendingAccountDeletionWith({ rpc: async () => ({ data: null, error: { message: "function does not exist" } }) }, "u"), null);
|
|
assert.equal(await readPendingAccountDeletionWith({ rpc: async () => { throw new Error("down"); } }, "u"), null);
|
|
});
|
|
|
|
test("the kept tables in code and in SQL are the same list, and finance is kept", () => {
|
|
const sqlList = migration.slice(migration.indexOf("select array["), migration.indexOf("]::text[]"));
|
|
const sqlTables = [...sqlList.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]);
|
|
assert.deepEqual(sqlTables, [...ACCOUNT_DELETION_KEPT_TABLES]);
|
|
// 2026-09-30 合并时加入:反馈与投诉保留(产品决定),联系方式在清除时置空。
|
|
assert.match(migration, /'user_feedback'/);
|
|
assert.match(migration, /update public\.user_feedback set contact = null/);
|
|
for (const table of ["payment_orders", "credit_transactions", "usage_ledger", "usage_reservations", "user_subscriptions", "birth_time_rectification_billing"]) {
|
|
assert.ok((ACCOUNT_DELETION_KEPT_TABLES as readonly string[]).includes(table), table);
|
|
}
|
|
for (const table of ["chat_sessions", "chart_profiles", "personal_reports", "synastry_reports", "profiles"]) {
|
|
assert.ok(!(ACCOUNT_DELETION_KEPT_TABLES as readonly string[]).includes(table), `${table} is deleted`);
|
|
}
|
|
});
|
|
|
|
test("the migration is add-only, all-or-nothing, and only touches ownership columns", () => {
|
|
assert.doesNotMatch(migration, /\bdrop\s+(table|column)\b|alter\s+table\s+public\.(?!account_deletion_requests)\w+\s+(drop|alter|rename)/i);
|
|
assert.match(migration, /create table if not exists public\.account_deletion_requests/);
|
|
assert.match(migration, /now\(\) \+ interval '7 days'/);
|
|
// Signed out everywhere on request.
|
|
assert.match(migration, /delete from identity\.sessions where user_id = \$1/);
|
|
// Deletes keep retrying across passes; leftovers roll the whole thing back.
|
|
assert.match(migration, /exception when foreign_key_violation/);
|
|
assert.match(migration, /raise exception 'account_deletion_purge_incomplete/);
|
|
// created_by / updated_by rows are operators' configuration, never the user's.
|
|
assert.match(migration, /att\.attname = 'user_id' or \(cls\.relname = 'profiles' and att\.attname = 'id'\)/);
|
|
// Tombstone identity: no email, name, login.
|
|
assert.match(migration, /email = ''deleted\+'' \|\| id::text \|\| ''@deleted\.invalid''/);
|
|
assert.match(migration, /delete from identity\.accounts where user_id = \$1/);
|
|
// No charge while pending, enforced in the database too.
|
|
for (const table of ["usage_reservations", "birth_time_rectification_billing", "credit_transactions"]) {
|
|
assert.match(migration, new RegExp(`before insert on public\\.${table}`), table);
|
|
}
|
|
assert.match(migration, /raise exception 'account_deletion_admin_account'/);
|
|
});
|
|
|
|
test("the purge worker completes, skips, counts failures and logs no identifiers", async () => {
|
|
const logs: string[] = [];
|
|
const failed: [string, string][] = [];
|
|
const seen: { limit: number; attempts: number }[] = [];
|
|
const result = await runAccountDeletionTick({
|
|
now: () => new Date("2026-10-08T00:00:00Z"),
|
|
listDue: async (_now, limit, attempts) => { seen.push({ limit, attempts }); return ["r1", "r2", "r3"]; },
|
|
purge: async (id) => (id === "r1" ? { ok: true, status: "completed" } : id === "r2" ? { ok: true, status: "skipped" } : { ok: false, code: "purge_incomplete" }),
|
|
markFailed: async (id, code) => { failed.push([id, code]); },
|
|
log: (line) => logs.push(line),
|
|
});
|
|
assert.deepEqual(result, { due: 3, completed: 1, skipped: 1, failed: 1 });
|
|
assert.deepEqual(failed, [["r3", "purge_incomplete"]]);
|
|
assert.equal(seen[0]?.attempts, ACCOUNT_DELETION_MAX_ATTEMPTS);
|
|
assert.equal(logs.length, 1);
|
|
assert.doesNotMatch(logs[0]!, /r1|r2|r3|@/);
|
|
assert.equal(purgeErrorCode("account_deletion_purge_incomplete: chat_sessions"), "purge_incomplete");
|
|
assert.equal(purgeErrorCode("boom"), "purge_failed");
|
|
const quiet: string[] = [];
|
|
await runAccountDeletionTick({ now: () => new Date(), listDue: async () => [], purge: async () => ({ ok: true, status: "completed" }), markFailed: async () => {}, log: (line) => quiet.push(line) });
|
|
assert.deepEqual(quiet, [], "an idle tick says nothing");
|
|
});
|
|
|
|
test("every paid route refuses a frozen account before doing work", () => {
|
|
for (const path of [
|
|
"../src/app/api/consult/route.ts",
|
|
"../src/app/api/reports/route.ts",
|
|
"../src/app/api/rectification/agent/route.ts",
|
|
"../src/app/api/rectification/cases/open/route.ts",
|
|
"../src/app/api/synastry/route.ts",
|
|
]) {
|
|
const source = read(path);
|
|
assert.match(source, /refuseWhenAccountDeletionPending\(/, path);
|
|
assert.match(source, /if \(frozen\) return frozen;/, path);
|
|
}
|
|
const server = read("../src/lib/account-deletion-server.ts");
|
|
assert.match(server, /status: 423/);
|
|
assert.match(read("../src/instrumentation.ts"), /startAccountDeletionWorker\(\);/);
|
|
});
|
|
|
|
test("the settings section explains the rule and only submits after 「注销」 is typed", async () => {
|
|
const h = createClientLifecycleHarness();
|
|
try {
|
|
await h.render(<AccountDeletionSection />);
|
|
const open = h.elements().find((node) => node.tagName === "BUTTON" && node.text.startsWith("注销账号"))!;
|
|
await h.event(open);
|
|
const text = h.container.text;
|
|
assert.match(text, /7 天内重新登录可以撤销/);
|
|
assert.match(text, /剩余点数和会员权益会一并作废/);
|
|
assert.match(text, /订单和点数流水按法规保留/);
|
|
assert.ok(text.includes(LEGAL_ENTITY.contactEmail));
|
|
const confirm = () => h.elements().find((node) => node.tagName === "BUTTON" && /确认注销|正在提交/.test(node.text))!;
|
|
assert.equal(confirm().disabled, true);
|
|
const input = h.elements().find((node) => node.tagName === "INPUT")!;
|
|
await h.event(input, "onChange", { target: { value: "注销" } });
|
|
assert.equal(confirm().disabled, false);
|
|
assert.deepEqual(h.errors, []);
|
|
} finally { await h.close(); }
|
|
});
|
|
|
|
test("a pending account sees 「账号注销中」 with the date and a way back", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () => new Response(JSON.stringify({ status: "pending", scheduledFor: "2026-10-07T02:00:00Z" }), { status: 200 })) as typeof fetch;
|
|
const h = createClientLifecycleHarness();
|
|
try {
|
|
await h.render(<AccountDeletionGate signedIn />);
|
|
await h.idle();
|
|
assert.match(h.container.text, /账号注销中/);
|
|
assert.match(h.container.text, /10 月 7 日 永久删除/);
|
|
assert.ok(h.elements().some((node) => node.tagName === "BUTTON" && node.text === "撤销注销"));
|
|
assert.ok(h.elements().some((node) => node.tagName === "BUTTON" && node.text === "退出登录"));
|
|
await h.render(<AccountDeletionGate signedIn={false} />);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
await h.close();
|
|
}
|
|
const signedOut = createClientLifecycleHarness();
|
|
let calls = 0;
|
|
globalThis.fetch = (async () => { calls += 1; return new Response("{}"); }) as typeof fetch;
|
|
try {
|
|
await signedOut.render(<AccountDeletionGate signedIn={false} />);
|
|
await signedOut.idle();
|
|
assert.equal(calls, 0, "no request before sign-in");
|
|
assert.equal(signedOut.container.text, "");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
await signedOut.close();
|
|
}
|
|
});
|