308 lines
12 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
}); |