Files
Learn_System/backend/tests/permissions.test.js
T
Maxim Dolgolyov 6bd1532735 feat(permissions): A4 — убрать role-level token_version bump (нет массового разлогина)
requirePermission читает права из БД на каждый запрос → серверное применение
живое. Прежний bump token_version при role-level изменении разлогинивал ВСЕХ
пользователей роли из-за одного тумблера. Убрали его: изменение применяется
сразу на сервере, клиент подхватит при следующем /permissions/me. User-level
bump оставлен (точечно одному пользователю — целевое обновление, не массовое).
Тест 3 обновлён: role-level НЕ бампает token_version + значение сохраняется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:17:32 +03:00

213 lines
11 KiB
JavaScript

'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);
});
// ── 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, 'не-админу недоступно');
});
});