Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions src/__tests__/adminListKeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import Fastify from 'fastify'

vi.mock('../db', () => ({
prisma: {
apiKey: {
findMany: vi.fn(),
count: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}))

import { prisma } from '../db'
import { registerAdminRoutes } from '../api/admin'

const mockFindMany = prisma.apiKey.findMany as any
const mockCount = prisma.apiKey.count as any

const ORIGINAL_ADMIN_TOKEN = process.env.ADMIN_TOKEN

beforeEach(() => {
vi.clearAllMocks()
process.env.ADMIN_TOKEN = 'secret-admin-token-123'
})

afterEach(() => {
if (ORIGINAL_ADMIN_TOKEN === undefined) delete process.env.ADMIN_TOKEN
else process.env.ADMIN_TOKEN = ORIGINAL_ADMIN_TOKEN
})

async function buildApp() {
const app = Fastify()
await app.register(registerAdminRoutes)
return app
}

describe('Issue #505: GET /admin/keys', () => {
it('returns 401 when admin token is missing or wrong', async () => {
const app = await buildApp()
const resNoAuth = await app.inject({
method: 'GET',
url: '/admin/keys',
})
expect(resNoAuth.statusCode).toBe(401)
expect(resNoAuth.json()).toMatchObject({ error: 'Unauthorized', code: 'admin_unauthorized' })

const resWrongAuth = await app.inject({
method: 'GET',
url: '/admin/keys',
headers: { 'x-admin-token': 'wrong-token' },
})
expect(resWrongAuth.statusCode).toBe(401)
})

it('lists active keys excluding revoked by default and never exposes hash or secret', async () => {
const mockRecords = [
{
id: 'key-1',
label: 'Production Key',
ratePerMin: 120,
ratePerDay: 20000,
monthlyQuotaCents: 50000,
dailyQuotaCents: 2000,
overagePolicy: 'block',
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
expiresAt: null,
},
{
id: 'key-2',
label: 'Staging Key',
ratePerMin: 60,
ratePerDay: 5000,
monthlyQuotaCents: 10000,
dailyQuotaCents: 500,
overagePolicy: 'charge_402',
createdAt: new Date('2026-01-02T00:00:00Z'),
revokedAt: null,
expiresAt: new Date('2026-12-31T00:00:00Z'),
},
]

mockCount.mockResolvedValue(2)
mockFindMany.mockResolvedValue(mockRecords)

const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/admin/keys',
headers: { 'x-admin-token': 'secret-admin-token-123' },
})

expect(res.statusCode).toBe(200)

// Verify Prisma queried for non-revoked keys by default
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { revokedAt: null },
})
)

const payload = res.json()
expect(payload.keys).toHaveLength(2)
expect(payload.total).toBe(2)
expect(payload.hasMore).toBe(false)
expect(payload.keys[0].id).toBe('key-1')
expect(payload.keys[0].isRevoked).toBe(false)

// CRITICAL ACCEPTANCE CRITERIA: A test asserts the hash is absent from the response body
const responseText = res.payload
expect(responseText).not.toContain('hash')
for (const key of payload.keys) {
expect(key.hash).toBeUndefined()
expect(key.key).toBeUndefined()
}
})

it('retrieves revoked keys when includeRevoked=true is passed and supports pagination', async () => {
const mockRecords = [
{
id: 'key-revoked-1',
label: 'Old Key',
ratePerMin: 60,
ratePerDay: 5000,
monthlyQuotaCents: 10000,
dailyQuotaCents: 500,
overagePolicy: 'block',
createdAt: new Date('2025-12-01T00:00:00Z'),
revokedAt: new Date('2026-01-10T00:00:00Z'),
expiresAt: null,
},
]

mockCount.mockResolvedValue(10)
mockFindMany.mockResolvedValue(mockRecords)

const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/admin/keys?includeRevoked=true&limit=1&offset=5',
headers: { 'x-admin-token': 'secret-admin-token-123' },
})

expect(res.statusCode).toBe(200)

// Verify where clause included revoked keys and pagination parameters
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {},
skip: 5,
take: 1,
})
)

