import { beforeEach, describe, expect, it } from 'vitest' import { API_KEY_TYPES } from '../../src/auth/constants.js' import { buildServer } from '../../src/server.js' import { createAdminKey, createClientKey, createProject, createRootKey, } from '../helpers/fixtures.js' import { getTestDb, truncateAll } from '../helpers/db.js' const describeIfDb = process.env.TEST_DATABASE_URL ? describe : describe.skip describeIfDb('auth', () => { const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined beforeEach(async () => { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL await truncateAll(db!) }) /** * Missing or invalid Authorization header */ describe('missing or invalid Authorization header', () => { it('returns 401 when Authorization header is absent', async () => { const app = await buildServer({ db }) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', }) expect(response.statusCode).toBe(401) expect(response.json()).toMatchObject({ error: 'Unauthorized', statusCode: 401 }) await app.close() }) it('returns 401 when Authorization header contains a valid-looking but non-existent key', async () => { const app = await buildServer({ db }) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', headers: { authorization: 'ff_0000000000000000000000000000000000000000' }, }) expect(response.statusCode).toBe(401) expect(response.json()).toMatchObject({ error: 'Unauthorized', statusCode: 401 }) await app.close() }) it('returns 401 when Authorization header is an empty string', async () => { const app = await buildServer({ db }) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', headers: { authorization: '' }, }) expect(response.statusCode).toBe(401) expect(response.json()).toMatchObject({ error: 'Unauthorized', statusCode: 401 }) await app.close() }) }) /** * Root key requirements */ describe('root key requirements', () => { it('root key can call POST /api/v1/admin/projects and receives 201', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: rootKey }, payload: { name: 'Root Project', slug: 'root-project' }, }) expect(response.statusCode).toBe(201) await app.close() }) it('project admin key is rejected by POST /api/v1/admin/projects with 403', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'existing-project') const adminKey = await createAdminKey(app, rootKey, project.id) const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: adminKey }, payload: { name: 'Sneaky Project', slug: 'sneaky-project' }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) it('client key is rejected by POST /api/v1/admin/projects with 403', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'client-test-project') const envsRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: rootKey }, }) const envs = envsRes.json>() const envId = envs[0].id const clientKey = await createClientKey(app, rootKey, project.id, envId) const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: clientKey }, payload: { name: 'Client Sneaky', slug: 'client-sneaky' }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) it('root key can call DELETE /api/v1/admin/projects/:projectId and receives 204', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'to-delete') const response = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(204) await app.close() }) it('project admin key is rejected by DELETE /api/v1/admin/projects/:projectId with 403', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'admin-cannot-delete') const adminKey = await createAdminKey(app, rootKey, project.id) const response = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: adminKey }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) }) /** * Admin key project scoping */ describe('admin key project scoping', () => { it('project admin key can access its own project via GET /api/v1/admin/projects/:projectId', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'my-project') const adminKey = await createAdminKey(app, rootKey, project.id) const response = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: adminKey }, }) expect(response.statusCode).toBe(200) await app.close() }) it('project admin key is rejected when accessing a different project route with 403', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const projectA = await createProject(app, rootKey, 'project-alpha') const projectB = await createProject(app, rootKey, 'project-beta') const adminKeyA = await createAdminKey(app, rootKey, projectA.id) const response = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectB.id}`, headers: { authorization: adminKeyA }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) it('root key can access any project via GET /api/v1/admin/projects/:projectId', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const projectA = await createProject(app, rootKey, 'project-one') const projectB = await createProject(app, rootKey, 'project-two') const responseA = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectA.id}`, headers: { authorization: rootKey }, }) const responseB = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectB.id}`, headers: { authorization: rootKey }, }) expect(responseA.statusCode).toBe(200) expect(responseB.statusCode).toBe(200) await app.close() }) }) /** * Client key restrictions */ describe('client key restrictions', () => { it('client key is rejected on admin routes with 403', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'client-restricted') const envsRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: rootKey }, }) const envs = envsRes.json>() const envId = envs[0].id const clientKey = await createClientKey(app, rootKey, project.id, envId) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', headers: { authorization: clientKey }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) it('client key can access GET /api/v1/client/features and receives 200', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'client-features') const envsRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: rootKey }, }) const envs = envsRes.json>() const envId = envs[0].id const clientKey = await createClientKey(app, rootKey, project.id, envId) const response = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: clientKey }, }) expect(response.statusCode).toBe(200) await app.close() }) }) /** * Key lifecycle */ describe('key lifecycle', () => { it('deleted key returns 401 on subsequent requests', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'lifecycle-project') const createRes = await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/keys`, headers: { authorization: rootKey }, payload: { type: API_KEY_TYPES.ADMIN, description: 'Temporary key' }, }) expect(createRes.statusCode).toBe(201) const { key, id: keyId } = createRes.json<{ key: string; id: string }>() const deleteRes = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}/keys/${keyId}`, headers: { authorization: rootKey }, }) expect(deleteRes.statusCode).toBe(204) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', headers: { authorization: key }, }) expect(response.statusCode).toBe(401) expect(response.json()).toMatchObject({ error: 'Unauthorized', statusCode: 401 }) await app.close() }) }) describe('rate limiting on the unauthenticated routes', () => { async function appWithLimit(max: number) { process.env.AUTH_RATE_LIMIT_MAX = String(max) try { return await buildServer({ db }) } finally { delete process.env.AUTH_RATE_LIMIT_MAX } } const login = (app: Awaited>) => app.inject({ method: 'POST', url: '/api/v1/admin/auth/login', payload: { email: 'nobody@flagraft.local', password: 'wrong-password' }, }) it('refuses further login attempts once the limit is hit', async () => { const app = await appWithLimit(2) expect((await login(app)).statusCode).toBe(401) expect((await login(app)).statusCode).toBe(401) const blocked = await login(app) expect(blocked.statusCode).toBe(429) expect(blocked.json()).toMatchObject({ error: 'TooManyRequests', statusCode: 429 }) await app.close() }) it('throttles invite acceptance, which also hashes a password', async () => { const app = await appWithLimit(2) const accept = () => app.inject({ method: 'POST', url: '/api/v1/public/invite/not-a-real-token/accept', payload: { password: 'a-long-enough-password' }, }) await accept() await accept() expect((await accept()).statusCode).toBe(429) await app.close() }) it('leaves logout alone', async () => { const app = await appWithLimit(2) const logout = () => app.inject({ method: 'POST', url: '/api/v1/admin/auth/logout' }) for (let i = 0; i < 5; i++) expect((await logout()).statusCode).toBe(200) await app.close() }) it('leaves the public workspace endpoint alone', async () => { const app = await appWithLimit(2) const workspace = () => app.inject({ method: 'GET', url: '/api/v1/public/workspace' }) for (let i = 0; i < 5; i++) expect((await workspace()).statusCode).toBe(200) await app.close() }) /** Regression: this was a 500 until the error handler kept a thrown 4xx. */ it('answers the client evaluation limit with a real 429', async () => { process.env.RATE_LIMIT_MAX = '1' const app = await buildServer({ db }) delete process.env.RATE_LIMIT_MAX const call = () => app.inject({ method: 'GET', url: '/api/v1/client/features' }) await call() const blocked = await call() expect(blocked.statusCode).toBe(429) expect(blocked.json()).toMatchObject({ error: 'TooManyRequests', statusCode: 429 }) await app.close() }) it('does not spend the client budget on login attempts', async () => { const app = await appWithLimit(2) await login(app) await login(app) expect((await login(app)).statusCode).toBe(429) const client = await app.inject({ method: 'GET', url: '/api/v1/client/features' }) expect(client.statusCode).not.toBe(429) await app.close() }) }) /** * Auth is required for /api/ and nothing else, so the bundled UI and Swagger * can serve themselves. This pins the rest of the surface: adding a route * outside /api/ makes it public, and that should be a deliberate act. */ describe('the unauthenticated route surface', () => { /** '*' is the CORS preflight route; '/' and '/*' appear when the UI is bundled. */ const PUBLIC_OUTSIDE_API = ['/docs', '/health', '/ready', '/', '/*', '*'] it('has no route outside /api/ beyond the known public ones', async () => { const app = await buildServer({ db, skipBootSeed: true }) await app.ready() const topLevel = app .printRoutes({ commonPrefix: false }) .split('\n') .map((line) => /^[├└]── (\S+)/.exec(line)?.[1]) .filter((p): p is string => Boolean(p)) const unexpected = topLevel.filter( (p) => !p.startsWith('/api/') && !PUBLIC_OUTSIDE_API.includes(p), ) expect(unexpected).toEqual([]) await app.close() }) it('still refuses an /api/ route without credentials', async () => { const app = await buildServer({ db, skipBootSeed: true }) const res = await app.inject({ method: 'GET', url: '/api/v1/admin/projects' }) expect(res.statusCode).toBe(401) await app.close() }) }) })