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 | */ |
| 24 | import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; |
| 25 | import { eq, inArray } from 'drizzle-orm'; |
| 26 | |
| 27 | import { getDb } from '../db/client.js'; |
| 28 | import { apiKeys, orgMembers, organizations, projects, sessions, users } from '../db/schema.js'; |
| 29 | import { suspendUser } from './admin.js'; |
| 30 | |
| 31 | const HAS_DB = Boolean(process.env.BRIVEN_DATA_PLANE_URL); |
| 32 | const S = Date.now().toString(36); |
| 33 | |
| 34 | const SUSPENDEE = `u_susp_${S}`; // the user we suspend |
| 35 | const OTHER = `u_other_${S}`; // owns the shared org alongside nobody else |
| 36 | const PERSONAL_ORG = `o_pers_${S}`; // SUSPENDEE's personal org → sole-owned |
| 37 | const SHARED_ORG = `o_team_${S}`; // OTHER owns it; SUSPENDEE is only a developer |
| 38 | const SOLE_PROJECT = `p_sole_${S}`; |
| 39 | const SHARED_PROJECT = `p_team_${S}`; |
| 40 | const SOLE_KEY = `ak_sole_${S}`; // → revoked |
| 41 | const SHARED_KEY = `ak_team_${S}`; // → survives |
| 42 | const SESSION_A = `s_a_${S}`; |
| 43 | const SESSION_B = `s_b_${S}`; |
| 44 | |
| 45 | const USER_IDS = [SUSPENDEE, OTHER]; |
| 46 | const ORG_IDS = [PERSONAL_ORG, SHARED_ORG]; |
| 47 | const hourFromNow = () => new Date(Date.now() + 60 * 60 * 1000); |
| 48 | |
| 49 | describe.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 | }); |