geocrop-platform./apps/nextgen/server/tests/rbac-matrix.test.js

113 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
});
}
}
});