account-deletion.integration.test.ts127 lines · main
1/**
2 * GDPR hard-purge (migration 0042) — control plane.
3 *
4 * Guards the Phase-2 fix: `hardDeleteExpiredAccounts` previously ALWAYS threw
5 * FK violation 23503 (organizations.created_by + audit_logs.actor_id were
6 * ON DELETE NO ACTION), so no expired account was ever erased. Migration 0042
7 * flips both FKs to ON DELETE SET NULL + makes created_by nullable, and the
8 * purge now hard-deletes the user's own soft-deleted orgs first.
9 *
10 * This test proves, against real Postgres:
11 * - a soft-deleted user past the 30-day grace IS purged (no 23503);
12 * - a user still within grace is NOT purged;
13 * - the user's own soft-deleted (personal/sole) org is hard-deleted;
14 * - a SHARED org the user created SURVIVES with created_by nulled out
15 * (the original cascade-incident protection);
16 * - an audit row authored by the user survives with actor_id nulled.
17 *
18 * Integration test (real control Postgres, no mock.module). Gated on
19 * BRIVEN_DATA_PLANE_URL — the repo's "integration mode is on" signal — NOT
20 * BRIVEN_DATABASE_URL, which test-preload.ts always sets to a dead URL. The
21 * test:integration run provides both real URLs together. NOTE: it calls the
22 * real purge with graceDays=30, which also removes any OTHER >30-day
23 * soft-deleted users present — fine on the throwaway dev/CI control DB.
24 */
25import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
26import { eq, inArray } from 'drizzle-orm';
27
28import { getDb } from '../db/client.js';
29import { auditLogs, organizations, users } from '../db/schema.js';
30import { hardDeleteExpiredAccounts } from './account-deletion.js';
31
32const HAS_DB = Boolean(process.env.BRIVEN_DATA_PLANE_URL);
33const S = Date.now().toString(36);
34const EXPIRED = `u_pexp_${S}`; // soft-deleted 40 days ago → purge
35const FRESH = `u_pfresh_${S}`; // soft-deleted 29 days ago → keep
36const SHAREOWNER = `u_pshare_${S}`; // expired; created a SURVIVING shared org
37const PERSONAL_ORG = `o_ppers_${S}`; // created by EXPIRED, soft-deleted → purge
38const SHARED_ORG = `o_pshare_${S}`; // created by SHAREOWNER, NOT soft-deleted → survive
39const AUDIT_ROW = `au_p_${S}`;
40
41const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000);
42const USER_IDS = [EXPIRED, FRESH, SHAREOWNER];
43const ORG_IDS = [PERSONAL_ORG, SHARED_ORG];
44
45describe.skipIf(!HAS_DB)('hardDeleteExpiredAccounts — GDPR purge (migration 0042)', () => {
46 beforeAll(async () => {
47 const db = getDb();
48 await db.insert(users).values([
49 { id: EXPIRED, email: `${EXPIRED}@x.test`, deletedAt: daysAgo(40) },
50 { id: FRESH, email: `${FRESH}@x.test`, deletedAt: daysAgo(29) },
51 { id: SHAREOWNER, email: `${SHAREOWNER}@x.test`, deletedAt: daysAgo(40) },
52 ]);
53 await db.insert(organizations).values([
54 // EXPIRED's own soft-deleted personal org → should be hard-deleted.
55 {
56 id: PERSONAL_ORG,
57 slug: `pers-${S}`,
58 name: 'personal',
59 personal: true,
60 createdBy: EXPIRED,
61 deletedAt: daysAgo(40),
62 },
63 // A shared org SHAREOWNER created that is still alive → must survive,
64 // created_by nulled (cannot block the purge).
65 {
66 id: SHARED_ORG,
67 slug: `shared-${S}`,
68 name: 'shared',
69 personal: false,
70 createdBy: SHAREOWNER,
71 },
72 ]);
73 await db
74 .insert(auditLogs)
75 .values({ id: AUDIT_ROW, actorId: SHAREOWNER, action: 'test.purge_fixture' });
76 });
77
78 afterAll(async () => {
79 const db = getDb();
80 await db.delete(auditLogs).where(eq(auditLogs.id, AUDIT_ROW));
81 await db.delete(organizations).where(inArray(organizations.id, ORG_IDS));
82 await db.delete(users).where(inArray(users.id, USER_IDS));
83 });
84
85 test('purge erases expired users, keeps in-grace users, and does not throw 23503', async () => {
86 const db = getDb();
87 await hardDeleteExpiredAccounts({ graceDays: 30 });
88
89 const remaining = await db
90 .select({ id: users.id })
91 .from(users)
92 .where(inArray(users.id, USER_IDS));
93 const ids = remaining.map((r) => r.id);
94 expect(ids).toContain(FRESH); // within grace → kept
95 expect(ids).not.toContain(EXPIRED); // past grace → erased
96 expect(ids).not.toContain(SHAREOWNER); // past grace → erased
97 });
98
99 test("the user's own soft-deleted org is hard-deleted", async () => {
100 const db = getDb();
101 const rows = await db
102 .select({ id: organizations.id })
103 .from(organizations)
104 .where(eq(organizations.id, PERSONAL_ORG));
105 expect(rows.length).toBe(0);
106 });
107
108 test('a SHARED org survives with created_by nulled (no cascade into shared data)', async () => {
109 const db = getDb();
110 const [org] = await db
111 .select({ id: organizations.id, createdBy: organizations.createdBy })
112 .from(organizations)
113 .where(eq(organizations.id, SHARED_ORG));
114 expect(org).toBeTruthy();
115 expect(org?.createdBy).toBeNull();
116 });
117
118 test('audit row survives with actor_id nulled (trail preserved)', async () => {
119 const db = getDb();
120 const [row] = await db
121 .select({ id: auditLogs.id, actorId: auditLogs.actorId })
122 .from(auditLogs)
123 .where(eq(auditLogs.id, AUDIT_ROW));
124 expect(row).toBeTruthy();
125 expect(row?.actorId).toBeNull();
126 });
127});