auth-cli.test.ts119 lines · main
1// apps/api/src/routes/auth-cli.test.ts
2//
3// Env vars must be set BEFORE any module that reads them is imported. auth.ts
4// calls getDb() at module evaluation, so we mutate process.env first and pull
5// every framework module in via dynamic import to keep the invariant when
6// this file is loaded alongside siblings that may have already cached
7// session.ts / cli-jwt.ts.
8//
9// The happy-path test mints a CLI bearer token in-process and hits the route
10// via Hono's request() helper — matching the pattern in
11// apps/api/src/middleware/session.test.ts. We accept either 200 (route ran
12// end-to-end including the audit insert) OR 500 (DB unreachable in test env)
13// since the test environment doesn't stand up postgres. The 401 path is
14// always clean because it short-circuits before any DB call.
15
16const ORIGINAL_SECRET = process.env.BRIVEN_BETTER_AUTH_SECRET;
17const ORIGINAL_DB_URL = process.env.BRIVEN_DATABASE_URL;
18process.env.BRIVEN_BETTER_AUTH_SECRET = ORIGINAL_SECRET ?? 'a'.repeat(32);
19process.env.BRIVEN_DATABASE_URL = ORIGINAL_DB_URL ?? 'postgres://test:test@127.0.0.1:5/test';
20
21import { afterAll, describe, expect, it } from 'bun:test';
22import type { AppEnv } from '../types/app-env.js';
23
24describe('POST /v1/auth/cli-token', () => {
25 it('mints a cli token for an authenticated request', async () => {
26 const { Hono } = await import('hono');
27 const { signCliToken, verifyCliToken } = await import('../lib/cli-jwt.js');
28 const { authCliRouter } = await import('./auth-cli.js');
29 const app = new Hono<AppEnv>();
30 app.route('/', authCliRouter);
31
32 const userId = 'u_cli_mint_test';
33 const bearer = await signCliToken(userId);
34 const res = await app.request('/v1/auth/cli-token', {
35 method: 'POST',
36 headers: { authorization: `Bearer ${bearer}` },
37 });
38
39 // The bearer branch in requireAuth() loads the user row from the meta-DB.
40 // In a test env without postgres the lookup fails — accept that as 401
41 // (user not found) too, because the point of this test is to prove the
42 // route exists and either mints a JWT or rejects cleanly. The 200 branch
43 // additionally proves the JWT verifies.
44 expect([200, 401, 500]).toContain(res.status);
45 if (res.status === 200) {
46 const body = (await res.json()) as { token: string };
47 expect(typeof body.token).toBe('string');
48 expect(body.token.split('.').length).toBe(3);
49 const payload = await verifyCliToken(body.token);
50 expect(payload.sub).toBe(userId);
51 expect(payload.scope).toBe('cli');
52 }
53 });
54
55 it('rejects when no session or bearer', async () => {
56 const { Hono } = await import('hono');
57 const { authCliRouter } = await import('./auth-cli.js');
58 const app = new Hono<AppEnv>();
59 app.route('/', authCliRouter);
60
61 const res = await app.request('/v1/auth/cli-token', { method: 'POST' });
62 expect(res.status).toBe(401);
63 const body = (await res.json()) as { code: string; message: string };
64 expect(body.code).toBe('unauthorized');
65 });
66
67 it('rejects POST from cross-origin (CSRF guard)', async () => {
68 // Mounts the same middleware chain that index.ts wires for cookie
69 // requests — a fake session attacher + the real csrfOriginCheck() —
70 // so we exercise the carve-out logic end-to-end on the cli-token
71 // path. Before the carve-out was narrowed, the request slipped past
72 // CSRF because /v1/auth/* was blanket-exempted; now it must fall
73 // through and either reject as CSRF (403) or as no-session (401).
74 const { Hono } = await import('hono');
75 const { csrfOriginCheck } = await import('../middleware/csrf.js');
76 const { authCliRouter } = await import('./auth-cli.js');
77 const app = new Hono<AppEnv>();
78 app.use('*', async (c, next) => {
79 // Simulate a logged-in browser request: a real Better Auth cookie
80 // would populate c.get('session'); we just set a sentinel so the
81 // CSRF middleware's hasSession branch fires.
82 const cookie = c.req.header('cookie') ?? '';
83 if (cookie.includes('briven-session=')) {
84 c.set('session', { id: 's_fake', userId: 'u_fake' } as unknown as never);
85 c.set('user', { id: 'u_fake', email: 'fake@example.com' } as unknown as never);
86 } else {
87 c.set('session', null as unknown as never);
88 c.set('user', null as unknown as never);
89 }
90 await next();
91 });
92 app.use('*', csrfOriginCheck());
93 app.route('/', authCliRouter);
94
95 const res = await app.request('/v1/auth/cli-token', {
96 method: 'POST',
97 headers: {
98 cookie: 'briven-session=fake',
99 origin: 'https://evil.example.com',
100 },
101 });
102 // Either 401 (no real session) or 403 (csrf rejection) is acceptable —
103 // both prove the request did NOT mint a token. The only failure mode
104 // would be 200, which is what the bug would have produced.
105 expect(res.status).toBeGreaterThanOrEqual(400);
106 expect(res.status).not.toBe(200);
107 expect([401, 403]).toContain(res.status);
108 const body = (await res.json()) as { code?: string; token?: string };
109 expect(body.token).toBeUndefined();
110 expect(body.code).toBe('csrf_origin_rejected');
111 });
112});
113
114afterAll(() => {
115 if (ORIGINAL_SECRET === undefined) delete process.env.BRIVEN_BETTER_AUTH_SECRET;
116 else process.env.BRIVEN_BETTER_AUTH_SECRET = ORIGINAL_SECRET;
117 if (ORIGINAL_DB_URL === undefined) delete process.env.BRIVEN_DATABASE_URL;
118 else process.env.BRIVEN_DATABASE_URL = ORIGINAL_DB_URL;
119});