Files
Jyotisha/frontend/tests/database-account-deletion.test.ts
T
Jesse_ChenandClaude Opus 5.5 96adbce3a9 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
2026-09-30 09:42:11 +08:00

106 lines
6.0 KiB
TypeScript

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?.();
}
});