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 */
17import { randomBytes } from 'node:crypto';
18
19import { 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.
23process.env.BRIVEN_ENCRYPTION_KEY = randomBytes(32).toString('hex');
24
25/** In-memory tables, keyed by the drizzle table object. Reset per test. */
26const store = new Map<unknown, Array<Record<string, unknown>>>();
27function 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. */
37function 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
104mock.module('../db/client.js', () => ({ getDb: () => makeDb() }));
105mock.module('../db/data-plane.js', () => ({
106 runInProjectDatabase: async () => undefined,
107}));
108mock.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.
137mock.module('../services/auth-tenant-pool.js', () => ({
138 getAuthInstance: async () => ({ betterAuth: { handler: async () => new Response() } }),
139 invalidateAuthInstance: async () => undefined,
140 clearAuthInstancePool: async () => undefined,
141}));
142
143let svc: typeof import('./auth-sdk-keys.js');
144let schema: typeof import('../db/schema.js');
145
146beforeAll(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
161beforeEach(() => {
162 store.clear();
163});
164
165const PROJECT = 'p_test01';
166
167describe('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
183describe('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 */
247describe('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});