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
106 lines
6.0 KiB
TypeScript
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?.();
|
|
}
|
|
});
|