// Integration test: walks the SCREENS.md role-permission matrix rows that // have a clear View/CRUD answer and asserts the matching HTTP status code // for every role × every representative route. // // These specs do NOT touch the real database. They mount a tiny Express // app with the requireRole middleware in front of placeholder handlers, // then poke each (role, route) cell of the matrix. // // The cells below are lifted directly from .harness/SCREENS.md §"Role // Permissions Matrix" (and stable). When that table changes, update the // `MATRIX` array below; this file is the executable form of the doc. const { ROLE, requireRole } = require('../src/middleware/requireRole'); const express = require('express'); const request = require('supertest'); function buildMatrixApp() { const app = express(); app.use(express.json()); // Pretend-auth: substitute req.user from the X-Test-Role header so we // exercise the actual requireRole middleware with realistic shapes. app.use((req, res, next) => { const role = req.get('X-Test-Role'); req.user = role ? { id: 1, role } : null; next(); }); // Each entry mirrors a cell of SCREENS.md. The allowed list reflects // the "View" or "CRUD" cell for that row. Use 'execute' for /exams/take // — that route is student-only and is closed to all other roles. const routes = [ { method: 'get', path: '/api/settings', allowed: [ROLE.SYSTEMS_ADMIN] }, { method: 'get', path: '/api/audit-logs', allowed: [ROLE.SYSTEMS_ADMIN] }, { method: 'get', path: '/api/users', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL] }, { method: 'get', path: '/api/departments', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL] }, { method: 'get', path: '/api/hr/management', allowed: [ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL, ROLE.HR] }, { method: 'get', path: '/api/finance/payroll', allowed: [ROLE.BURSAR] }, { method: 'get', path: '/api/fees', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.BURSAR, ROLE.STUDENT] }, { method: 'get', path: '/api/front-office/visitors', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL, ROLE.TEACHER] }, { method: 'get', path: '/api/attendance/take', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL, ROLE.TEACHER] }, { method: 'get', path: '/api/my-courses', allowed: [ROLE.TEACHER, ROLE.STUDENT] }, { method: 'get', path: '/api/exams/take', allowed: [ROLE.STUDENT] }, { method: 'get', path: '/api/clubs-management', allowed: [ROLE.CLUBS_HEAD] }, { method: 'get', path: '/api/notice-board', allowed: [ ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL, ROLE.HR, ROLE.TEACHER, ROLE.STUDENT, ROLE.PARENT, ROLE.CLUBS_HEAD, ] }, { method: 'get', path: '/api/extracurriculars', allowed: [ ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL, ROLE.HR, ROLE.TEACHER, ROLE.STUDENT, ROLE.PARENT, ROLE.CLUBS_HEAD, ] }, ]; for (const r of routes) { app[r.method](r.path, requireRole(...r.allowed), (req, res) => res.json({ ok: true })); } return app; } const MATRIX_ROWS = [ { path: '/api/settings', allowed: [ROLE.SYSTEMS_ADMIN] }, { path: '/api/audit-logs', allowed: [ROLE.SYSTEMS_ADMIN] }, { path: '/api/users', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL] }, { path: '/api/departments', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL] }, { path: '/api/hr/management', allowed: [ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL, ROLE.HR] }, { path: '/api/finance/payroll', allowed: [ROLE.BURSAR] }, { path: '/api/fees', allowed: [ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.BURSAR, ROLE.STUDENT] }, { path: '/api/notice-board', allowed: [ ROLE.SYSTEMS_ADMIN, ROLE.SCHOOL_ADMIN, ROLE.PRINCIPAL, ROLE.HR, ROLE.TEACHER, ROLE.STUDENT, ROLE.PARENT, ROLE.CLUBS_HEAD, ] }, { path: '/api/exams/take', allowed: [ROLE.STUDENT] }, { path: '/api/clubs-management', allowed: [ROLE.CLUBS_HEAD] }, ]; const ALL_ROLES = Object.values(ROLE).concat([null]); describe('SCREENS.md role-permission matrix', () => { const app = buildMatrixApp(); for (const { path, allowed } of MATRIX_ROWS) { for (const role of ALL_ROLES) { const expected = role === null ? 401 : (allowed.includes(role) ? 200 : 403); const label = role === null ? 'no-token' : role; it(`${label.padEnd(15)} -> ${expected} ${path}`, async () => { const req = request(app).get(path); if (role) req.set('X-Test-Role', role); const res = await req; expect(res.status).toBe(expected); }); } } }); describe('SCREENS.md negative cells (rows where the role is forbidden)', () => { // A focused subset: every role that is *not* in the allowed list for a // sensitive route must get 403. Useful as a regression net against // accidentally widening access. const app = buildMatrixApp(); const strictRows = [ { path: '/api/audit-logs', expectedRoles: [ROLE.STUDENT, ROLE.PARENT, ROLE.TEACHER, ROLE.PRINCIPAL] }, { path: '/api/clubs-management', expectedRoles: [ROLE.STUDENT, ROLE.PARENT, ROLE.TEACHER, ROLE.PRINCIPAL] }, { path: '/api/finance/payroll', expectedRoles: [ROLE.STUDENT, ROLE.PARENT, ROLE.TEACHER, ROLE.PRINCIPAL] }, ]; for (const { path, expectedRoles } of strictRows) { for (const role of expectedRoles) { it(`forbids ${role} from ${path}`, async () => { const res = await request(app).get(path).set('X-Test-Role', role); expect(res.status).toBe(403); }); } } });