test: 7 e2e tests for permission boundaries
Focused suite covering IDOR and privilege-escalation patterns: 1. Student cannot delete another teacher's class (role block) 2. Teacher cannot delete another teacher's class (ownership check) 3. Banned user blocked on all protected routes 4. Token revoked after token_version bump (password change flow) 5. Join class with wrong invite code → 404, no membership created 6. Admin-only route blocks teacher role 7. Protected route without token → 401 All 7 pass. Pre-existing auth.test.js failures (rate-limiter shared state in test server) are unrelated and were present before this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
const { test, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { db, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
/* 1. Student cannot delete a teacher's class ─────────────────────────────── */
|
||||
test('student cannot delete another teacher\'s class', async () => {
|
||||
const teacher = await getToken('teacher');
|
||||
const student = await getToken('student');
|
||||
|
||||
const create = await inject('POST', '/api/classes', { name: 'Security test class 1' }, teacher.token);
|
||||
assert.ok(create.status < 300, `create failed: ${JSON.stringify(create.body)}`);
|
||||
const classId = create.body.id;
|
||||
|
||||
const del = await inject('DELETE', `/api/classes/${classId}`, null, student.token);
|
||||
assert.equal(del.status, 403, `expected 403, got ${del.status}: ${JSON.stringify(del.body)}`);
|
||||
|
||||
// Class must still exist
|
||||
const row = db.prepare('SELECT id FROM classes WHERE id = ?').get(classId);
|
||||
assert.ok(row, 'class was deleted — IDOR confirmed');
|
||||
});
|
||||
|
||||
/* 2. Teacher A cannot delete Teacher B's class ───────────────────────────── */
|
||||
test('teacher cannot delete another teacher\'s class', async () => {
|
||||
const tA = await getToken('teacher');
|
||||
const tB = await getToken('teacher');
|
||||
|
||||
const create = await inject('POST', '/api/classes', { name: 'Teacher B class' }, tB.token);
|
||||
assert.ok(create.status < 300, `create failed: ${JSON.stringify(create.body)}`);
|
||||
const classId = create.body.id;
|
||||
|
||||
const del = await inject('DELETE', `/api/classes/${classId}`, null, tA.token);
|
||||
assert.equal(del.status, 403, `expected 403, got ${del.status}: ${JSON.stringify(del.body)}`);
|
||||
|
||||
const row = db.prepare('SELECT id FROM classes WHERE id = ?').get(classId);
|
||||
assert.ok(row, 'class was deleted by wrong teacher — IDOR confirmed');
|
||||
});
|
||||
|
||||
/* 3. Banned user is blocked on protected routes ─────────────────────────── */
|
||||
test('banned user is blocked on protected routes', async () => {
|
||||
const u = await getToken('student');
|
||||
|
||||
// Token works before ban
|
||||
const before = await inject('GET', '/api/auth/me', null, u.token);
|
||||
assert.equal(before.status, 200);
|
||||
|
||||
db.prepare('UPDATE users SET is_banned = 1 WHERE id = ?').run(u.userId);
|
||||
|
||||
const after = await inject('GET', '/api/auth/me', null, u.token);
|
||||
assert.equal(after.status, 403, `expected 403 for banned user, got ${after.status}`);
|
||||
assert.ok(
|
||||
/заблокирован|banned/i.test(after.body.error || ''),
|
||||
`expected ban message, got: ${after.body.error}`
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
db.prepare('UPDATE users SET is_banned = 0 WHERE id = ?').run(u.userId);
|
||||
});
|
||||
|
||||
/* 4. Token is revoked when token_version is bumped ──────────────────────── */
|
||||
test('token revoked when token_version changes', async () => {
|
||||
const u = await getToken('student');
|
||||
|
||||
// Token works
|
||||
const ok = await inject('GET', '/api/auth/me', null, u.token);
|
||||
assert.equal(ok.status, 200);
|
||||
|
||||
// Simulate password change (bumps token_version)
|
||||
db.prepare('UPDATE users SET token_version = COALESCE(token_version, 0) + 1 WHERE id = ?').run(u.userId);
|
||||
|
||||
const revoked = await inject('GET', '/api/auth/me', null, u.token);
|
||||
assert.equal(revoked.status, 401, `expected 401 after token_version bump, got ${revoked.status}`);
|
||||
});
|
||||
|
||||
/* 5. Student cannot join class with wrong invite code ───────────────────── */
|
||||
test('join with wrong invite code returns 404', async () => {
|
||||
const student = await getToken('student');
|
||||
|
||||
const join = await inject('POST', '/api/classes/join', { invite_code: 'WRONG-XXXX-0000' }, student.token);
|
||||
assert.ok(join.status === 404 || join.status === 400,
|
||||
`expected 404 or 400, got ${join.status}: ${JSON.stringify(join.body)}`);
|
||||
|
||||
// Verify no class membership was created
|
||||
const membership = db.prepare('SELECT 1 FROM class_members WHERE user_id = ?').get(student.userId);
|
||||
assert.equal(membership, undefined, 'student joined a class with wrong code');
|
||||
});
|
||||
|
||||
/* 6. Admin-only route blocks teacher ────────────────────────────────────── */
|
||||
test('admin-only GET /admin/users blocks teacher', async () => {
|
||||
const teacher = await getToken('teacher');
|
||||
|
||||
const res = await inject('GET', '/api/admin/users', null, teacher.token);
|
||||
assert.equal(res.status, 403, `expected 403 for teacher on admin route, got ${res.status}`);
|
||||
});
|
||||
|
||||
/* 7. Protected route without token returns 401 ──────────────────────────── */
|
||||
test('protected route without token returns 401', async () => {
|
||||
const res = await inject('GET', '/api/auth/me', null, null);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
Reference in New Issue
Block a user