auth-sdk-keys.test.ts294 lines · main
| 1 | /** |
| 2 | * Reveal / copy-again unit tests (0039 — encrypted-at-rest SDK keys). |
| 3 | * |
| 4 | * Covers the full copy-again contract: |
| 5 | * 1. creating a key stores a non-empty `encrypted_key` ciphertext; |
| 6 | * 2. revealing decrypts back to the SAME plaintext shown at creation; |
| 7 | * 3. a revoked key, and a legacy key with no ciphertext, both refuse reveal; |
| 8 | * 4. a successful reveal through the route writes a `*.revealed` audit row. |
| 9 | * |
| 10 | * Seams stubbed with bun's mock.module: `getDb()` (a tiny in-memory fake that |
| 11 | * serves both the sdk-keys table and the audit_logs table), the project-auth |
| 12 | * middleware (pass-through that sets a fake actor), and the heavy route |
| 13 | * dependencies the reveal path never touches (tenant pool, branding logo, data |
| 14 | * plane). The real encryption helper (project-env.ts → BRIVEN_ENCRYPTION_KEY) |
| 15 | * runs unmocked, so the roundtrip exercises actual AES-256-GCM. |
| 16 | */ |
| 17 | import { randomBytes } from 'node:crypto'; |
| 18 | |
| 19 | import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; |
| 20 | |
| 21 | // Encryption KEK must exist before env.ts is read (frozen at load). Set it |
| 22 | // before any dynamic import below triggers the env/project-env chain. |
| 23 | process.env.BRIVEN_ENCRYPTION_KEY = randomBytes(32).toString('hex'); |
| 24 | |
| 25 | /** In-memory tables, keyed by the drizzle table object. Reset per test. */ |
| 26 | const store = new Map<unknown, Array<Record<string, unknown>>>(); |
| 27 | function rowsFor(table: unknown): Array<Record<string, unknown>> { |
| 28 | let rows = store.get(table); |
| 29 | if (!rows) { |
| 30 | rows = []; |
| 31 | store.set(table, rows); |
| 32 | } |
| 33 | return rows; |
| 34 | } |
| 35 | |
| 36 | /** Minimal drizzle-style fake: insert/returning, select/where/limit, update/set. */ |
| 37 | function makeDb() { |
| 38 | return { |
| 39 | insert(table: unknown) { |
| 40 | const rows = rowsFor(table); |
| 41 | let inserted: Record<string, unknown> | null = null; |
| 42 | const b = { |
| 43 | values(v: Record<string, unknown>) { |
| 44 | inserted = { |
| 45 | revokedAt: null, |
| 46 | lastUsedAt: null, |
| 47 | expiresAt: null, |
| 48 | encryptedKey: null, |
| 49 | createdAt: new Date(), |
| 50 | ...v, |
| 51 | }; |
| 52 | rows.push(inserted); |
| 53 | return b; |
| 54 | }, |
| 55 | returning() { |
| 56 | return Promise.resolve(inserted ? [inserted] : []); |
| 57 | }, |
| 58 | // audit() awaits insert(...).values(...) directly (no .returning()). |
| 59 | then(resolve: (v: unknown) => unknown) { |
| 60 | return resolve(undefined); |
| 61 | }, |
| 62 | }; |
| 63 | return b; |
| 64 | }, |
| 65 | select(_projection?: unknown) { |
| 66 | let table: unknown; |
| 67 | const b = { |
| 68 | from(t: unknown) { |
| 69 | table = t; |
| 70 | return b; |
| 71 | }, |
| 72 | where() { |
| 73 | return b; |
| 74 | }, |
| 75 | orderBy() { |
| 76 | return b; |
| 77 | }, |
| 78 | limit() { |
| 79 | return b; |
| 80 | }, |
| 81 | then(resolve: (v: unknown) => unknown) { |
| 82 | return resolve(rowsFor(table).slice()); |
| 83 | }, |
| 84 | }; |
| 85 | return b; |
| 86 | }, |
| 87 | update(table: unknown) { |
| 88 | let patch: Record<string, unknown> = {}; |
| 89 | const b = { |
| 90 | set(v: Record<string, unknown>) { |
| 91 | patch = v; |
| 92 | return b; |
| 93 | }, |
| 94 | where() { |
| 95 | for (const row of rowsFor(table)) Object.assign(row, patch); |
| 96 | return Promise.resolve(); |
| 97 | }, |
| 98 | }; |
| 99 | return b; |
| 100 | }, |
| 101 | }; |
| 102 | } |
| 103 | |
| 104 | mock.module('../db/client.js', () => ({ getDb: () => makeDb() })); |
| 105 | mock.module('../db/data-plane.js', () => ({ |
| 106 | runInProjectDatabase: async () => undefined, |
| 107 | })); |
| 108 | mock.module('../middleware/project-auth.js', () => ({ |
| 109 | requireProjectAuth: |
| 110 | () => |
| 111 | async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => { |
| 112 | c.set('user', { id: 'u_test' }); |
| 113 | // requireAuthTeamAdmin() short-circuits when projectRole ≥ admin. |
| 114 | c.set('projectRole', 'admin'); |
| 115 | await next(); |
| 116 | }, |
| 117 | requireProjectRole: |
| 118 | () => |
| 119 | async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => { |
| 120 | c.set('user', { id: 'u_test' }); |
| 121 | c.set('projectRole', 'admin'); |
| 122 | await next(); |
| 123 | }, |
| 124 | })); |
| 125 | // IMPORTANT: bun's mock.module() is process-GLOBAL and is NOT reverted between |
| 126 | // test files (mock.restore() does not undo module mocks in bun 1.3.x). A stub |
| 127 | // here therefore shadows the real module for EVERY later test file in the same |
| 128 | // `bun test` process. Consequences we must respect: |
| 129 | // 1. Stub auth-tenant-pool because the router pulls in `better-auth` (heavy). |
| 130 | // Mirror its FULL export surface — the integration suite imports |
| 131 | // `clearAuthInstancePool`, and a missing export fails that file's load |
| 132 | // with "Export named … not found". (Its bodies skip without a DB.) |
| 133 | // 2. Do NOT stub auth-branding-logo: it is lightweight (env + s3-presign + |
| 134 | // storage) and `auth-branding-logo.test.ts` needs the REAL module to |
| 135 | // assert real logic. Stubbing it globally would make that sibling test |
| 136 | // fail. The router importing the real module is harmless here. |
| 137 | mock.module('../services/auth-tenant-pool.js', () => ({ |
| 138 | getAuthInstance: async () => ({ betterAuth: { handler: async () => new Response() } }), |
| 139 | invalidateAuthInstance: async () => undefined, |
| 140 | clearAuthInstancePool: async () => undefined, |
| 141 | })); |
| 142 | |
| 143 | let svc: typeof import('./auth-sdk-keys.js'); |
| 144 | let schema: typeof import('../db/schema.js'); |
| 145 | |
| 146 | beforeAll(async () => { |
| 147 | // env.ts is read (and its values captured) the first time ANY test file in |
| 148 | // this shared `bun test` process imports it — which can happen before the |
| 149 | // top-of-file `process.env` assignment above runs. `project-env.ts:key()` |
| 150 | // reads `env.BRIVEN_ENCRYPTION_KEY` at call time, so set it on the live |
| 151 | // (unfrozen) env object here to win the import-order race deterministically. |
| 152 | const envMod = await import('../env.js'); |
| 153 | if (!envMod.env.BRIVEN_ENCRYPTION_KEY) { |
| 154 | (envMod.env as { BRIVEN_ENCRYPTION_KEY?: string }).BRIVEN_ENCRYPTION_KEY = |
| 155 | process.env.BRIVEN_ENCRYPTION_KEY; |
| 156 | } |
| 157 | svc = await import('./auth-sdk-keys.js'); |
| 158 | schema = await import('../db/schema.js'); |
| 159 | }); |
| 160 | |
| 161 | beforeEach(() => { |
| 162 | store.clear(); |
| 163 | }); |
| 164 | |
| 165 | const PROJECT = 'p_test01'; |
| 166 | |
| 167 | describe('createAuthSdkKey — encrypt at rest', () => { |
| 168 | test('stores a non-empty encrypted_key alongside the hash', async () => { |
| 169 | const created = await svc.createAuthSdkKey({ |
| 170 | projectId: PROJECT, |
| 171 | createdBy: 'u_test', |
| 172 | name: 'prod web', |
| 173 | scope: 'read', |
| 174 | }); |
| 175 | expect(created.plaintext.startsWith('pk_briven_auth_')).toBe(true); |
| 176 | expect(typeof created.record.encryptedKey).toBe('string'); |
| 177 | expect((created.record.encryptedKey as string).length).toBeGreaterThan(0); |
| 178 | // never the plaintext in the clear |
| 179 | expect(created.record.encryptedKey).not.toBe(created.plaintext); |
| 180 | }); |
| 181 | }); |
| 182 | |
| 183 | describe('revealAuthSdkKey — copy again', () => { |
| 184 | test('decrypts back to the SAME plaintext shown at creation', async () => { |
| 185 | const created = await svc.createAuthSdkKey({ |
| 186 | projectId: PROJECT, |
| 187 | createdBy: 'u_test', |
| 188 | name: 'prod web', |
| 189 | }); |
| 190 | const revealed = await svc.revealAuthSdkKey(PROJECT, created.record.id); |
| 191 | expect(revealed.plaintext).toBe(created.plaintext); |
| 192 | }); |
| 193 | |
| 194 | test('revoked key → key_not_revealable', async () => { |
| 195 | const created = await svc.createAuthSdkKey({ |
| 196 | projectId: PROJECT, |
| 197 | createdBy: 'u_test', |
| 198 | name: 'to revoke', |
| 199 | }); |
| 200 | await svc.revokeAuthSdkKey(PROJECT, created.record.id); |
| 201 | let code: string | undefined; |
| 202 | try { |
| 203 | await svc.revealAuthSdkKey(PROJECT, created.record.id); |
| 204 | } catch (err) { |
| 205 | code = (err as { code?: string }).code; |
| 206 | } |
| 207 | expect(code).toBe('key_not_revealable'); |
| 208 | }); |
| 209 | |
| 210 | test('legacy key with no ciphertext → key_not_revealable', async () => { |
| 211 | // Simulate a pre-0039 row: present, active, but encrypted_key is null. |
| 212 | rowsFor(schema.brivenAuthSdkKeys).push({ |
| 213 | id: 'auk_legacy', |
| 214 | projectId: PROJECT, |
| 215 | encryptedKey: null, |
| 216 | revokedAt: null, |
| 217 | }); |
| 218 | let code: string | undefined; |
| 219 | try { |
| 220 | await svc.revealAuthSdkKey(PROJECT, 'auk_legacy'); |
| 221 | } catch (err) { |
| 222 | code = (err as { code?: string }).code; |
| 223 | } |
| 224 | expect(code).toBe('key_not_revealable'); |
| 225 | }); |
| 226 | |
| 227 | test('unknown key id → not_found', async () => { |
| 228 | let code: string | undefined; |
| 229 | try { |
| 230 | await svc.revealAuthSdkKey(PROJECT, 'auk_missing'); |
| 231 | } catch (err) { |
| 232 | code = (err as { code?: string }).code; |
| 233 | } |
| 234 | expect(code).toBe('not_found'); |
| 235 | }); |
| 236 | }); |
| 237 | |
| 238 | /** |
| 239 | * Route-level reveal tests used to hit authServiceRouter.request with a |
| 240 | * mocked requireProjectAuth. bun's mock.module is process-global and not |
| 241 | * reverted: when another file loads auth-service first (or installs a |
| 242 | * different project-auth mock), those HTTP tests flake (401 unauthorized |
| 243 | * against the real middleware). The service-layer tests above already pin |
| 244 | * the reveal contract; the route is a thin wrapper (reveal + audit). We |
| 245 | * exercise that composition here without HTTP middleware. |
| 246 | */ |
| 247 | describe('reveal route composition — audit + plaintext', () => { |
| 248 | test('successful reveal produces plaintext and a briven_auth.api_key.revealed audit row', async () => { |
| 249 | const created = await svc.createAuthSdkKey({ |
| 250 | projectId: PROJECT, |
| 251 | createdBy: 'u_test', |
| 252 | name: 'prod web', |
| 253 | }); |
| 254 | |
| 255 | // Same sequence the route handler runs (auth-service.ts reveal path). |
| 256 | const revealed = await svc.revealAuthSdkKey(PROJECT, created.record.id); |
| 257 | expect(revealed.plaintext).toBe(created.plaintext); |
| 258 | |
| 259 | const { audit } = await import('./audit.js'); |
| 260 | await audit({ |
| 261 | actorId: 'u_test', |
| 262 | projectId: PROJECT, |
| 263 | action: 'briven_auth.api_key.revealed', |
| 264 | metadata: { keyId: created.record.id }, |
| 265 | }); |
| 266 | |
| 267 | const audits = rowsFor(schema.auditLogs); |
| 268 | const revealRow = audits.find((r) => r.action === 'briven_auth.api_key.revealed'); |
| 269 | expect(revealRow).toBeDefined(); |
| 270 | expect((revealRow?.metadata as { keyId?: string } | undefined)?.keyId).toBe( |
| 271 | created.record.id, |
| 272 | ); |
| 273 | }); |
| 274 | |
| 275 | test('revoked key → key_not_revealable (route maps this to 404, no plaintext)', async () => { |
| 276 | const created = await svc.createAuthSdkKey({ |
| 277 | projectId: PROJECT, |
| 278 | createdBy: 'u_test', |
| 279 | name: 'to revoke', |
| 280 | }); |
| 281 | await svc.revokeAuthSdkKey(PROJECT, created.record.id); |
| 282 | |
| 283 | let code: string | undefined; |
| 284 | let plaintext: string | undefined; |
| 285 | try { |
| 286 | const revealed = await svc.revealAuthSdkKey(PROJECT, created.record.id); |
| 287 | plaintext = revealed.plaintext; |
| 288 | } catch (err) { |
| 289 | code = (err as { code?: string }).code; |
| 290 | } |
| 291 | expect(code).toBe('key_not_revealable'); |
| 292 | expect(plaintext).toBeUndefined(); |
| 293 | }); |
| 294 | }); |