113 lines
5.6 KiB
JavaScript
113 lines
5.6 KiB
JavaScript
// 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);
|
||
});
|
||
}
|
||
}
|
||
});
|