const payload = res.json()
expect(payload.total).toBe(10)
expect(payload.limit).toBe(1)
expect(payload.offset).toBe(5)
expect(payload.hasMore).toBe(true)
expect(payload.keys[0].isRevoked).toBe(true)
expect(payload.keys[0].revokedAt).toBeDefined()
expect(res.payload).not.toContain('hash')
})
})
105 changes: 72 additions & 33 deletions src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@ interface RotateKeyBody {
overlapHours?: number
}

interface ListKeysQuery {
limit?: string | number
offset?: string | number
includeRevoked?: string | boolean
}

/**
* Registers admin-only endpoints for minting, listing, and revoking API keys.
*
* - `GET /admin/keys` — list keys with pagination and revoked filter.
* - `POST /admin/keys` — mint a new key (returns the plaintext key ONCE).
* - `GET /admin/keys` — list all existing keys with metadata and scopes.
* - `DELETE /admin/keys/:id` — revoke an existing key.
Expand All @@ -36,7 +43,7 @@ interface RotateKeyBody {
* them; they enforce their own `ADMIN_TOKEN` check instead.
*/
export async function registerAdminRoutes(app: FastifyInstance) {
app.get<{ Querystring: { limit?: string } }>(
app.get<{ Querystring: { limit?: string } }>(
'/admin/webhook-delivery-failures',
{ config: { public: true } },
async (req, reply) => {
Expand All @@ -55,6 +62,70 @@ export async function registerAdminRoutes(app: FastifyInstance) {
},
)

app.get<{ Querystring: ListKeysQuery }>(
'/admin/keys',
{ config: { public: true } },
async (req: FastifyRequest<{ Querystring: ListKeysQuery }>, reply: FastifyReply) => {
if (!isAdminAuthorized(req)) {
admin_auth_failures_total.inc()
return reply.status(401).send({
error: 'Unauthorized',
code: 'admin_unauthorized',
message: 'Provide a valid X-Admin-Token header.',
})
}

const limit = Math.min(Math.max(1, parseInt(String(req.query?.limit ?? 50), 10) || 50), 100)
const offset = Math.max(0, parseInt(String(req.query?.offset ?? 0), 10) || 0)
const includeRevoked = req.query?.includeRevoked === 'true' || req.query?.includeRevoked === true

const where = includeRevoked ? {} : { revokedAt: null }

const [total, records] = await Promise.all([
prisma.apiKey.count({ where }),
prisma.apiKey.findMany({
where,
select: {
id: true,
label: true,
ratePerMin: true,
ratePerDay: true,
monthlyQuotaCents: true,
dailyQuotaCents: true,
overagePolicy: true,
createdAt: true,
revokedAt: true,
expiresAt: true,
},
orderBy: { createdAt: 'desc' },
skip: offset,
take: limit,
}),
])

const keys = records.map((r) => ({
id: r.id,
label: r.label,
ratePerMin: r.ratePerMin,
ratePerDay: r.ratePerDay,
monthlyQuotaCents: r.monthlyQuotaCents,
dailyQuotaCents: r.dailyQuotaCents,
overagePolicy: r.overagePolicy,
createdAt: r.createdAt,
revokedAt: r.revokedAt,
expiresAt: r.expiresAt,
isRevoked: r.revokedAt !== null,
}))

return reply.send({
keys,
total,
limit,
offset,
hasMore: offset + keys.length < total,
})
},
)
app.post<{ Body: CreateKeyBody }>(
'/admin/keys',
{ config: { public: true } },
Expand Down Expand Up @@ -120,39 +191,7 @@ export async function registerAdminRoutes(app: FastifyInstance) {
},
)

app.get(
'/admin/keys',
{ config: { public: true } },
async (req: FastifyRequest, reply: FastifyReply) => {
if (!isAdminAuthorized(req)) {
admin_auth_failures_total.inc()
return reply.status(401).send({
error: 'Unauthorized',
code: 'admin_unauthorized',
message: 'Provide a valid X-Admin-Token header.',
})
}

const keys = await prisma.apiKey.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
label: true,
scopes: true,
ratePerMin: true,
ratePerDay: true,
revokedAt: true,
expiresAt: true,
createdAt: true,
monthlyQuotaCents: true,
dailyQuotaCents: true,
overagePolicy: true,
},
})

return reply.status(200).send({ keys })
},
)

app.delete<{ Params: { id: string } }>(
'/admin/keys/:id',
Expand Down
Loading