tests/integration/auth.test.ts
Rama main · réplica de lectura del repositorio original.
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<Array<{ id: string; slug: string }>>()
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<Array<{ id: string; slug: string }>>()
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<Array<{ id: string; slug: string }>>()
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<ReturnType<typeof buildServer>>) =>
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()
})
})
})