feat(account): self-service deletion with a 7-day cooling-off period

Request signs out everywhere and freezes paid routes (423 + DB triggers);
signing in within 7 days shows the pending gate with 撤销注销. A periodic
idempotent worker purges personal content afterwards and keeps finance
rows against a tombstoned identity. Read-only admin list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-30 09:42:11 +08:00
co-authored by Claude Opus 5.5
parent 3c8123d912
commit 96adbce3a9
30 changed files with 1480 additions and 2 deletions
+173
View File
@@ -0,0 +1,173 @@
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]);
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();
}
});
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
// Self-service account deletion (2026-09-30): request signs out and freezes,
// cancel restores, purge deletes personal content and keeps de-identified
// finance rows. Needs Docker (test:db).
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const userId = "91000000-0000-4000-8000-000000000001";
const otherId = "91000000-0000-4000-8000-000000000002";
function dockerAvailable(): boolean {
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", stdio: "ignore" }).status === 0;
}
test("account deletion: request, frozen charges, cancel, purge with kept finance rows", { skip: dockerAvailable() ? false : "docker unavailable on this host" }, () => {
const fixture = startPostgresFixture();
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") },
});
assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`);
assert.match(migration.stdout, /applied 20260930020000_account_deletion_requests\.sql/);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users (id, name, email, email_verified, email_verified_at) values
('${userId}', 'Deletion User', 'deletion-user@example.com', true, now()),
('${otherId}', 'Other User', 'deletion-other@example.com', true, now());
insert into identity.accounts (id, account_id, provider_id, user_id, password)
values ('92000000-0000-4000-8000-000000000001', 'deletion-user@example.com', 'credential', '${userId}', 'password-hash');
insert into identity.sessions (id, token, user_id, expires_at)
values ('92000000-0000-4000-8000-000000000002', 'deletion-session-token', '${userId}', now() + interval '1 day');
`);
fixture.psql(`
update public.profiles set credits = 12, name = 'Deletion User', birth_date = '1990-01-02' where id = '${userId}';
insert into public.chat_sessions (user_id, title, theme, messages) values
('${userId}', 'Mine', 'general', '[]'::jsonb), ('${otherId}', 'Theirs', 'general', '[]'::jsonb);
insert into public.chart_profiles (user_id, role, profile) values ('${userId}', 'other', '{}'::jsonb);
insert into public.synastry_reports (user_id, partner_name, report) values ('${userId}', 'Partner', '{}'::jsonb);
insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id)
values ('${userId}', 'redeem', 12, 12, 'deletion-kept-credit');
`);
// Request: pending, signed out everywhere.
const requested = JSON.parse(fixture.psql(`select public.request_account_deletion('${userId}')::text;`).trim().split("\n").pop()!);
assert.equal(requested.status, "pending");
assert.equal(fixture.psql(`select count(*) from identity.sessions where user_id = '${userId}';`).trim(), "0");
// Frozen: a debit is refused, a credit (refund) is not.
assert.throws(() => fixture.psql(`
insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id)
values ('${userId}', 'consume', -1, 11, 'deletion-refused-debit');
`), /account_deletion_pending/);
// Cancel within the window restores; request again for the purge.
assert.match(fixture.psql(`select public.cancel_account_deletion('${userId}');`), /\bt\b/);
fixture.psql(`select public.request_account_deletion('${userId}');`);
const requestId = fixture.psql(`select id from public.account_deletion_requests where user_id = '${userId}' and status = 'pending';`).trim();
// Not due yet: skipped, nothing touched.
assert.match(fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`), /not_due/);
fixture.psql(`update public.account_deletion_requests set requested_at = now() - interval '8 days', scheduled_for = now() - interval '1 day' where id = '${requestId}';`);
const purged = fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`);
assert.match(purged, /"status": ?"completed"/);
const state = JSON.parse(fixture.psql(`
select jsonb_build_object(
'chatMine', (select count(*) from public.chat_sessions where user_id = '${userId}'),
'chatOther', (select count(*) from public.chat_sessions where user_id = '${otherId}'),
'chartProfiles', (select count(*) from public.chart_profiles where user_id = '${userId}'),
'synastry', (select count(*) from public.synastry_reports where user_id = '${userId}'),
'profiles', (select count(*) from public.profiles where id = '${userId}'),
'keptCredit', (select count(*) from public.credit_transactions where user_id = '${userId}'),
'identityEmail', (select email from identity.users where id = '${userId}'),
'identityName', (select name from identity.users where id = '${userId}'),
'authEmail', (select email from auth.users where id = '${userId}'),
'accounts', (select count(*) from identity.accounts where user_id = '${userId}'),
'status', (select status from public.account_deletion_requests where id = '${requestId}')
)::text;
`).trim());
assert.deepEqual(state, {
chatMine: 0,
chatOther: 1,
chartProfiles: 0,
synastry: 0,
profiles: 0,
keptCredit: 1,
identityEmail: `deleted+${userId}@deleted.invalid`,
identityName: "已注销用户",
authEmail: `deleted+${userId}@deleted.invalid`,
accounts: 0,
status: "completed",
});
// Idempotent: a second purge of the same request is a no-op.
assert.match(fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`), /skipped/);
} finally {
fixture.stop?.();
}
});
+4 -1
View File
@@ -45,5 +45,8 @@ test("the general dialog renders the shared theme preference panel", () => {
assert.match(overlay, /dialog: "profile" \| "chart-library" \| "billing" \| "general"/);
assert.match(overlay, /renderGeneral: \(\) => ReactNode/);
assert.match(overlay, /return model\.renderGeneral\(\)/);
assert.match(page, /renderGeneral\(\) \{\s*return <ThemePreferencePanel \/>;/);
// 原值: /renderGeneral\(\) \{\s*return <ThemePreferencePanel \/>;/
// 新值: 主题面板仍是第一项,其后挂注销账号区
// 原因: 2026-09-30 自助注销入口放在通用设置底部,头像菜单不加入口
assert.match(page, /renderGeneral\(\) \{\s*return <><ThemePreferencePanel \/><AccountDeletionSection \/><\/>;/);
});