tests/integration/keys.test.ts
Rama main · réplica de lectura del repositorio original.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API_KEY_TYPES } from '../../src/auth/constants.js'
import { buildServer } from '../../src/server.js'
import { createAdminKey, createProject, createRootKey } from '../helpers/fixtures.js'
import { getTestDb, truncateAll } from '../helpers/db.js'
const describeIfDb = process.env.TEST_DATABASE_URL ? describe : describe.skip
describeIfDb('api keys', () => {
const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined
beforeEach(async () => {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL
await truncateAll(db!)
})
/**
* Helper function to retrieve the unique ID of an environment by its slug
*/
async function getEnvironmentId(
app: Awaited<ReturnType<typeof buildServer>>,
rootKey: string,
projectId: string,
slug: string,
): Promise<string> {
const res = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${projectId}/environments`,
headers: { authorization: rootKey },
})
const envs = res.json<Array<{ id: string; slug: string }>>()
return envs.find((e) => e.slug === slug)!.id
}
/**
* Tests for creating various types of API keys
*/
describe('POST /api/v1/admin/projects/:projectId/keys', () => {
it('creates an admin key successfully and returns 201 with correct shape', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: { type: API_KEY_TYPES.ADMIN, description: 'My admin key' },
})
expect(res.statusCode).toBe(201)
const body = res.json<{
id: string
prefix: string
type: string
environmentId: null
key: string
createdAt: string
}>()
expect(body.type).toBe(API_KEY_TYPES.ADMIN)
expect(body.environmentId).toBeNull()
expect(typeof body.id).toBe('string')
expect(typeof body.key).toBe('string')
expect(typeof body.prefix).toBe('string')
expect(typeof body.createdAt).toBe('string')
await app.close()
})
it('returned key starts with ff_', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: { type: API_KEY_TYPES.ADMIN, description: 'Test key' },
})
expect(res.statusCode).toBe(201)
const { key } = res.json<{ key: string }>()
expect(key.startsWith('ff_')).toBe(true)
await app.close()
})
it('returned prefix equals the first 12 characters of the key', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: { type: API_KEY_TYPES.ADMIN, description: 'Test key' },
})
expect(res.statusCode).toBe(201)
const { key, prefix } = res.json<{ key: string; prefix: string }>()
expect(prefix).toBe(key.slice(0, 12))
await app.close()
})
it('creates a client key successfully with environmentId and returns 201', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const productionId = await getEnvironmentId(app, rootKey, project.id, 'production')
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: {
type: API_KEY_TYPES.CLIENT,
environmentId: productionId,
description: 'Production client',
},
})
expect(res.statusCode).toBe(201)
const body = res.json<{ type: string; environmentId: string }>()
expect(body.type).toBe(API_KEY_TYPES.CLIENT)
expect(body.environmentId).toBe(productionId)
await app.close()
})
it('returns 400 when type is client but environmentId is not provided', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: { type: API_KEY_TYPES.CLIENT, description: 'Test key' },
})
expect(res.statusCode).toBe(400)
expect(res.json()).toMatchObject({
error: 'BadRequest',
message: 'Client keys require environmentId',
statusCode: 400,
})
await app.close()
})
it('returns 400 when type is admin but environmentId is provided', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const productionId = await getEnvironmentId(app, rootKey, project.id, 'production')
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: {
type: API_KEY_TYPES.ADMIN,
environmentId: productionId,
description: 'Test key',
},
})
expect(res.statusCode).toBe(400)
expect(res.json()).toMatchObject({
error: 'BadRequest',
message: 'Admin keys cannot be scoped to an environment',
statusCode: 400,
})
await app.close()
})
it('returns 404 when environmentId does not exist', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: {
type: API_KEY_TYPES.CLIENT,
environmentId: '00000000-0000-0000-0000-000000000000',
description: 'Test key',
},
})
expect(res.statusCode).toBe(404)
expect(res.json()).toMatchObject({
error: 'NotFound',
statusCode: 404,
})
await app.close()
})
it('returns 400 when type is an invalid value', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
payload: { type: 'superadmin', description: 'Test key' },
})
expect(res.statusCode).toBe(400)
expect(res.json()).toMatchObject({
error: 'ValidationError',
statusCode: 400,
})
await app.close()
})
})
/**
* Tests for listing API keys within a project
*/
describe('GET /api/v1/admin/projects/:projectId/keys', () => {
it('returns an empty array when no project-scoped keys have been created', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ data: [], total: 0, limit: 25, offset: 0 })
await app.close()
})
it('pages through results and reports the unpaged total', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const adminKey = await createAdminKey(app, rootKey, project.id)
for (let i = 0; i < 4; i++) {
await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: adminKey },
payload: { type: API_KEY_TYPES.ADMIN, description: `key-${i}` },
})
}
const page = async (offset: number) =>
(
await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys?limit=2&offset=${offset}`,
headers: { authorization: rootKey },
})
).json<{ data: Array<{ id: string }>; total: number }>()
/** 4 created here plus the admin key used to create them. */
const first = await page(0)
expect(first.total).toBe(5)
expect(first.data).toHaveLength(2)
const ids = [...first.data, ...(await page(2)).data, ...(await page(4)).data].map((k) => k.id)
expect(new Set(ids).size).toBe(5)
expect((await page(99)).data).toEqual([])
await app.close()
})
it('filters by search, scope and environment', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const adminKey = await createAdminKey(app, rootKey, project.id)
const productionId = await getEnvironmentId(app, rootKey, project.id, 'production')
const developmentId = await getEnvironmentId(app, rootKey, project.id, 'development')
await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: adminKey },
payload: {
type: API_KEY_TYPES.CLIENT,
environmentId: productionId,
description: 'Checkout web',
},
})
await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: adminKey },
payload: {
type: API_KEY_TYPES.CLIENT,
environmentId: developmentId,
description: 'Local dev 50% rollout',
},
})
const list = async (query: string) =>
(
await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys?${query}`,
headers: { authorization: rootKey },
})
).json<{ data: Array<{ type: string; description: string | null }>; total: number }>()
expect((await list('type=client')).total).toBe(2)
expect((await list(`environmentId=${productionId}`)).total).toBe(1)
expect((await list('search=checkout')).total).toBe(1)
/** A bare % must not act as a wildcard matching every row. */
const percent = await list('search=%25')
expect(percent.total).toBe(1)
expect(percent.data[0].description).toBe('Local dev 50% rollout')
/** The prefix is searchable too, so an admin can paste part of a key. */
const anyKey = (await list('limit=1')).data[0] as unknown as { prefix?: string }
expect((await list('type=client&search=checkout')).total).toBe(1)
expect(anyKey).toBeDefined()
await app.close()
})
it('stamps lastUsedAt when the key authenticates a request', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const adminKey = await createAdminKey(app, rootKey, project.id)
const read = async () =>
(
await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
).json<{ data: Array<{ lastUsedAt: string | null }> }>().data[0]
expect((await read()).lastUsedAt).toBeNull()
/** Any call carrying the key counts as use. */
await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/flags`,
headers: { authorization: adminKey },
})
/** The stamp is fire-and-forget, so give it a moment to land. */
await vi.waitFor(async () => expect((await read()).lastUsedAt).not.toBeNull())
/**
* Every request used to write this column, and since all traffic for one
* key hits one row those writes queued and capped server throughput. The
* stamp is now recorded at most once a minute per key, so a second call
* straight after must leave it alone.
*/
const stamped = (await read()).lastUsedAt
await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/flags`,
headers: { authorization: adminKey },
})
await new Promise((resolve) => setTimeout(resolve, 100))
expect((await read()).lastUsedAt).toBe(stamped)
await app.close()
})
it('rejects a page size above the maximum', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys?limit=5000`,
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(400)
await app.close()
})
it('returns created admin and client keys for the project', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const productionId = await getEnvironmentId(app, rootKey, project.id, 'production')
const adminKey = await createAdminKey(app, rootKey, project.id)
await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: adminKey },
payload: {
type: API_KEY_TYPES.CLIENT,
environmentId: productionId,
description: 'Production client',
},
})
const res = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(200)
const keys = res.json<{ data: Array<{ type: string; environmentId: string | null }> }>().data
expect(keys.length).toBe(2)
expect(keys.some((k) => k.type === API_KEY_TYPES.ADMIN)).toBe(true)
expect(keys.some((k) => k.type === API_KEY_TYPES.CLIENT)).toBe(true)
await app.close()
})
it('does not include the plaintext key field in list response -- only prefix', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
await createAdminKey(app, rootKey, project.id)
const res = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(200)
const keys = res.json<{ data: Array<Record<string, unknown>> }>().data
expect(keys.length).toBeGreaterThan(0)
for (const k of keys) {
expect('key' in k).toBe(false)
expect(typeof k['prefix']).toBe('string')
}
await app.close()
})
it('returns keys ordered by createdAt ascending', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const productionId = await getEnvironmentId(app, rootKey, project.id, 'production')
/**
* Create an admin key then a client key -- order should be preserved
*/
const adminKeyStr = await createAdminKey(app, rootKey, project.id)
await app.inject({
method: 'POST',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: adminKeyStr },
payload: { type: API_KEY_TYPES.CLIENT, environmentId: productionId },
})
const res = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(200)
/** Newest first is the default now; the explicit ascending sort still works. */
const keys = res.json<{ data: Array<{ createdAt: string }> }>().data
const timestamps = keys.map((k) => new Date(k.createdAt).getTime())
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a))
const ascRes = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys?sort=created&dir=asc`,
headers: { authorization: rootKey },
})
const ascending = ascRes
.json<{ data: Array<{ createdAt: string }> }>()
.data.map((k) => new Date(k.createdAt).getTime())
expect(ascending).toEqual([...ascending].sort((a, b) => a - b))
await app.close()
})
})
/**
* Tests for revoking and deleting API keys
*/
describe('DELETE /api/v1/admin/projects/:projectId/keys/:keyId', () => {
it('deletes a key and returns 204', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
await createAdminKey(app, rootKey, project.id)
const listRes = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
const keys = listRes.json<{ data: Array<{ id: string }> }>().data
const target = keys[0]
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/projects/${project.id}/keys/${target.id}`,
headers: { authorization: rootKey },
})
expect(deleteRes.statusCode).toBe(204)
const afterList = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
const remaining = afterList.json<{ data: Array<{ id: string }> }>().data
expect(remaining.find((k) => k.id === target.id)).toBeUndefined()
await app.close()
})
it('prevents the deleted key from being used for auth -- subsequent request returns 401', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const adminKey = await createAdminKey(app, rootKey, project.id)
const listRes = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
const keys = listRes.json<{ data: Array<{ id: string }> }>().data
const target = keys[0]
await app.inject({
method: 'DELETE',
url: `/api/v1/admin/projects/${project.id}/keys/${target.id}`,
headers: { authorization: rootKey },
})
/**
* The deleted admin key should now be rejected
*/
const afterDelete = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: adminKey },
})
expect(afterDelete.statusCode).toBe(401)
await app.close()
})
it('rejects a revoked key that had already authenticated a request', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const adminKey = await createAdminKey(app, rootKey, project.id)
/** Using the key first puts it in the auth cache. */
const before = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/flags`,
headers: { authorization: adminKey },
})
expect(before.statusCode).toBe(200)
const keys = (
await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/keys`,
headers: { authorization: rootKey },
})
).json<{ data: Array<{ id: string; type: string }> }>().data
const target = keys.find((k) => k.type === 'admin')!
await app.inject({
method: 'DELETE',
url: `/api/v1/admin/projects/${project.id}/keys/${target.id}`,
headers: { authorization: rootKey },
})
/** Revocation has to evict the cached key, not wait out its TTL. */
const after = await app.inject({
method: 'GET',
url: `/api/v1/admin/projects/${project.id}/flags`,
headers: { authorization: adminKey },
})
expect(after.statusCode).toBe(401)
await app.close()
})
it('returns 404 for a non-existent keyId', async () => {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey)
const res = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/projects/${project.id}/keys/00000000-0000-0000-0000-000000000000`,
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(404)
expect(res.json()).toMatchObject({
error: 'NotFound',
statusCode: 404,
})
await app.close()
})
})
})