db.test.ts196 lines · main
1// Route-level tests for the customer per-project database lifecycle
2// routes (/v1/projects/:id/db/health|snapshots|restart|recover|reprovision).
3//
4// Env vars must be set BEFORE any module that reads them is imported, so
5// real modules are pulled in via top-level `await import` after the
6// process.env mutation — mirroring routes/projects.test.ts.
7//
8// IMPORTANT: bun's mock.module() is process-GLOBAL and is NOT reverted
9// between test files (see services/auth-sdk-keys.test.ts). Every stub here
10// therefore (a) spreads the REAL module so no export goes missing, and
11// (b) DELEGATES to the real implementation unless an explicit x-test-*
12// header / test-only project id is present — so later test files that hit
13// these modules see unchanged behavior.
14
15const ORIGINAL_SECRET = process.env.BRIVEN_BETTER_AUTH_SECRET;
16const ORIGINAL_DB_URL = process.env.BRIVEN_DATABASE_URL;
17process.env.BRIVEN_BETTER_AUTH_SECRET = ORIGINAL_SECRET ?? 'a'.repeat(32);
18process.env.BRIVEN_DATABASE_URL = ORIGINAL_DB_URL ?? 'postgres://test:test@127.0.0.1:5/test';
19
20import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
21import { Hono } from 'hono';
22
23import type { ProjectAppEnv } from '../types/app-env.js';
24
25// Module namespace objects are LIVE — after mock.module() the properties
26// on these namespaces point at the mocks. Snapshot the original exports
27// (spread) and the original functions NOW, before any mock is installed,
28// so the delegating stubs below call the real implementations and don't
29// recurse into themselves.
30const realProjectAuth = { ...(await import('../middleware/project-auth.js')) };
31const realStepUp = { ...(await import('../middleware/step-up.js')) };
32const realProjects = { ...(await import('../services/projects.js')) };
33
34// Test-authenticated requests carry `x-test-role`; everything else falls
35// through to the REAL requireProjectAuth (session/brk_/cli-jwt branches).
36// MUST forward `paramName` — platform routes use requireProjectAuth('ref');
37// defaulting to 'id' reintroduces the "missing project id" 403 on /platform/*.
38mock.module('../middleware/project-auth.js', () => ({
39 ...realProjectAuth,
40 requireProjectAuth:
41 (paramName: string = 'id') =>
42 async (
43 c: {
44 req: { header: (name: string) => string | undefined };
45 set: (key: string, value: unknown) => void;
46 },
47 next: () => Promise<void>,
48 ) => {
49 const role = c.req.header('x-test-role');
50 if (!role) {
51 return realProjectAuth.requireProjectAuth(paramName)(c as never, next);
52 }
53 c.set('user', { id: 'u_dbtest', email: 'dbtest@example.com', name: 'db test' });
54 c.set('apiKeyId', null);
55 c.set('projectRole', role);
56 await next();
57 },
58}));
59
60// `x-test-mfa: fresh` passes, `x-test-mfa: stale` returns the real gate's
61// 403 shape; no header → REAL requireRecentMfa (db-backed) for later files.
62mock.module('../middleware/step-up.js', () => ({
63 ...realStepUp,
64 requireRecentMfa: (maxAgeMin = 10) => {
65 const real = realStepUp.requireRecentMfa(maxAgeMin);
66 return async (
67 c: {
68 req: { header: (name: string) => string | undefined };
69 json: (body: unknown, status?: number) => Response;
70 },
71 next: () => Promise<void>,
72 ) => {
73 const attest = c.req.header('x-test-mfa');
74 if (attest === 'fresh') {
75 await next();
76 return;
77 }
78 if (attest === 'stale') {
79 return c.json({ code: 'step_up_required', maxAgeMin }, 403);
80 }
81 return real(c as never, next);
82 };
83 },
84}));
85
86// Known project row for the reprovision confirm-name check; every other
87// project id resolves through the real service.
88mock.module('../services/projects.js', () => ({
89 ...realProjects,
90 getProjectInfo: async (projectId: string) => {
91 if (projectId !== 'p_dbtest') return realProjects.getProjectInfo(projectId);
92 return { id: projectId, name: 'jungle raid', slug: 'jungle-raid' } as Awaited<
93 ReturnType<typeof realProjects.getProjectInfo>
94 >;
95 },
96}));
97
98let app: Hono<ProjectAppEnv>;
99
100beforeAll(async () => {
101 const { dbRouter } = await import('./db.js');
102 const { errorHandler } = await import('../middleware/error.js');
103 app = new Hono<ProjectAppEnv>();
104 app.onError(errorHandler);
105 app.route('/', dbRouter);
106});
107
108async function call(
109 method: 'GET' | 'POST',
110 path: string,
111 opts: { role?: string; mfa?: 'fresh' | 'stale'; body?: unknown } = {},
112): Promise<Response> {
113 const headers: Record<string, string> = { 'x-forwarded-for': '203.0.113.9' };
114 if (opts.role) headers['x-test-role'] = opts.role;
115 if (opts.mfa) headers['x-test-mfa'] = opts.mfa;
116 if (opts.body !== undefined) headers['content-type'] = 'application/json';
117 return await app.request(path, {
118 method,
119 headers,
120 body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
121 });
122}
123
124const BASE = '/v1/projects/p_dbtest/db';
125
126describe('customer database lifecycle routes — gating', () => {
127 it('rejects unauthenticated callers with 401', async () => {
128 const res = await call('GET', `${BASE}/health`);
129 expect(res.status).toBe(401);
130 });
131
132 it('rejects sub-admin roles with 403 (router-level admin gate)', async () => {
133 const res = await call('GET', `${BASE}/health`, { role: 'developer' });
134 expect(res.status).toBe(403);
135 });
136
137 it('gates mutations behind recent step-up auth (403 step_up_required)', async () => {
138 const res = await call('POST', `${BASE}/restart`, { role: 'admin', mfa: 'stale', body: {} });
139 expect(res.status).toBe(403);
140 const body = (await res.json()) as { code?: string };
141 expect(body.code).toBe('step_up_required');
142 });
143
144 it('reprovision refuses role admin — owner only', async () => {
145 const res = await call('POST', `${BASE}/reprovision`, {
146 role: 'admin',
147 mfa: 'fresh',
148 body: { confirmName: 'jungle raid' },
149 });
150 expect(res.status).toBe(403);
151 const body = (await res.json()) as { message?: string };
152 expect(body.message ?? '').toContain('owner');
153 });
154});
155
156describe('customer database lifecycle routes — typed confirmations', () => {
157 it('recover without the confirm field → 400 validation_failed', async () => {
158 const res = await call('POST', `${BASE}/recover`, {
159 role: 'admin',
160 mfa: 'fresh',
161 body: { snapshotId: 's000000000000000000000001' },
162 });
163 expect(res.status).toBe(400);
164 const body = (await res.json()) as { code?: string };
165 expect(body.code).toBe('validation_failed');
166 });
167
168 it('recover with the wrong confirm word → 400 confirm_mismatch', async () => {
169 const res = await call('POST', `${BASE}/recover`, {
170 role: 'admin',
171 mfa: 'fresh',
172 body: { snapshotId: 's000000000000000000000001', confirm: 'recover' },
173 });
174 expect(res.status).toBe(400);
175 const body = (await res.json()) as { code?: string };
176 expect(body.code).toBe('confirm_mismatch');
177 });
178
179 it('reprovision with a confirmName matching neither slug nor name → 400 confirm_mismatch', async () => {
180 const res = await call('POST', `${BASE}/reprovision`, {
181 role: 'owner',
182 mfa: 'fresh',
183 body: { confirmName: 'not-the-project' },
184 });
185 expect(res.status).toBe(400);
186 const body = (await res.json()) as { code?: string };
187 expect(body.code).toBe('confirm_mismatch');
188 });
189});
190
191afterAll(() => {
192 if (ORIGINAL_SECRET === undefined) delete process.env.BRIVEN_BETTER_AUTH_SECRET;
193 else process.env.BRIVEN_BETTER_AUTH_SECRET = ORIGINAL_SECRET;
194 if (ORIGINAL_DB_URL === undefined) delete process.env.BRIVEN_DATABASE_URL;
195 else process.env.BRIVEN_DATABASE_URL = ORIGINAL_DB_URL;
196});