Files
Learn_System/backend/tests/security.test.js
T
Maxim Dolgolyov c1c08be2b0 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>
2026-05-06 17:21:05 +03:00

103 lines
4.8 KiB
JavaScript

'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);
});