/** * FR-SMS4 RBAC leak regression suite. * * Closes the four access-control leaks that the audit (`evidence/audit-2026-07-28.md`) * identified in dev @ 7fb4f00: * * 1. GET /api/fees/students — `?student_id=` ignored for student/parent * 2. GET /api/fees/invoices/:uid — owner/linked-parent/finance allowlist only * 3. GET /api/payments/* — ownership on status / student list / cancel / initiate * 4. GET /api/students/:uid/fees — student self-access when uid matches * * Tests use the seeded dev DB (see `./setup.js`) and the demo accounts from * `.harness/AGENTS.md`. The suite self-installs the parent_students link (the * upstream seed has a pre-existing CHECK-constraint typo on `relationship`), * a second student row, and a pair of invoice rows so the assertions are * deterministic regardless of seed ordering. */ const { pointAtDevDb } = require('./setup'); pointAtDevDb(); const request = require('supertest'); const Database = require('better-sqlite3'); const bcrypt = require('bcryptjs'); const { v4: uuidv4 } = require('uuid'); const app = require('../src/index'); async function loginToken(email, password) { const r = await request(app).post('/api/auth/login').send({ email, password }); if (r.status !== 200 || !r.body?.token) { throw new Error(`login ${email} -> ${r.status}: ${JSON.stringify(r.body)}`); } return r.body.token; } describe('FR-SMS4 RBAC leak closure', () => { let db; let tokens; let demoStudentId; let demoStudentUid; let otherStudentId; let otherStudentUid; let demoInvoiceUid; let otherInvoiceUid; beforeAll(async () => { db = new Database(process.env.DB_PATH); db.pragma('foreign_keys = ON'); tokens = { admin: await loginToken('admin@school.com', 'admin123'), teacher: await loginToken('teacher@school.com', 'teacher123'), student: await loginToken('student@school.com', 'student123'), parent: await loginToken('parent@school.com', 'parent123'), bursar: await loginToken('bursar@school.com', 'bursar123'), }; const demoStudent = db.prepare( "SELECT id, uid FROM users WHERE email='student@school.com'" ).get(); const demoParent = db.prepare( "SELECT id FROM users WHERE email='parent@school.com'" ).get(); if (!demoStudent || !demoParent) throw new Error('demo users missing'); demoStudentId = demoStudent.id; demoStudentUid = demoStudent.uid; // Repair the parent_students link. The current seed passes // relationship='Father' (capital F) but the CHECK constraint enforces // lowercase. The link is a fixture for this suite, not behavior under // test, so we fix it inline rather than mutate the upstream seed. db.prepare( `INSERT OR IGNORE INTO parent_students (uid, parent_id, student_id, relationship) VALUES (?, ?, ?, 'father')` ).run(uuidv4(), demoParent.id, demoStudentId); // Second student (cross-account assertions need a victim). otherStudentUid = 'uid-rbac-other-student'; const existingOther = db.prepare('SELECT id FROM users WHERE uid = ?').get(otherStudentUid); if (existingOther) { otherStudentId = existingOther.id; } else { const hash = bcrypt.hashSync('student123', 4); const r = db.prepare( `INSERT INTO users (uid, email, password, first_name, last_name, role, is_active) VALUES (?, ?, ?, ?, ?, 'student', 1)` ).run(otherStudentUid, 'rbac-other@school.com', hash, 'Other', 'Student'); otherStudentId = r.lastInsertRowid; } // Pair of invoices (table is empty after init.js). demoInvoiceUid = 'uid-rbac-invoice-demo'; otherInvoiceUid = 'uid-rbac-invoice-other'; const insertInvoice = db.prepare(` INSERT OR IGNORE INTO invoices (uid, invoice_number, student_id, subtotal, total, paid_amount, status, issued_at, due_date, created_by) VALUES (?, ?, ?, 0, 0, 0, 'issued', datetime('now'), date('now'), ?) `); insertInvoice.run(demoInvoiceUid, 'INV-RBAC-DEMO-001', demoStudentId, demoStudentId); insertInvoice.run(otherInvoiceUid, 'INV-RBAC-OTHER-001', otherStudentId, demoStudentId); // student_fee for the other student (used by initiate + cancel tests). db.prepare( `INSERT OR IGNORE INTO student_fees (uid, student_id, fee_group_id, amount, status, due_date) VALUES (?, ?, 1, 100, 'pending', date('now'))` ).run('uid-rbac-sfee-other', otherStudentId); // Reset a pending payment for the other student to keep the cancel test // deterministic across multiple test runs against the same DB. db.prepare('DELETE FROM payments WHERE uid = ?').run('uid-rbac-pay-other'); db.prepare( `INSERT INTO payments (uid, student_fee_id, amount, payment_method, reference_number, collected_by, status) VALUES (?, (SELECT id FROM student_fees WHERE uid = 'uid-rbac-sfee-other'), 50, 'cash', 'REF-RBAC-OTHER-001', ?, 'pending')` ).run('uid-rbac-pay-other', demoStudentId); // Fresh student_fee for the demo student so the "own initiate" test has // a deterministic owner check (paid_amount=0, amount=100, status='pending'). // The seeded student_fees.id=1 may have its paid_amount bumped by prior // webhook test runs, which would 400 the initiate route with // "Fee already fully paid" instead of exercising the ownership gate. // Use INSERT OR IGNORE — nothing in this suite mutates paid_amount, so // the row stays deterministic across multiple runs. db.prepare( `INSERT OR IGNORE INTO student_fees (uid, student_id, fee_group_id, amount, paid_amount, status, due_date) VALUES (?, ?, 1, 100, 0, 'pending', date('now'))` ).run('uid-rbac-sfee-demo-fresh', demoStudentId); }); afterAll(() => { db.close(); }); // ===================== Leak 1 ===================== describe('GET /api/fees/students', () => { it('student sees only own rows even when student_id overrides to another id', async () => { const res = await request(app) .get(`/api/fees/students?student_id=${otherStudentId}`) .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body.every((r) => r.student_id === demoStudentId)).toBe(true); }); it('parent ignores student_id override and returns own children only', async () => { const res = await request(app) .get(`/api/fees/students?student_id=${otherStudentId}`) .set('Authorization', `Bearer ${tokens.parent}`); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); // parent is linked to demoStudent only — the requested unlinked id must not appear expect(res.body.every((r) => r.student_id !== otherStudentId)).toBe(true); }); it('bursar can still pass an arbitrary student_id', async () => { const res = await request(app) .get(`/api/fees/students?student_id=${demoStudentId}`) .set('Authorization', `Bearer ${tokens.bursar}`); expect(res.status).toBe(200); expect(res.body.some((r) => r.student_id === demoStudentId)).toBe(true); }); }); // ===================== Leak 2 ===================== describe('GET /api/fees/invoices/:uid', () => { it('bursar (finance role) can read any invoice', async () => { const res = await request(app) .get(`/api/fees/invoices/${demoInvoiceUid}`) .set('Authorization', `Bearer ${tokens.bursar}`); expect(res.status).toBe(200); }); it('teacher is forbidden from reading any invoice', async () => { const res = await request(app) .get(`/api/fees/invoices/${demoInvoiceUid}`) .set('Authorization', `Bearer ${tokens.teacher}`); expect(res.status).toBe(403); }); it('student is allowed to read their own invoice (owner)', async () => { const res = await request(app) .get(`/api/fees/invoices/${demoInvoiceUid}`) .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(200); }); it('student is forbidden from reading another student\'s invoice', async () => { const res = await request(app) .get(`/api/fees/invoices/${otherInvoiceUid}`) .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(403); }); it('parent can read a linked child\'s invoice', async () => { const res = await request(app) .get(`/api/fees/invoices/${demoInvoiceUid}`) .set('Authorization', `Bearer ${tokens.parent}`); expect(res.status).toBe(200); }); it('parent is forbidden from reading an unlinked child\'s invoice', async () => { const res = await request(app) .get(`/api/fees/invoices/${otherInvoiceUid}`) .set('Authorization', `Bearer ${tokens.parent}`); expect(res.status).toBe(403); }); }); // ===================== Leak 3 ===================== describe('GET /api/payments/student/:studentId', () => { it('student is forbidden from fetching another student\'s payment list', async () => { const res = await request(app) .get(`/api/payments/student/${otherStudentId}`) .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(403); }); it('student can fetch their own payment list', async () => { const res = await request(app) .get(`/api/payments/student/${demoStudentId}`) .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(200); }); it('bursar can fetch any student\'s payment list', async () => { const res = await request(app) .get(`/api/payments/student/${demoStudentId}`) .set('Authorization', `Bearer ${tokens.bursar}`); expect(res.status).toBe(200); }); }); describe('GET /api/payments/status/:paymentId', () => { it('student can read status of their own payment', async () => { const res = await request(app) .get('/api/payments/status/uid-pay-1') .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(200); }); it('teacher cannot read status of any payment', async () => { const res = await request(app) .get('/api/payments/status/uid-pay-1') .set('Authorization', `Bearer ${tokens.teacher}`); expect(res.status).toBe(403); }); }); describe('POST /api/payments/initiate', () => { it('student can initiate against their own student_fee', async () => { const ownFee = db.prepare( "SELECT id FROM student_fees WHERE uid='uid-rbac-sfee-demo-fresh'" ).get(); const res = await request(app) .post('/api/payments/initiate') .set('Authorization', `Bearer ${tokens.student}`) .send({ student_fee_id: ownFee.id, amount: 10 }); expect(res.status).toBe(200); expect(res.body.payment_id).toBeTruthy(); }); it('student cannot initiate against another student\'s student_fee', async () => { const otherFee = db.prepare( "SELECT id FROM student_fees WHERE uid='uid-rbac-sfee-other'" ).get(); const res = await request(app) .post('/api/payments/initiate') .set('Authorization', `Bearer ${tokens.student}`) .send({ student_fee_id: otherFee.id, amount: 10 }); expect(res.status).toBe(403); }); }); describe('DELETE /api/payments/:paymentId', () => { it('student cannot cancel another student\'s payment', async () => { const res = await request(app) .delete('/api/payments/uid-rbac-pay-other') .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(403); }); }); // ===================== Leak 4 ===================== describe('GET /api/students/:uid/fees', () => { it('student can access their own UID via the canonical path', async () => { const res = await request(app) .get(`/api/students/${demoStudentUid}/fees`) .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(200); }); it('student cannot access another student\'s UID', async () => { const res = await request(app) .get(`/api/students/${otherStudentUid}/fees`) .set('Authorization', `Bearer ${tokens.student}`); expect(res.status).toBe(403); }); it('parent can access a linked child\'s UID', async () => { const res = await request(app) .get(`/api/students/${demoStudentUid}/fees`) .set('Authorization', `Bearer ${tokens.parent}`); expect(res.status).toBe(200); }); }); });