From 2513163bf4926da14b6a85189d10dfffb782b70a Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Tue, 20 Oct 2026 00:10:15 +0530 Subject: [PATCH] feat(admin): list API keys with pagination and revoked filter (#505) --- src/__tests__/adminListKeys.test.ts | 167 ++++++++++++++++++++++++++++ src/api/admin.ts | 73 +++++++++++- 2 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/adminListKeys.test.ts diff --git a/src/__tests__/adminListKeys.test.ts b/src/__tests__/adminListKeys.test.ts new file mode 100644 index 00000000..3a5e6543 --- /dev/null +++ b/src/__tests__/adminListKeys.test.ts @@ -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') + }) +}) diff --git a/src/api/admin.ts b/src/api/admin.ts index 6cd01f68..1e3425c0 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -19,9 +19,16 @@ interface CreateKeyBody { overagePolicy?: 'block' | 'charge_402' | 'allow_overage' } +interface ListKeysQuery { + limit?: string | number + offset?: string | number + includeRevoked?: string | boolean +} + /** - * Registers admin-only endpoints for minting and revoking API keys. + * 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). * - `DELETE /admin/keys/:id` — revoke an existing key. * @@ -29,6 +36,70 @@ interface CreateKeyBody { * them; they enforce their own `ADMIN_TOKEN` check instead. */ 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 } },