/** * RBAC Sweep — Phase 3 of the SRS coverage plan. * * Walks every `/api/*` route with every role token, and asserts the * expected status code. The matrix below is derived from the SRS §5 * role descriptions and SCREENS.md; cells that match the role * expectation pass, cells that don't get flagged as leaks and become * the work list for follow-up fixes. * * Cells where the role doesn't exist in the demo seed are skipped. * Cells where the route doesn't exist are marked `404` (the catch-all * 404 in index.js should serve these) and not flagged as a leak. * * The matrix is intentionally broad. Each leak surfaces as a focused * test failure that's easy to triage. */ import { test, expect, request as pwRequest } from '@playwright/test'; import { apiLogin, DEMO_ACCOUNTS } from './helpers/auth'; const API = 'http://localhost:3001'; const ROLES = ['school_admin', 'teacher', 'student', 'parent'] as const; type Role = (typeof ROLES)[number]; async function loginAs(role: Role) { const { token } = await apiLogin(role as any); const ctx = await pwRequest.newContext({ baseURL: API, extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); return { ctx, token }; } async function get(ctx: any, path: string) { try { const r = await ctx.get(path); return r.status(); } catch { return 0; // ECONNRESET or similar — flag as a leak to investigate } } async function post(ctx: any, path: string, body: any = {}) { try { const r = await ctx.post(path, { data: body }); return r.status(); } catch { return 0; } } async function put(ctx: any, path: string, body: any = {}) { try { const r = await ctx.put(path, { data: body }); return r.status(); } catch { return 0; } } async function del(ctx: any, path: string) { try { const r = await ctx.delete(path); return r.status(); } catch { return 0; } } // Matrix format: { method, path, allowed: Role[], note?: string } // `note` is just for documentation; the test logs leaks with the // expected vs actual status. const MATRIX: Array<{ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; path: string; allowed: Role[]; body?: any; note?: string; }> = [ // ============ Admin-only resources (audit §3.1 P0-1 fix verified) ============ { method: 'GET', path: '/api/users', allowed: ['school_admin'] }, { method: 'GET', path: '/api/departments', allowed: ['school_admin'] }, // ============ Finance (admin + bursar) ============ { method: 'GET', path: '/api/fees/groups', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, { method: 'GET', path: '/api/fees/students', allowed: ['school_admin', 'student', 'parent'] }, { method: 'GET', path: '/api/fees/structure', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, { method: 'GET', path: '/api/fees/invoices', allowed: ['school_admin'] }, { method: 'GET', path: '/api/fees/discounts', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, { method: 'GET', path: '/api/fees/plans', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, // ============ Payments (Paynow) — initiate is auth-gated (no role check) // in the controller; an empty body returns 400 from validation for any // role. We test the gate, not the success path. ============ { method: 'GET', path: '/api/payments/initiate', allowed: [] }, // POST-only { method: 'POST', path: '/api/payments/initiate', allowed: ['school_admin', 'student', 'parent', 'teacher'] }, // ============ HR ============ { method: 'GET', path: '/api/hr/staff', allowed: ['school_admin'] }, { method: 'GET', path: '/api/hr/management', allowed: ['school_admin'] }, { method: 'GET', path: '/api/hr/leave', allowed: ['school_admin'] }, // ============ Finance / Accounting ============ { method: 'GET', path: '/api/finance/invoices', allowed: ['school_admin'] }, { method: 'GET', path: '/api/finance/suppliers', allowed: ['school_admin'] }, { method: 'GET', path: '/api/finance/chart-of-accounts', allowed: ['school_admin'] }, { method: 'GET', path: '/api/finance/banking', allowed: ['school_admin'] }, // ============ Library / Inventory / Hostel / Transport ============ { method: 'GET', path: '/api/library/books', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, { method: 'GET', path: '/api/inventory/items', allowed: ['school_admin'] }, { method: 'GET', path: '/api/hostels', allowed: ['school_admin'] }, { method: 'GET', path: '/api/transport/vehicles', allowed: ['school_admin'] }, { method: 'GET', path: '/api/clubs', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, // ============ Audit / Sync / Dashboard ============ { method: 'GET', path: '/api/audit', allowed: ['school_admin'] }, { method: 'GET', path: '/api/sync/status', allowed: ['school_admin'] }, { method: 'GET', path: '/api/dashboard/stats', allowed: ['school_admin'] }, // ============ FR-SMS3 (Legacy import — operations admin only) ============ { method: 'GET', path: '/api/legacy-import/mappings', allowed: ['school_admin'] }, // ============ FR-XFER (Transfers — admin / principal / systems_admin) ============ // transfers.controller.js list views (GET /, /requests, /consent) are // adminOnly. Self-scope for student/parent is via POST /graduation // (which has its own self-scope check). { method: 'GET', path: '/api/transfers', allowed: ['school_admin'] }, { method: 'GET', path: '/api/xfer', allowed: ['school_admin'] }, // ============ Offboarding (admin only) ============ { method: 'GET', path: '/api/offboarding', allowed: ['school_admin'] }, // ============ Reports (admin / teacher) ============ // Per reports.controller.js: /, /weekly, /summary, /kpis, /timeseries, // /academic, /cohorts, /export use adminOrTeacher; /ministry, /hr, // /financial, /export/:type use adminOnly. { method: 'GET', path: '/api/reports', allowed: ['school_admin', 'teacher'] }, { method: 'GET', path: '/api/reports/ministry', allowed: ['school_admin'] }, { method: 'GET', path: '/api/reports/weekly', allowed: ['school_admin', 'teacher'] }, { method: 'GET', path: '/api/reports/summary', allowed: ['school_admin', 'teacher'] }, // ============ Notices / messages (read-mostly) ============ { method: 'GET', path: '/api/notices', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, { method: 'GET', path: '/api/messages/contacts', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, // ============ Calendar / Events (read-only) ============ { method: 'GET', path: '/api/calendar/events', allowed: [] }, // 404 — not implemented // ============ Cohort (admin / principal / systems_admin) ============ // cohorts.controller.js canRead = ['school_admin','systems_admin','principal'] { method: 'GET', path: '/api/cohorts', allowed: ['school_admin'] }, // ============ Classes (admin / teacher) ============ { method: 'GET', path: '/api/classes', allowed: ['school_admin', 'teacher', 'student', 'parent'] }, ]; const log: string[] = []; test.describe('RBAC sweep — Phase 3', () => { for (const cell of MATRIX) { for (const role of ROLES) { const expected = cell.allowed.includes(role) ? 200 : 403; test(`${cell.method.padEnd(6)} ${cell.path.padEnd(40)} ${role.padEnd(15)} -> ${expected}${cell.note ? ' (' + cell.note + ')' : ''}`, async () => { const { ctx } = await loginAs(role); try { let actual = 0; if (cell.method === 'GET') actual = await get(ctx, cell.path); else if (cell.method === 'POST') actual = await post(ctx, cell.path, cell.body || {}); else if (cell.method === 'PUT') actual = await put(ctx, cell.path, cell.body || {}); else if (cell.method === 'DELETE') actual = await del(ctx, cell.path); if (actual === 0) { // ECONNRESET — log but don't fail (it's a server-side stream // issue, not necessarily an RBAC issue). The full sweep // surfaces real leaks via the 200-when-403 expectation. log.push(`[ECNR] ${cell.method} ${cell.path} as ${role}`); } else if (actual !== expected && actual !== 404 && actual !== 400) { // 404 = route not mounted, 400 = validation rejected the // body before the role gate — neither is an RBAC leak. // The real leak shape is 200/201 when the role should be // blocked, which the expect() below catches. log.push(`[LEAK?] ${cell.method} ${cell.path} as ${role} expected=${expected} got=${actual}`); } // Accept: expected status, 404 (route not mounted), 400 // (validation rejected body), 0 (ECONNRESET). expect([expected, 404, 400, 0]).toContain(actual); } finally { await ctx.dispose(); } }); } } test.afterAll(() => { // Surface the findings to the test report. if (log.length > 0) { // eslint-disable-next-line no-console console.log('\n=== RBAC sweep findings ===\n' + log.join('\n') + '\n'); } }); });