'use strict'; /** * Integration tests: /api/permissions * Covers: role toggle + audit + token_version bump, user override, reset, /me, 403 for non-admin. */ const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { db, inject, getToken, cleanup } = require('./setup'); after(() => cleanup()); describe('Permissions', () => { let adminToken, teacherUser, studentUser; before(async () => { const a = await getToken('admin'); adminToken = a.token; teacherUser = await getToken('teacher'); studentUser = await getToken('student'); }); // ── 1. Non-admin gets 403 on POST /api/permissions ──────────────────────── it('non-admin (teacher) gets 403 on POST /api/permissions', async () => { const res = await inject('POST', '/api/permissions', { role: 'student', permission: 'tests.free', enabled: false, }, teacherUser.token); assert.equal(res.status, 403, `expected 403, got ${res.status}`); }); it('non-admin (student) gets 403 on POST /api/permissions', async () => { const res = await inject('POST', '/api/permissions', { role: 'student', permission: 'tests.free', enabled: false, }, studentUser.token); assert.equal(res.status, 403, `expected 403, got ${res.status}`); }); // ── 2. No token gets 401 on GET /api/permissions ────────────────────────── it('unauthenticated request gets 401 on GET /api/permissions', async () => { const res = await inject('GET', '/api/permissions', null, null); assert.equal(res.status, 401, `expected 401, got ${res.status}`); }); // ── 3. Role-level toggle сохраняется и НЕ инвалидирует сессии (live enforcement) ─ it('admin toggles role permission; token_version НЕ бампается (нет массового разлогина)', async () => { const tvBefore = db.prepare('SELECT token_version FROM users WHERE id = ?') .get(studentUser.userId).token_version; const res = await inject('POST', '/api/permissions', { role: 'student', permission: 'tests.free', enabled: false, }, adminToken); assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`); assert.equal(res.body.ok, true); // Значение сохранено в role_permissions — сервер применяет его живо (requirePermission читает БД). const row = db.prepare('SELECT enabled FROM role_permissions WHERE role = ? AND permission = ?') .get('student', 'tests.free'); assert.equal(row.enabled, 0, 'role-level значение сохранено'); const tvAfter = db.prepare('SELECT token_version FROM users WHERE id = ?') .get(studentUser.userId).token_version; assert.equal(tvAfter, tvBefore, 'role-level изменение НЕ должно разлогинивать пользователей роли'); // Restore await inject('POST', '/api/permissions', { role: 'student', permission: 'tests.free', enabled: true, }, adminToken); }); // ── 4. Audit entry created for role permission toggle ───────────────────── it('audit entry created when role permission is toggled', async () => { // Clear audit log first to count new entries const countBefore = db.prepare( "SELECT COUNT(*) AS n FROM admin_audit_log WHERE action = 'permission.set'" ).get().n; await inject('POST', '/api/permissions', { role: 'teacher', permission: 'questions.manage', enabled: true, }, adminToken); const countAfter = db.prepare( "SELECT COUNT(*) AS n FROM admin_audit_log WHERE action = 'permission.set'" ).get().n; assert.ok(countAfter > countBefore, 'Expected new permission.set audit entry'); }); // ── 5. POST /api/permissions/users/:id sets user override + bumps token_version it('admin sets user override and target token_version bumped', async () => { const tvBefore = db.prepare('SELECT token_version FROM users WHERE id = ?') .get(studentUser.userId).token_version; const res = await inject('POST', `/api/permissions/users/${studentUser.userId}`, { permission: 'shop.purchase', enabled: false, }, adminToken); assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`); assert.equal(res.body.ok, true); const tvAfter = db.prepare('SELECT token_version FROM users WHERE id = ?') .get(studentUser.userId).token_version; assert.ok(tvAfter > tvBefore, `token_version should increase for targeted user`); // Verify the override was persisted const row = db.prepare( 'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?' ).get(studentUser.userId, 'shop.purchase'); assert.ok(row, 'user_permissions row should exist'); assert.equal(row.enabled, 0, 'override should be disabled'); }); // ── 6. Audit entry created for user override ───────────────────────────── it('audit entry created when user override is set', async () => { const countBefore = db.prepare( "SELECT COUNT(*) AS n FROM admin_audit_log WHERE action = 'permission.user_set'" ).get().n; await inject('POST', `/api/permissions/users/${teacherUser.userId}`, { permission: 'results.export', enabled: false, }, adminToken); const countAfter = db.prepare( "SELECT COUNT(*) AS n FROM admin_audit_log WHERE action = 'permission.user_set'" ).get().n; assert.ok(countAfter > countBefore, 'Expected new permission.user_set audit entry'); }); // ── 7. DELETE /api/permissions/users/:id/reset clears overrides, bumps tv ─ it('reset user permissions clears overrides and bumps token_version', async () => { // Ensure there's at least one override await inject('POST', `/api/permissions/users/${studentUser.userId}`, { permission: 'board.post', enabled: false, }, adminToken); const tvBefore = db.prepare('SELECT token_version FROM users WHERE id = ?') .get(studentUser.userId).token_version; const res = await inject('DELETE', `/api/permissions/users/${studentUser.userId}/reset`, {}, adminToken); assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`); assert.equal(res.body.ok, true); const tvAfter = db.prepare('SELECT token_version FROM users WHERE id = ?') .get(studentUser.userId).token_version; assert.ok(tvAfter > tvBefore, 'token_version should increase after reset'); // No more overrides in user_permissions const rows = db.prepare('SELECT * FROM user_permissions WHERE user_id = ?') .all(studentUser.userId); assert.equal(rows.length, 0, 'user_permissions should be empty after reset'); }); // ── 8. GET /api/permissions/me returns effective permissions ────────────── it('GET /api/permissions/me returns role and permissions array for student', async () => { // Use a fresh student whose token_version has not been bumped by earlier tests const freshStudent = await getToken('student'); const res = await inject('GET', '/api/permissions/me', null, freshStudent.token); assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`); assert.equal(res.body.role, 'student'); assert.ok(Array.isArray(res.body.permissions), 'permissions should be an array'); assert.ok(res.body.permissions.length > 0, 'permissions array should not be empty'); // Each entry must have key and effective for (const p of res.body.permissions) { assert.ok('key' in p, 'permission entry missing key'); assert.ok('effective' in p, 'permission entry missing effective'); } }); it('GET /api/permissions/me for admin returns empty permissions (admin bypasses all)', async () => { const res = await inject('GET', '/api/permissions/me', null, adminToken); assert.equal(res.status, 200); assert.equal(res.body.role, 'admin'); assert.ok(Array.isArray(res.body.permissions), 'admin permissions should be an array'); assert.equal(res.body.permissions.length, 0, 'admin should have empty permissions list'); }); // ── 9. Invalid role rejected ─────────────────────────────────────────────── it('POST /api/permissions with invalid role returns 400', async () => { const res = await inject('POST', '/api/permissions', { role: 'superadmin', permission: 'tests.free', enabled: true, }, adminToken); assert.equal(res.status, 400); }); // ── 10. A1: зависимости (requires) ───────────────────────────────────────── it('зависимость requires: simulations.quiz неэффективен при выключенном simulations.access', async () => { const off = await inject('POST', '/api/permissions', { role: 'student', permission: 'simulations.access', enabled: false }, adminToken); assert.equal(off.status, 200); const view = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken); assert.equal(view.status, 200); const quiz = view.body.permissions.find(p => p.key === 'simulations.quiz'); const acc = view.body.permissions.find(p => p.key === 'simulations.access'); assert.equal(acc.effective, false, 'родитель simulations.access выключен'); assert.equal(quiz.effective, false, 'дочернее simulations.quiz неэффективно из-за requires'); assert.deepEqual(quiz.requires, ['simulations.access'], 'requires проброшен в API'); // restore await inject('POST', '/api/permissions', { role: 'student', permission: 'simulations.access', enabled: true }, adminToken); }); // ── B5: группы прав в определениях ───────────────────────────────────────── it('B5: GET /api/permissions — у каждого определения есть group', async () => { const res = await inject('GET', '/api/permissions', null, adminToken); assert.equal(res.status, 200); assert.ok(Array.isArray(res.body.definitions) && res.body.definitions.length > 0); assert.ok(res.body.definitions.every(d => typeof d.group === 'string' && d.group.length > 0), 'у каждого определения есть непустой group'); }); // ── 11. A3: история изменений прав ───────────────────────────────────────── it('GET /api/permissions/log — история (admin видит записи; не-админу 403)', async () => { await inject('POST', '/api/permissions', { role: 'teacher', permission: 'shop.manage', enabled: true }, adminToken); const log = await inject('GET', '/api/permissions/log', null, adminToken); assert.equal(log.status, 200); assert.ok(Array.isArray(log.body) && log.body.length >= 1, 'есть записи истории'); assert.ok('text' in log.body[0] && 'actor' in log.body[0], 'формат записи'); const fresh = await getToken('student'); const denied = await inject('GET', '/api/permissions/log', null, fresh.token); assert.equal(denied.status, 403, 'не-админу недоступно'); }); // ── B6: массово по классу ────────────────────────────────────────────────── it('B6: массовое право классу — личный оверрайд всем ученикам + сброс + валидация', async () => { const cr = await inject('POST', '/api/classes', { name: 'PermBulk Class' }, adminToken); assert.ok(cr.status < 300, JSON.stringify(cr.body)); const cid = db.prepare('SELECT id FROM classes WHERE name = ?').get('PermBulk Class').id; await inject('POST', `/api/classes/${cid}/members`, { user_id: studentUser.userId }, adminToken); const off = await inject('POST', `/api/permissions/class/${cid}/bulk`, { permission: 'shop.purchase', enabled: false }, adminToken); assert.equal(off.status, 200); assert.ok(off.body.affected >= 1, 'затронут хотя бы один ученик'); const row = db.prepare('SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?') .get(studentUser.userId, 'shop.purchase'); assert.ok(row && row.enabled === 0, 'личный оверрайд выключен у ученика класса'); // сброс (наследование роли) await inject('POST', `/api/permissions/class/${cid}/bulk`, { permission: 'shop.purchase', enabled: null }, adminToken); const gone = db.prepare('SELECT 1 FROM user_permissions WHERE user_id = ? AND permission = ?') .get(studentUser.userId, 'shop.purchase'); assert.ok(!gone, 'оверрайд снят'); // teacher-право для массовой студенческой операции отклоняется const bad = await inject('POST', `/api/permissions/class/${cid}/bulk`, { permission: 'questions.manage', enabled: true }, adminToken); assert.equal(bad.status, 400); }); // ── B7: пресеты-профили ──────────────────────────────────────────────────── it('B7: пресеты — список + применение к классу + валидация', async () => { const list = await inject('GET', '/api/permissions/presets', null, adminToken); assert.equal(list.status, 200); assert.ok(Array.isArray(list.body.student) && list.body.student.some(p => p.id === 'focus')); const cr = await inject('POST', '/api/classes', { name: 'PresetClass' }, adminToken); assert.ok(cr.status < 300); const cid = db.prepare('SELECT id FROM classes WHERE name = ?').get('PresetClass').id; await inject('POST', `/api/classes/${cid}/members`, { user_id: studentUser.userId }, adminToken); // focus: shop.purchase=0, gamification.challenges=0 const ap = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'focus' }, adminToken); assert.equal(ap.status, 200); assert.ok(ap.body.affected >= 1); const shop = db.prepare('SELECT enabled FROM user_permissions WHERE user_id=? AND permission=?') .get(studentUser.userId, 'shop.purchase'); assert.ok(shop && shop.enabled === 0, 'focus выключил магазин'); // reset: снимает все оверрайды await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'reset' }, adminToken); const left = db.prepare("SELECT COUNT(*) n FROM user_permissions WHERE user_id=?").get(studentUser.userId).n; assert.equal(left, 0, 'reset снял все личные правила'); const badP = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'nope' }, adminToken); assert.equal(badP.status, 400); }); // ── B8: временные права (expires_at) ─────────────────────────────────────── it('B8: временный оверрайд хранит срок, отдаётся, просроченный игнорируется и чистится', async () => { const r = await inject('POST', `/api/permissions/users/${studentUser.userId}`, { permission: 'shop.purchase', enabled: false, days: 7 }, adminToken); assert.equal(r.status, 200); assert.equal(r.body.expires_in_days, 7); const row = db.prepare('SELECT enabled, expires_at FROM user_permissions WHERE user_id=? AND permission=?') .get(studentUser.userId, 'shop.purchase'); assert.ok(row && row.enabled === 0 && row.expires_at, 'оверрайд с expires_at сохранён'); const view = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken); const sp = view.body.permissions.find(p => p.key === 'shop.purchase'); assert.equal(sp.userVal, false, 'активный временный оверрайд виден'); assert.ok(sp.expiresAt, 'expiresAt отдаётся в API'); // имитируем просрочку db.prepare("UPDATE user_permissions SET expires_at = datetime('now','-1 day') WHERE user_id=? AND permission=?") .run(studentUser.userId, 'shop.purchase'); const view2 = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken); const sp2 = view2.body.permissions.find(p => p.key === 'shop.purchase'); assert.equal(sp2.userVal, undefined, 'просроченный оверрайд не учитывается (наследует роль)'); const left = db.prepare("SELECT COUNT(*) n FROM user_permissions WHERE user_id=? AND permission=?") .get(studentUser.userId, 'shop.purchase').n; assert.equal(left, 0, 'просроченная строка вычищена seedDefaults'); }); });