admin-suspend.integration.test.ts126 lines · main
1/**
2 * Account suspension lock-out (Phase 2) — control plane.
3 *
4 * Guards the Phase-2 fix in services/admin.ts `suspendUser`: before it, a
5 * suspended user's brk_* api keys kept full read+write (the api-key path never
6 * re-checks owner status), and live sessions stayed valid. `suspendUser` now
7 * runs in one transaction that: (1) sets users.suspended_at; (2) deletes EVERY
8 * session row for the user (immediate sign-out); (3) cascade-revokes api keys
9 * on every project in an org the user SOLELY owns — and ONLY those. A team org
10 * the user merely belongs to (not sole owner) must keep its keys live.
11 *
12 * This proves, against real Postgres:
13 * - the user's suspended_at is stamped;
14 * - all of the user's sessions are gone;
15 * - the key on a SOLE-owned (personal) project is revoked;
16 * - the key on a SHARED (multi-owner team) project SURVIVES — scoping is
17 * identical to what account deletion would erase, no collateral team damage.
18 *
19 * Integration test (real control Postgres, no mock.module). Gated on
20 * BRIVEN_DATA_PLANE_URL — the repo's "integration mode is on" signal — NOT
21 * BRIVEN_DATABASE_URL, which test-preload.ts always sets to a dead URL. The
22 * test:integration run provides both real URLs together.
23 */
24import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
25import { eq, inArray } from 'drizzle-orm';
26
27import { getDb } from '../db/client.js';
28import { apiKeys, orgMembers, organizations, projects, sessions, users } from '../db/schema.js';
29import { suspendUser } from './admin.js';
30
31const HAS_DB = Boolean(process.env.BRIVEN_DATA_PLANE_URL);
32const S = Date.now().toString(36);
33
34const SUSPENDEE = `u_susp_${S}`; // the user we suspend
35const OTHER = `u_other_${S}`; // owns the shared org alongside nobody else
36const PERSONAL_ORG = `o_pers_${S}`; // SUSPENDEE's personal org → sole-owned
37const SHARED_ORG = `o_team_${S}`; // OTHER owns it; SUSPENDEE is only a developer
38const SOLE_PROJECT = `p_sole_${S}`;
39const SHARED_PROJECT = `p_team_${S}`;
40const SOLE_KEY = `ak_sole_${S}`; // → revoked
41const SHARED_KEY = `ak_team_${S}`; // → survives
42const SESSION_A = `s_a_${S}`;
43const SESSION_B = `s_b_${S}`;
44
45const USER_IDS = [SUSPENDEE, OTHER];
46const ORG_IDS = [PERSONAL_ORG, SHARED_ORG];
47const hourFromNow = () => new Date(Date.now() + 60 * 60 * 1000);
48
49describe.skipIf(!HAS_DB)('suspendUser — lock-out + scoped key revocation (Phase 2)', () => {
50 beforeAll(async () => {
51 const db = getDb();
52 await db.insert(users).values([
53 { id: SUSPENDEE, email: `${SUSPENDEE}@x.test` },
54 { id: OTHER, email: `${OTHER}@x.test` },
55 ]);
56 await db.insert(organizations).values([
57 { id: PERSONAL_ORG, slug: `pers-${S}`, name: 'personal', personal: true, createdBy: SUSPENDEE },
58 { id: SHARED_ORG, slug: `team-${S}`, name: 'team', personal: false, createdBy: OTHER },
59 ]);
60 // suspendUser only inspects orgs the user is a MEMBER of, so seed the
61 // membership rows. SUSPENDEE owns the personal org; in the team org OTHER
62 // is the sole owner and SUSPENDEE is just a developer.
63 await db.insert(orgMembers).values([
64 { orgId: PERSONAL_ORG, userId: SUSPENDEE, role: 'owner' },
65 { orgId: SHARED_ORG, userId: OTHER, role: 'owner' },
66 { orgId: SHARED_ORG, userId: SUSPENDEE, role: 'developer' },
67 ]);
68 await db.insert(projects).values([
69 { id: SOLE_PROJECT, slug: `sole-${S}`, name: 'sole', orgId: PERSONAL_ORG },
70 { id: SHARED_PROJECT, slug: `shared-${S}`, name: 'shared', orgId: SHARED_ORG },
71 ]);
72 await db.insert(apiKeys).values([
73 { id: SOLE_KEY, projectId: SOLE_PROJECT, createdBy: SUSPENDEE, name: 'sole', hash: `h_sole_${S}`, suffix: 'aaaa' },
74 { id: SHARED_KEY, projectId: SHARED_PROJECT, createdBy: OTHER, name: 'team', hash: `h_team_${S}`, suffix: 'bbbb' },
75 ]);
76 await db.insert(sessions).values([
77 { id: SESSION_A, userId: SUSPENDEE, token: `t_a_${S}`, expiresAt: hourFromNow() },
78 { id: SESSION_B, userId: SUSPENDEE, token: `t_b_${S}`, expiresAt: hourFromNow() },
79 ]);
80 });
81
82 afterAll(async () => {
83 const db = getDb();
84 await db.delete(apiKeys).where(inArray(apiKeys.id, [SOLE_KEY, SHARED_KEY]));
85 await db.delete(sessions).where(inArray(sessions.id, [SESSION_A, SESSION_B]));
86 await db.delete(projects).where(inArray(projects.id, [SOLE_PROJECT, SHARED_PROJECT]));
87 await db.delete(orgMembers).where(inArray(orgMembers.userId, USER_IDS));
88 await db.delete(organizations).where(inArray(organizations.id, ORG_IDS));
89 await db.delete(users).where(inArray(users.id, USER_IDS));
90 });
91
92 test('suspendUser stamps suspended_at and kills every live session', async () => {
93 const db = getDb();
94 await suspendUser(SUSPENDEE);
95
96 const [row] = await db
97 .select({ suspendedAt: users.suspendedAt })
98 .from(users)
99 .where(eq(users.id, SUSPENDEE));
100 expect(row?.suspendedAt).toBeTruthy(); // flag set
101
102 const live = await db
103 .select({ id: sessions.id })
104 .from(sessions)
105 .where(eq(sessions.userId, SUSPENDEE));
106 expect(live.length).toBe(0); // signed out everywhere
107 });
108
109 test('a key on a SOLE-owned project is revoked', async () => {
110 const db = getDb();
111 const [key] = await db
112 .select({ revokedAt: apiKeys.revokedAt })
113 .from(apiKeys)
114 .where(eq(apiKeys.id, SOLE_KEY));
115 expect(key?.revokedAt).toBeTruthy(); // dead
116 });
117
118 test('a key on a SHARED (team) project survives — no collateral revocation', async () => {
119 const db = getDb();
120 const [key] = await db
121 .select({ revokedAt: apiKeys.revokedAt })
122 .from(apiKeys)
123 .where(eq(apiKeys.id, SHARED_KEY));
124 expect(key?.revokedAt).toBeNull(); // still live
125 });
126});