- global-setup.ts: TRUNCATE local_credentials + seed devuser/devpass (PHC scrypt inline) - Create login.spec.ts: real-login-form e2e (gate redirect, wrong-password error, correct login) - ci.yml: add LOCAL_SESSION_SECRET dev value + local_credentials seed step in harness job - Fix all test mocks: add devSessionCookieMiddleware no-op to vi.mock(devBypass.js) blocks in admin/setup/push/lists/localAuth/authMode/requireAdmin tests (Rule 1 - Bug: missing export) - Full API suite: 446/446 tests pass; pnpm typecheck: exit 0
140 lines
5.1 KiB
TypeScript
140 lines
5.1 KiB
TypeScript
/**
|
|
* 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<void>) => next(),
|
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
COLOR_PALETTE: ['#4A90D9'],
|
|
}));
|
|
|
|
import { db } from '../../src/db/client.js';
|
|
import { requireAdmin } from '../../src/lib/requireAdmin.js';
|
|
|
|
const mockDb = db as { select: ReturnType<typeof vi.fn> };
|
|
|
|
// ── 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');
|
|
});
|
|
});
|