diff --git a/backend/tests/security.test.js b/backend/tests/security.test.js new file mode 100644 index 0000000..bf3abf9 --- /dev/null +++ b/backend/tests/security.test.js @@ -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); +});