import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; vi.mock('@/lib/admin/auth', () => ({ isAdminEnabled: vi.fn(), isAdminAuthenticated: vi.fn(), COOKIE_NAME: 'folio_admin_session', })); import { isAdminEnabled, isAdminAuthenticated } from '@/lib/admin/auth'; const mockEnabled = isAdminEnabled as unknown as ReturnType; const mockAuthed = isAdminAuthenticated as unknown as ReturnType; /** * Every guarded admin route handler. Adding a new /api/admin route means * adding a row here — a route that forgets its guard then fails this suite * instead of shipping. /api/admin/auth is deliberately absent: it is the login * endpoint and must stay reachable without a session. */ const ROUTES: { name: string; path: string; load: () => Promise>; method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH'; args: () => unknown[]; }[] = [ { name: 'GET /api/admin/gallery', path: 'gallery', load: () => import('../gallery/route'), method: 'GET', args: () => [], }, { name: 'PUT /api/admin/gallery', path: 'gallery', load: () => import('../gallery/route'), method: 'PUT', args: () => [new Request('http://localhost/api/admin/gallery', { method: 'PUT', body: '{}' })], }, { name: 'GET /api/admin/settings', path: 'settings', load: () => import('../settings/route'), method: 'GET', args: () => [], }, { name: 'PUT /api/admin/settings', path: 'settings', load: () => import('../settings/route'), method: 'PUT', args: () => [new Request('http://localhost/api/admin/settings', { method: 'PUT', body: '{}' })], }, { name: 'GET /api/admin/albums', path: 'albums', load: () => import('../albums/route'), method: 'GET', args: () => [], }, { name: 'POST /api/admin/reload', path: 'reload', load: () => import('../reload/route'), method: 'POST', args: () => [], }, { name: 'GET /api/admin/status', path: 'status', load: () => import('../status/route'), method: 'GET', args: () => [], }, { // Reports album titles, where passwords live and the proxy topology. name: 'GET /api/admin/doctor', path: 'doctor', load: () => import('../doctor/route'), method: 'GET', args: () => [new NextRequest('http://localhost/api/admin/doctor')], }, { // Lists album titles and raw asset UUIDs. name: 'GET /api/admin/alt-text', path: 'alt-text', load: () => import('../alt-text/route'), method: 'GET', args: () => [], }, { name: 'GET /api/admin/assets', path: 'assets', load: () => import('../assets/route'), method: 'GET', args: () => [new Request('http://localhost/api/admin/assets')], }, { name: 'GET /api/admin/thumbnail/[id]', path: 'thumbnail/[id]', load: () => import('../thumbnail/[id]/route'), method: 'GET', args: () => [ new Request('http://localhost/api/admin/thumbnail/abc'), { params: Promise.resolve({ id: 'abc' }) }, ], }, { name: 'GET /api/admin/albums/[albumId]/assets', path: 'albums/[albumId]/assets', load: () => import('../albums/[albumId]/assets/route'), method: 'GET', args: () => [ new Request('http://localhost/api/admin/albums/a1/assets'), { params: Promise.resolve({ albumId: 'a1' }) }, ], }, { name: 'GET /api/admin/analytics', path: 'analytics', load: () => import('../analytics/route'), method: 'GET', args: () => [], }, { // Names and email addresses of everyone who used the contact form. name: 'GET /api/admin/messages', path: 'messages', load: () => import('../messages/route'), method: 'GET', args: () => [], }, { name: 'PATCH /api/admin/messages', path: 'messages', load: () => import('../messages/route'), method: 'PATCH', args: () => [ new NextRequest('http://localhost/api/admin/messages', { method: 'PATCH', body: '{}' }), ], }, { name: 'DELETE /api/admin/messages', path: 'messages', load: () => import('../messages/route'), method: 'DELETE', args: () => [new NextRequest('http://localhost/api/admin/messages?id=x', { method: 'DELETE' })], }, { name: 'GET /api/admin/privacy', path: 'privacy', load: () => import('../privacy/route'), method: 'GET', args: () => [], }, { name: 'PUT /api/admin/privacy', path: 'privacy', load: () => import('../privacy/route'), method: 'PUT', args: () => [new Request('http://localhost/api/admin/privacy', { method: 'PUT', body: '{}' })], }, { name: 'GET /api/admin/backups', path: 'backups', load: () => import('../backups/route'), method: 'GET', args: () => [], }, { name: 'POST /api/admin/backups', path: 'backups', load: () => import('../backups/route'), method: 'POST', args: () => [new Request('http://localhost/api/admin/backups', { method: 'POST', body: '{}' })], }, { name: 'PUT /api/admin/favicon', path: 'favicon', load: () => import('../favicon/route'), method: 'PUT', args: () => [new Request('http://localhost/api/admin/favicon', { method: 'PUT' })], }, { name: 'DELETE /api/admin/favicon', path: 'favicon', load: () => import('../favicon/route'), method: 'DELETE', args: () => [], }, { name: 'GET /api/admin/about', path: 'about', load: () => import('../about/route'), method: 'GET', args: () => [], }, { name: 'PUT /api/admin/about', path: 'about', load: () => import('../about/route'), method: 'PUT', args: () => [new Request('http://localhost/api/admin/about', { method: 'PUT', body: '{}' })], }, { name: 'GET /api/admin/journal', path: 'journal', load: () => import('../journal/route'), method: 'GET', args: () => [], }, { name: 'POST /api/admin/journal', path: 'journal', load: () => import('../journal/route'), method: 'POST', args: () => [new Request('http://localhost/api/admin/journal', { method: 'POST', body: '{}' })], }, { name: 'GET /api/admin/journal/[slug]', path: 'journal/[slug]', load: () => import('../journal/[slug]/route'), method: 'GET', args: () => [ new Request('http://localhost/api/admin/journal/test-slug'), { params: Promise.resolve({ slug: 'test-slug' }) }, ], }, { name: 'PUT /api/admin/journal/[slug]', path: 'journal/[slug]', load: () => import('../journal/[slug]/route'), method: 'PUT', args: () => [ new Request('http://localhost/api/admin/journal/test-slug', { method: 'PUT', body: '{}' }), { params: Promise.resolve({ slug: 'test-slug' }) }, ], }, { name: 'DELETE /api/admin/journal/[slug]', path: 'journal/[slug]', load: () => import('../journal/[slug]/route'), method: 'DELETE', args: () => [ new Request('http://localhost/api/admin/journal/test-slug', { method: 'DELETE' }), { params: Promise.resolve({ slug: 'test-slug' }) }, ], }, { name: 'GET /api/admin/pages', path: 'pages', load: () => import('../pages/route'), method: 'GET', args: () => [], }, { name: 'POST /api/admin/pages', path: 'pages', load: () => import('../pages/route'), method: 'POST', args: () => [new Request('http://localhost/api/admin/pages', { method: 'POST', body: '{}' })], }, { name: 'GET /api/admin/pages/[slug]', path: 'pages/[slug]', load: () => import('../pages/[slug]/route'), method: 'GET', args: () => [ new Request('http://localhost/api/admin/pages/test-slug'), { params: Promise.resolve({ slug: 'test-slug' }) }, ], }, { name: 'PUT /api/admin/pages/[slug]', path: 'pages/[slug]', load: () => import('../pages/[slug]/route'), method: 'PUT', args: () => [ new Request('http://localhost/api/admin/pages/test-slug', { method: 'PUT', body: '{}' }), { params: Promise.resolve({ slug: 'test-slug' }) }, ], }, { name: 'DELETE /api/admin/pages/[slug]', path: 'pages/[slug]', load: () => import('../pages/[slug]/route'), method: 'DELETE', args: () => [ new Request('http://localhost/api/admin/pages/test-slug', { method: 'DELETE' }), { params: Promise.resolve({ slug: 'test-slug' }) }, ], }, { name: 'GET /api/admin/proofing', path: 'proofing', load: () => import('../proofing/route'), method: 'GET', args: () => [new Request('http://localhost/api/admin/proofing')], }, { name: 'POST /api/admin/proofing', path: 'proofing', load: () => import('../proofing/route'), method: 'POST', args: () => [ new Request('http://localhost/api/admin/proofing', { method: 'POST', body: '{}' }), ], }, { name: 'GET /api/admin/proofing/[id]', path: 'proofing/[id]', load: () => import('../proofing/[id]/route'), method: 'GET', args: () => [ new Request('http://localhost/api/admin/proofing/abc'), { params: Promise.resolve({ id: 'abc' }) }, ], }, { name: 'PATCH /api/admin/proofing/[id]', path: 'proofing/[id]', load: () => import('../proofing/[id]/route'), method: 'PATCH', args: () => [ new Request('http://localhost/api/admin/proofing/abc', { method: 'PATCH', body: '{}' }), { params: Promise.resolve({ id: 'abc' }) }, ], }, { name: 'DELETE /api/admin/proofing/[id]', path: 'proofing/[id]', load: () => import('../proofing/[id]/route'), method: 'DELETE', args: () => [ new Request('http://localhost/api/admin/proofing/abc', { method: 'DELETE' }), { params: Promise.resolve({ id: 'abc' }) }, ], }, { name: 'GET /api/admin/proofing/[id]/export', path: 'proofing/[id]/export', load: () => import('../proofing/[id]/export/route'), method: 'GET', args: () => [ new Request('http://localhost/api/admin/proofing/abc/export'), { params: Promise.resolve({ id: 'abc' }) }, ], }, ]; describe('admin route guards', () => { beforeEach(() => { vi.clearAllMocks(); }); for (const route of ROUTES) { it(`${route.name} returns 403 when the admin panel is disabled`, async () => { mockEnabled.mockReturnValue(false); mockAuthed.mockResolvedValue(false); const mod = await route.load(); const handler = mod[route.method] as (...a: unknown[]) => Promise; const res = await handler(...route.args()); expect(res.status).toBe(403); }); it(`${route.name} returns 401 without a valid session`, async () => { mockEnabled.mockReturnValue(true); mockAuthed.mockResolvedValue(false); const mod = await route.load(); const handler = mod[route.method] as (...a: unknown[]) => Promise; const res = await handler(...route.args()); expect(res.status).toBe(401); }); } it('has a row for every route module under app/api/admin', async () => { const fs = await import('fs'); const path = await import('path'); const adminDir = path.join(process.cwd(), 'app/api/admin'); const found: string[] = []; const walk = (dir: string) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === '__tests__') continue; walk(full); } else if (entry.name === 'route.ts') { found.push(path.relative(adminDir, path.dirname(full)).replace(/\\/g, '/')); } } }; walk(adminDir); const covered = new Set(ROUTES.map((r) => r.path)); const uncovered = found.filter((p) => p !== 'auth' && !covered.has(p)); expect(uncovered, `Admin routes with no guard test: ${uncovered.join(', ')}`).toEqual([]); }); }); /** * The table above proves each route refuses an unauthenticated caller. This * proves they all refuse it the *same way* — by going through withAdmin() * rather than by carrying their own copy of the check, which is how the two * refusals drifted apart in the first place. */ describe('admin routes are wrapped in withAdmin', () => { const read = async () => { const fs = await import('fs'); const path = await import('path'); const adminDir = path.join(process.cwd(), 'app/api/admin'); const files: { rel: string; src: string }[] = []; const walk = (dir: string) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === '__tests__') continue; walk(full); } else if (entry.name === 'route.ts') { const rel = path.relative(adminDir, full).replace(/\\/g, '/'); // The login endpoint must stay reachable without a session. if (rel.startsWith('auth/')) continue; files.push({ rel, src: fs.readFileSync(full, 'utf8') }); } } }; walk(adminDir); return files; }; it('exports no handler outside the wrapper', async () => { const offenders: string[] = []; for (const { rel, src } of await read()) { const bare = src.match(/export\s+(?:async\s+)?function\s+(GET|PUT|POST|DELETE|PATCH)\b/g); if (bare) offenders.push(`${rel}: ${bare.join(', ')}`); } expect( offenders, `Admin route handlers not wrapped in withAdmin():\n${offenders.join('\n')}`, ).toEqual([]); }); it('never re-implements the guard by hand', async () => { const offenders: string[] = []; for (const { rel, src } of await read()) { if (src.includes('isAdminEnabled') || src.includes('isAdminAuthenticated')) { offenders.push(rel); } } expect( offenders, `These call the auth helpers directly instead of using withAdmin():\n${offenders.join('\n')}`, ).toEqual([]); }); });