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 | |
| 16 | const ORIGINAL_SECRET = process.env.BRIVEN_BETTER_AUTH_SECRET; |
| 17 | const ORIGINAL_DB_URL = process.env.BRIVEN_DATABASE_URL; |
| 18 | process.env.BRIVEN_BETTER_AUTH_SECRET = ORIGINAL_SECRET ?? 'a'.repeat(32); |
| 19 | process.env.BRIVEN_DATABASE_URL = ORIGINAL_DB_URL ?? 'postgres://test:test@127.0.0.1:5/test'; |
| 20 | |
| 21 | import { afterAll, describe, expect, it } from 'bun:test'; |
| 22 | import type { AppEnv } from '../types/app-env.js'; |
| 23 | |
| 24 | describe('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 | |
| 114 | afterAll(() => { |
| 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 | }); |