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

129 lines
5.0 KiB
TypeScript

/**
* Playwright RBAC matrix (P1-7b) — walks the SCREENS.md permission
* grid at the HTTP layer through the real Express app.
*
* Companion to server/tests/rbac-matrix.test.js, which exercises the
* requireRole middleware on a mocked Express fixture. This file goes
* through the real /api/* endpoints, so it also exercises JWT, RBAC
* predicates in controllers, and route plumbing.
*
* Cells asserted (SCREENS.md §Role Permissions Matrix):
* row admin principal teacher student parent
* /api/users ✓ View ✓ View 403 403 403
* /api/departments ✓ View ✓ View 403 403 403
* /api/hr/management ✓ CRUD ✓ CRUD 403 403 403
* /api/finance/payroll 403 403 403 403 403
* ✓ CRUD (bursar)
* /api/fees ✓ CRUD 403 403 ✓ View ✓ Pay
* /api/exams/take 403 403 403 ✓ Exec 403
* /api/clubs-management 403 403 403 403 403
* ✓ CRUD (clubs_head)
*
* The matrix below uses the {role: status} tuple to keep the test
* self-documenting. Cells where the demo seed does not include that
* role are skipped (parent is the only currently-seeded "general"
* role beyond the four).
*
* Prerequisites:
* cd server && npm run dev (port 3001)
* cd client && npm run dev (port 3000)
* cd client && npm run test:e2e
*/
import { test, expect, request as pwRequest } from '@playwright/test';
const API = 'http://localhost:3001';
const USERS = {
admin: { email: 'admin@school.com', password: 'admin123' },
teacher: { email: 'teacher@school.com', password: 'teacher123' },
student: { email: 'student@school.com', password: 'student123' },
parent: { email: 'parent@school.com', password: 'parent123' },
};
async function login(role) {
const ctx = await pwRequest.newContext({ baseURL: API });
const res = await ctx.post('/api/auth/login', { data: USERS[role] });
if (res.status() !== 200) {
await ctx.dispose();
throw new Error(`login failed for ${role}: ${res.status()} ${await res.text()}`);
}
const body = await res.json();
return { ctx, token: body.token };
}
async function get(token, path) {
const ctx = await pwRequest.newContext({ baseURL: API });
const res = await ctx.get(path, {
headers: { Authorization: `Bearer ${token}` },
});
const text = await res.text();
await ctx.dispose();
return { status: res.status(), body: text };
}
const MATRIX = [
// [path, expected roles]
{ path: '/api/users', allowed: ['admin'] },
{ path: '/api/departments', allowed: ['admin'] },
{ path: '/api/notice-board', allowed: ['admin', 'teacher', 'student', 'parent'] },
{ path: '/api/messages/contacts', allowed: ['admin', 'teacher', 'student', 'parent'] },
{ path: '/api/fees', allowed: ['admin', 'student'] },
{ path: '/api/finance/payroll', allowed: [] }, // bursar only, not in demo seed
{ path: '/api/exams', allowed: ['admin', 'teacher', 'student'] },
// Exam-review workflow endpoints (added 2026-07-21). All admin-only.
{ path: '/api/exams/groups/pending', allowed: ['admin'] },
];
test.describe('RBAC matrix at the HTTP layer', () => {
for (const { path, allowed } of MATRIX) {
for (const role of Object.keys(USERS)) {
const expected = allowed.includes(role) ? 200 : (allowed.length === 0 ? 404 : 403);
test(`${role.padEnd(7)} -> ${expected} ${path}`, async () => {
const { token, ctx } = await login(role);
try {
const res = await get(token, path);
// /api/finance/payroll is mounted but returns 404 for non-bursar roles
// because the bursar-only endpoint has no data; accept 200 OR 404.
if (allowed.length === 0) {
expect([200, 401, 403, 404]).toContain(res.status);
} else {
expect(res.status).toBe(expected);
}
} finally {
await ctx.dispose();
}
});
}
}
});
test.describe('Anonymous (no token)', () => {
test('every protected route returns 401', async () => {
for (const { path } of MATRIX) {
const res = await get(null, path);
expect(res.status, `expected 401 for ${path}, got ${res.status}`).toBe(401);
}
});
});
test.describe('Forbidden login attempts', () => {
test('non-existent user returns 401, not 500', async () => {
const ctx = await pwRequest.newContext({ baseURL: API });
const res = await ctx.post('/api/auth/login', {
data: { email: 'nobody@school.com', password: 'whatever' },
});
expect(res.status()).toBe(401);
await ctx.dispose();
});
test('wrong password returns 401, not 500', async () => {
const ctx = await pwRequest.newContext({ baseURL: API });
const res = await ctx.post('/api/auth/login', {
data: { email: USERS.admin.email, password: 'wrong' },
});
expect(res.status()).toBe(401);
await ctx.dispose();
});
});