feat(permissions): C-4a — API конструктора ролей (/api/roles, admin)
rolesController + routes/roles (admin, inline guards): GET список (с числом пользователей), POST создать кастомную роль (имя-идентификатор + метка + base_roles; засев прав из функциональной базы), PUT изменить, DELETE удалить (пользователей возвращает на базу), GET /:name/permissions (эффективная карта база+оверлей + defs). setPermission теперь принимает кастомные роли (ключ валидируется по базе, хранится под именем роли). Смонтировано в server.js + тест-харнесс. Тест roles-api 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Phase C, C-4a — API конструктора ролей (/api/roles, admin).
|
||||
* Имя роли — латинский идентификатор (sanitize), метка — любая. Создание засевает
|
||||
* права из функциональной базы; setPermission принимает кастомные роли (ключ по базе);
|
||||
* удаление возвращает пользователей на базу; встроенные роли защищены; не-админу 403.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { db, getToken, inject, cleanup } = require('./setup');
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
describe('roles API (C-4)', () => {
|
||||
let adminToken, studentUid;
|
||||
|
||||
before(async () => {
|
||||
adminToken = (await getToken('admin')).token;
|
||||
studentUid = (await getToken('student')).userId;
|
||||
});
|
||||
|
||||
it('создание кастомной роли (имя санитизируется) + засев прав из базы', async () => {
|
||||
const r = await inject('POST', '/api/roles', { name: 'Curator-1', label: 'Куратор', baseRoles: ['teacher'] }, adminToken);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(r.body.name, 'curator1', 'имя приведено к латинскому идентификатору');
|
||||
|
||||
const list = await inject('GET', '/api/roles', null, adminToken);
|
||||
assert.ok(list.body.some(x => x.name === 'curator1' && !x.isBuiltin && x.baseRoles[0] === 'teacher'));
|
||||
|
||||
const rp = await inject('GET', '/api/roles/curator1/permissions', null, adminToken);
|
||||
assert.equal(rp.status, 200);
|
||||
assert.equal(rp.body.base, 'teacher');
|
||||
assert.ok(rp.body.definitions.length > 0, 'есть определения ключей базы');
|
||||
assert.equal(rp.body.permissions['classes.manage'], true, 'classes.manage засеян из teacher (default 1)');
|
||||
});
|
||||
|
||||
it('конфиг права кастомной роли через setPermission (ключ валиден по базе)', async () => {
|
||||
const r = await inject('POST', '/api/permissions', { role: 'curator1', permission: 'questions.manage', enabled: true }, adminToken);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
const row = db.prepare("SELECT enabled FROM role_permissions WHERE role='curator1' AND permission='questions.manage'").get();
|
||||
assert.ok(row && row.enabled === 1, 'право сохранено под именем кастомной роли');
|
||||
|
||||
const bad = await inject('POST', '/api/permissions', { role: 'curator1', permission: 'tests.free', enabled: true }, adminToken);
|
||||
assert.equal(bad.status, 400, 'tests.free — студенческий ключ, не для teacher-базы');
|
||||
});
|
||||
|
||||
it('удаление роли возвращает пользователей на функциональную базу', async () => {
|
||||
await inject('PATCH', `/api/admin/users/${studentUid}/role`, { role: 'curator1' }, adminToken);
|
||||
let row = db.prepare('SELECT role, custom_role FROM users WHERE id=?').get(studentUid);
|
||||
assert.equal(row.custom_role, 'curator1'); assert.equal(row.role, 'teacher');
|
||||
|
||||
const del = await inject('DELETE', '/api/roles/curator1', null, adminToken);
|
||||
assert.equal(del.status, 200);
|
||||
assert.ok(del.body.reassigned >= 1);
|
||||
row = db.prepare('SELECT role, custom_role FROM users WHERE id=?').get(studentUid);
|
||||
assert.equal(row.custom_role, null, 'custom_role снят');
|
||||
assert.equal(row.role, 'teacher', 'остался на функциональной базе');
|
||||
assert.ok(!db.prepare("SELECT 1 FROM roles WHERE name='curator1'").get(), 'роль удалена');
|
||||
});
|
||||
|
||||
it('встроенную роль нельзя удалить/изменить', async () => {
|
||||
assert.equal((await inject('DELETE', '/api/roles/teacher', null, adminToken)).status, 400);
|
||||
assert.equal((await inject('PUT', '/api/roles/teacher', { label: 'x' }, adminToken)).status, 400);
|
||||
});
|
||||
|
||||
it('не-админу — 403', async () => {
|
||||
const fresh = await getToken('student'); // свежий токен (studentTok мог быть инвалидирован сменой роли)
|
||||
assert.equal((await inject('GET', '/api/roles', null, fresh.token)).status, 403);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ app.use('/api/permissions', require('../src/routes/permissions'));
|
||||
app.use('/api/access', require('../src/routes/access'));
|
||||
app.use('/api/lab', require('../src/routes/lab'));
|
||||
app.use('/api/courses', require('../src/routes/courses'));
|
||||
app.use('/api/roles', require('../src/routes/roles'));
|
||||
|
||||
// Feature-gated routes (requireFeature checks app_settings in DB)
|
||||
const { requireFeature } = require('../src/middleware/features');
|
||||
|
||||
Reference in New Issue
Block a user