diff --git a/apps/api/tests/lib/requireAdmin.test.ts b/apps/api/tests/lib/requireAdmin.test.ts new file mode 100644 index 0000000..b231de8 --- /dev/null +++ b/apps/api/tests/lib/requireAdmin.test.ts @@ -0,0 +1,131 @@ +/** + * requireAdmin middleware tests (Plan 10-02, Task 1) + * + * Behavior-pinned contracts: + * 1. non-admin DB row → 403 { error: 'Forbidden' }, next() NOT called + * 2. admin DB row → next() called, request proceeds + * 3. no resolved user on context (c.get('user') undefined) → 403 + * 4. role is read from the DB (users.is_admin), NOT from context user object — + * a context user claiming isAdmin=true but with is_admin=false in DB is still 403 + * (bypass only skips OIDC, not the DB check — T-10-04, T-10-05) + * + * Uses mocked db to test the middleware in isolation (no live DB required). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; + +// Mock the db singleton so tests do not need a live MariaDB connection. +vi.mock('../../src/db/client.js', () => ({ + db: { + select: vi.fn(), + }, +})); + +// Bring in the ContextVariableMap augmentation (sets up c.get('user') typing) +vi.mock('../../src/auth/devBypass.js', () => ({ + DEV_USER: { id: 1, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color: '#4A90D9' }, + devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), + COLOR_PALETTE: ['#4A90D9'], +})); + +import { db } from '../../src/db/client.js'; +import { requireAdmin } from '../../src/lib/requireAdmin.js'; + +const mockDb = db as { select: ReturnType }; + +// ── DB query chain builder ──────────────────────────────────────────────────── + +function makeSelectChain(resolvedValue: unknown[]) { + const chain = { + from: vi.fn(), + where: vi.fn(), + limit: vi.fn().mockResolvedValue(resolvedValue), + }; + chain.from.mockReturnValue(chain); + chain.where.mockReturnValue(chain); + return chain; +} + +// ── Test app factory ────────────────────────────────────────────────────────── + +/** + * Creates a minimal Hono app that mounts requireAdmin and a downstream handler + * that sets a header so we can assert whether next() was called. + */ +function makeTestApp(userOnContext: { id: number; isAdmin?: boolean } | undefined) { + const app = new Hono(); + + // Inject user into context (simulates devAuthBypass or OIDC middleware output) + app.use('*', async (c, next) => { + if (userOnContext !== undefined) { + // Cast: the ContextVariableMap expects the full DEV_USER shape; we only need id + // eslint-disable-next-line @typescript-eslint/no-explicit-any + c.set('user', userOnContext as any); + } + await next(); + }); + + app.use('*', requireAdmin); + + app.get('/test', (c) => c.json({ ok: true })); + + return app; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('requireAdmin middleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 403 for an authenticated non-admin user (is_admin=false in DB)', async () => { + // DB returns row with isAdmin=false + mockDb.select.mockReturnValue(makeSelectChain([{ isAdmin: false }])); + + const app = makeTestApp({ id: 42 }); + const res = await app.request('/test'); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Forbidden'); + }); + + it('calls next() for an authenticated admin user (is_admin=true in DB)', async () => { + // DB returns row with isAdmin=true + mockDb.select.mockReturnValue(makeSelectChain([{ isAdmin: true }])); + + const app = makeTestApp({ id: 1 }); + const res = await app.request('/test'); + + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it('returns 403 when no user is resolved on context (c.get("user") is undefined)', async () => { + // No DB call expected — userId is missing, short-circuit to 403 + const app = makeTestApp(undefined); + const res = await app.request('/test'); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Forbidden'); + // DB must NOT be queried when user is absent + expect(mockDb.select).not.toHaveBeenCalled(); + }); + + it('returns 403 when context user claims isAdmin=true but DB row has is_admin=false (T-10-04)', async () => { + // Context carries a spoofed isAdmin claim — DB should be the authority + mockDb.select.mockReturnValue(makeSelectChain([{ isAdmin: false }])); + + // User on context has isAdmin=true (as if a client tried to inject it) + const app = makeTestApp({ id: 99, isAdmin: true }); + const res = await app.request('/test'); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Forbidden'); + }); +});