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

308 lines
12 KiB
JavaScript

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