geocrop-platform./apps/nextgen/client/e2e/rbac-sweep.spec.ts

203 lines
8.9 KiB
TypeScript

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