test(backend): +31 integration tests for permissions/overview/search/sessions/features
Coverage: - Permissions: role/user toggle + audit + token_version bump, /me, 403 non-admin (10 tests) - Admin overview: shape, all fields, types, auth guard, empty DB zeros (4 tests) - Cmd+K search: shape, min-query empty, SQL injection sanity, user lookup (5 tests) - Session delete: CASCADE, audit entry, 404 missing, 403 non-admin (4 tests) - Feature gates: disabled flag returns 404, enabled returns 401/200, admin API toggle (5 tests) - setup.js: add /api/permissions, /api/pet, /api/biochem routes for test coverage tests 66 (was 35) · pass 63 (was 32) · fail 3 (baseline auth.test.js, unchanged) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: GET /api/admin/overview
|
||||
* Covers: shape, required fields + types, auth guard, empty DB returns zeros.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
describe('Admin Overview', () => {
|
||||
let adminToken, teacherToken;
|
||||
|
||||
before(async () => {
|
||||
const a = await getToken('admin');
|
||||
adminToken = a.token;
|
||||
const t = await getToken('teacher');
|
||||
teacherToken = t.token;
|
||||
});
|
||||
|
||||
// ── 1. 401 without token ──────────────────────────────────────────────────
|
||||
it('returns 401 without auth token', async () => {
|
||||
const res = await inject('GET', '/api/admin/overview', null, null);
|
||||
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 2. 403 for non-admin ──────────────────────────────────────────────────
|
||||
it('returns 403 for teacher (non-admin)', async () => {
|
||||
const res = await inject('GET', '/api/admin/overview', null, teacherToken);
|
||||
assert.equal(res.status, 403, `expected 403, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 3. Returns expected shape ─────────────────────────────────────────────
|
||||
it('returns correct shape with all required fields', async () => {
|
||||
const res = await inject('GET', '/api/admin/overview', null, adminToken);
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
|
||||
const body = res.body;
|
||||
// Numeric fields
|
||||
const numericFields = [
|
||||
'newUsers24h', 'newSessions24h', 'activeUsers24h',
|
||||
'classesTotal', 'abandonedSessions24h',
|
||||
];
|
||||
for (const field of numericFields) {
|
||||
assert.ok(field in body, `missing field: ${field}`);
|
||||
assert.equal(typeof body[field], 'number', `${field} should be a number, got ${typeof body[field]}`);
|
||||
}
|
||||
|
||||
// Array fields
|
||||
const arrayFields = [
|
||||
'stuckSessions', 'bannedThisWeek', 'topSessions24h',
|
||||
'worstSessions24h', 'sessionsBySubject24h',
|
||||
];
|
||||
for (const field of arrayFields) {
|
||||
assert.ok(field in body, `missing array field: ${field}`);
|
||||
assert.ok(Array.isArray(body[field]), `${field} should be an array`);
|
||||
}
|
||||
|
||||
// Sparks object
|
||||
assert.ok('sparks' in body, 'missing sparks field');
|
||||
assert.ok(Array.isArray(body.sparks.users), 'sparks.users should be an array');
|
||||
assert.ok(Array.isArray(body.sparks.sessions), 'sparks.sessions should be an array');
|
||||
assert.ok(Array.isArray(body.sparks.active), 'sparks.active should be an array');
|
||||
|
||||
// Inventory object
|
||||
assert.ok('inventory' in body, 'missing inventory field');
|
||||
assert.ok(typeof body.inventory === 'object', 'inventory should be an object');
|
||||
});
|
||||
|
||||
// ── 4. Empty DB (no sessions) returns zeros, not crashes ─────────────────
|
||||
it('does not crash on empty DB — returns zero counts', async () => {
|
||||
const res = await inject('GET', '/api/admin/overview', null, adminToken);
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||
// counts should be >= 0 (not negative, not NaN, not null)
|
||||
assert.ok(res.body.newSessions24h >= 0, 'newSessions24h should be >= 0');
|
||||
assert.ok(res.body.abandonedSessions24h >= 0, 'abandonedSessions24h should be >= 0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: GET /api/admin/search (Cmd+K palette)
|
||||
* Covers: shape, min-query guard, SQL-injection sanity, auth.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
describe('Admin Search (Cmd+K)', () => {
|
||||
let adminToken, studentToken;
|
||||
|
||||
before(async () => {
|
||||
const a = await getToken('admin');
|
||||
adminToken = a.token;
|
||||
const s = await getToken('student');
|
||||
studentToken = s.token;
|
||||
});
|
||||
|
||||
// ── 1. Auth guard ────────────────────────────────────────────────────────
|
||||
it('returns 401 without auth token', async () => {
|
||||
const res = await inject('GET', '/api/admin/search?q=test', null, null);
|
||||
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
it('returns 403 for student (non-admin)', async () => {
|
||||
const res = await inject('GET', '/api/admin/search?q=test', null, studentToken);
|
||||
assert.equal(res.status, 403, `expected 403, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 2. Short query returns empty arrays ───────────────────────────────────
|
||||
it('q shorter than 2 chars returns empty arrays without error', async () => {
|
||||
const res = await inject('GET', '/api/admin/search?q=x', null, adminToken);
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||
assert.ok(Array.isArray(res.body.users), 'users should be array');
|
||||
assert.ok(Array.isArray(res.body.tests), 'tests should be array');
|
||||
assert.ok(Array.isArray(res.body.classes), 'classes should be array');
|
||||
assert.equal(res.body.users.length, 0, 'users should be empty for q.length < 2');
|
||||
assert.equal(res.body.tests.length, 0, 'tests should be empty for q.length < 2');
|
||||
assert.equal(res.body.classes.length, 0, 'classes should be empty for q.length < 2');
|
||||
});
|
||||
|
||||
it('missing q param returns empty arrays', async () => {
|
||||
const res = await inject('GET', '/api/admin/search', null, adminToken);
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||
assert.equal(res.body.users.length, 0);
|
||||
});
|
||||
|
||||
// ── 3. Valid query returns expected shape ─────────────────────────────────
|
||||
it('valid query returns shape with users, tests, classes arrays', async () => {
|
||||
const res = await inject('GET', '/api/admin/search?q=test', null, adminToken);
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
assert.ok(Array.isArray(res.body.users), 'users must be array');
|
||||
assert.ok(Array.isArray(res.body.tests), 'tests must be array');
|
||||
assert.ok(Array.isArray(res.body.classes), 'classes must be array');
|
||||
});
|
||||
|
||||
// ── 4. SQL injection sanity: parameterized — no crash ────────────────────
|
||||
it('SQL injection in q does not crash the server', async () => {
|
||||
const injected = encodeURIComponent("%' OR 1=1 --");
|
||||
const res = await inject('GET', `/api/admin/search?q=${injected}`, null, adminToken);
|
||||
// Must not 500 — should return 200 with arrays (possibly empty)
|
||||
assert.ok(res.status < 500, `server crashed with SQL injection input: status ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
assert.ok(Array.isArray(res.body.users), 'users should still be array');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 5. Search finds existing user ────────────────────────────────────────
|
||||
it('search finds a registered user by partial name', async () => {
|
||||
// Create a user with a unique name we can search
|
||||
const uniqueName = `Findable-${Date.now()}`;
|
||||
const reg = await inject('POST', '/api/auth/register', {
|
||||
email: `findable_${Date.now()}@search.test`,
|
||||
password: 'password123',
|
||||
name: uniqueName,
|
||||
});
|
||||
assert.equal(reg.status, 201);
|
||||
|
||||
const q = encodeURIComponent(uniqueName.slice(0, 8));
|
||||
const res = await inject('GET', `/api/admin/search?q=${q}`, null, adminToken);
|
||||
assert.equal(res.status, 200);
|
||||
const found = res.body.users.find(u => u.name === uniqueName);
|
||||
assert.ok(found, `Expected to find user "${uniqueName}" in search results`);
|
||||
assert.ok('id' in found, 'user result should have id');
|
||||
assert.ok('email' in found, 'user result should have email');
|
||||
assert.ok('role' in found, 'user result should have role');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: DELETE /api/admin/sessions/:id
|
||||
* Covers: session removal + cascade, audit entry, 404 for missing, 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('Admin Session Delete', () => {
|
||||
let adminToken, studentUser, subjectId, sessionId;
|
||||
|
||||
before(async () => {
|
||||
const a = await getToken('admin');
|
||||
adminToken = a.token;
|
||||
|
||||
const s = await getToken('student');
|
||||
studentUser = s;
|
||||
|
||||
// Ensure subject exists
|
||||
let subj = db.prepare("SELECT id FROM subjects WHERE slug = 'admin-sess-test'").get();
|
||||
if (!subj) {
|
||||
db.prepare("INSERT INTO subjects (slug, name, icon) VALUES ('admin-sess-test', 'Admin Sess Test', 'test')").run();
|
||||
subj = db.prepare("SELECT id FROM subjects WHERE slug = 'admin-sess-test'").get();
|
||||
}
|
||||
subjectId = subj.id;
|
||||
|
||||
// Create a question with options
|
||||
const qId = db.prepare(
|
||||
'INSERT INTO questions (subject_id, text, difficulty) VALUES (?, ?, 1)'
|
||||
).run(subjectId, 'Admin session delete test question').lastInsertRowid;
|
||||
db.prepare('INSERT INTO options (question_id, text, is_correct) VALUES (?, ?, 0)').run(qId, 'Wrong');
|
||||
db.prepare('INSERT INTO options (question_id, text, is_correct) VALUES (?, ?, 1)').run(qId, 'Correct');
|
||||
|
||||
// Start a session via API
|
||||
const sessRes = await inject('POST', '/api/sessions', {
|
||||
subject_slug: 'admin-sess-test', count: 1, mode: 'exam',
|
||||
}, studentUser.token);
|
||||
assert.equal(sessRes.status, 201, `session create failed: ${JSON.stringify(sessRes.body)}`);
|
||||
sessionId = sessRes.body.session_id;
|
||||
|
||||
// Answer to create user_answers row
|
||||
const q = sessRes.body.questions[0];
|
||||
await inject('POST', `/api/sessions/${sessionId}/answer`, {
|
||||
question_id: q.id, option_id: q.options[0].id,
|
||||
}, studentUser.token);
|
||||
});
|
||||
|
||||
// ── 1. Non-admin gets 403 ────────────────────────────────────────────────
|
||||
it('student gets 403 when trying to delete a session', async () => {
|
||||
const res = await inject('DELETE', `/api/admin/sessions/${sessionId}`, null, studentUser.token);
|
||||
assert.equal(res.status, 403, `expected 403, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 2. 404 for non-existent session ─────────────────────────────────────
|
||||
it('returns 404 for non-existent session id', async () => {
|
||||
const res = await inject('DELETE', '/api/admin/sessions/999999', null, adminToken);
|
||||
assert.equal(res.status, 404, `expected 404, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
});
|
||||
|
||||
// ── 3. Session delete cascades user_answers and session_questions ─────────
|
||||
it('deletes session and cascades related rows', async () => {
|
||||
// Verify rows exist before delete
|
||||
const answersBefore = db.prepare('SELECT COUNT(*) AS n FROM user_answers WHERE session_id = ?')
|
||||
.get(sessionId).n;
|
||||
const sqBefore = db.prepare('SELECT COUNT(*) AS n FROM session_questions WHERE session_id = ?')
|
||||
.get(sessionId).n;
|
||||
|
||||
// The session must still exist
|
||||
const sessBefore = db.prepare('SELECT id FROM test_sessions WHERE id = ?').get(sessionId);
|
||||
assert.ok(sessBefore, 'session should exist before delete');
|
||||
|
||||
const res = await inject('DELETE', `/api/admin/sessions/${sessionId}`, null, adminToken);
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
assert.equal(res.body.ok, true);
|
||||
|
||||
// Session gone
|
||||
const sessAfter = db.prepare('SELECT id FROM test_sessions WHERE id = ?').get(sessionId);
|
||||
assert.equal(sessAfter, undefined, 'session should be deleted');
|
||||
|
||||
// user_answers gone
|
||||
const answersAfter = db.prepare('SELECT COUNT(*) AS n FROM user_answers WHERE session_id = ?')
|
||||
.get(sessionId).n;
|
||||
assert.equal(answersAfter, 0, 'user_answers should be cleared after session delete');
|
||||
|
||||
// session_questions gone
|
||||
const sqAfter = db.prepare('SELECT COUNT(*) AS n FROM session_questions WHERE session_id = ?')
|
||||
.get(sessionId).n;
|
||||
assert.equal(sqAfter, 0, 'session_questions should be cleared after session delete');
|
||||
});
|
||||
|
||||
// ── 4. Audit entry created on delete ─────────────────────────────────────
|
||||
it('creates audit log entry on session delete', async () => {
|
||||
// Create and immediately delete another session
|
||||
const sessRes = await inject('POST', '/api/sessions', {
|
||||
subject_slug: 'admin-sess-test', count: 1, mode: 'practice',
|
||||
}, studentUser.token);
|
||||
assert.equal(sessRes.status, 201);
|
||||
const newSessId = sessRes.body.session_id;
|
||||
|
||||
const countBefore = db.prepare(
|
||||
"SELECT COUNT(*) AS n FROM admin_audit_log WHERE action = 'session.delete'"
|
||||
).get().n;
|
||||
|
||||
await inject('DELETE', `/api/admin/sessions/${newSessId}`, null, adminToken);
|
||||
|
||||
const countAfter = db.prepare(
|
||||
"SELECT COUNT(*) AS n FROM admin_audit_log WHERE action = 'session.delete'"
|
||||
).get().n;
|
||||
assert.ok(countAfter > countBefore, 'Expected session.delete audit entry to be created');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: Feature flag gates (requireFeature middleware).
|
||||
* Toggles app_settings directly in DB. Checks that disabled feature = 404,
|
||||
* enabled feature = 401 (route exists, auth required, not feature-blocked).
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { db, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
/**
|
||||
* Helper: set a feature flag in app_settings.
|
||||
* @param {string} name e.g. 'pet', 'biochem'
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
function setFeature(name, enabled) {
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"
|
||||
).run(`feature_${name}_enabled`, enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
describe('Feature Flag Gates', () => {
|
||||
let adminToken;
|
||||
|
||||
before(async () => {
|
||||
const a = await getToken('admin');
|
||||
adminToken = a.token;
|
||||
});
|
||||
|
||||
// ── 1. /api/pet disabled → 404 ───────────────────────────────────────────
|
||||
it('GET /api/pet returns 404 when pet feature is disabled', async () => {
|
||||
setFeature('pet', false);
|
||||
const res = await inject('GET', '/api/pet', null, adminToken);
|
||||
assert.equal(res.status, 404, `expected 404 when pet disabled, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 2. /api/pet enabled → 401 (route works, auth required, not feature-blocked)
|
||||
it('GET /api/pet returns 401 (not 404) when pet feature is enabled and no token', async () => {
|
||||
setFeature('pet', true);
|
||||
const res = await inject('GET', '/api/pet', null, null);
|
||||
// Feature not disabled, so middleware passes. Auth fails → 401.
|
||||
assert.equal(res.status, 401, `expected 401 when pet enabled but no token, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 3. /api/biochem/elements disabled → 404 ─────────────────────────────
|
||||
it('GET /api/biochem/elements returns 404 when biochem feature is disabled', async () => {
|
||||
setFeature('biochem', false);
|
||||
const res = await inject('GET', '/api/biochem/elements', null, adminToken);
|
||||
assert.equal(res.status, 404, `expected 404 when biochem disabled, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 4. /api/biochem/elements enabled → 200 (authenticated admin)
|
||||
it('GET /api/biochem/elements is accessible when biochem feature is enabled', async () => {
|
||||
setFeature('biochem', true);
|
||||
const res = await inject('GET', '/api/biochem/elements', null, adminToken);
|
||||
// Feature not disabled; admin is authenticated → should get data (200) not feature-block (404)
|
||||
assert.notEqual(res.status, 404, `expected feature to be accessible (not 404), got ${res.status}`);
|
||||
assert.ok(res.status < 500, `unexpected server error: ${res.status}`);
|
||||
});
|
||||
|
||||
// ── 5. Feature toggle via admin API also works ────────────────────────────
|
||||
it('admin PATCH /api/admin/features can disable pet, then re-enable', async () => {
|
||||
// Enable first
|
||||
const enableRes = await inject('PATCH', '/api/admin/features', { pet: true }, adminToken);
|
||||
assert.equal(enableRes.status, 200, `expected 200, got ${enableRes.status}`);
|
||||
|
||||
// Check feature is accessible (auth required, no token → 401, not 404)
|
||||
const accessible = await inject('GET', '/api/pet', null, null);
|
||||
assert.equal(accessible.status, 401, `expected 401 (feature on), got ${accessible.status}`);
|
||||
|
||||
// Disable via admin API
|
||||
const disableRes = await inject('PATCH', '/api/admin/features', { pet: false }, adminToken);
|
||||
assert.equal(disableRes.status, 200);
|
||||
|
||||
// Now should be 404
|
||||
const blocked = await inject('GET', '/api/pet', null, adminToken);
|
||||
assert.equal(blocked.status, 404, `expected 404 after disabling pet, got ${blocked.status}`);
|
||||
|
||||
// Re-enable for other tests
|
||||
await inject('PATCH', '/api/admin/features', { pet: true }, adminToken);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
'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. Admin can toggle role-level permission + token_version bumped ───────
|
||||
it('admin toggles role permission and student token_version is 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', {
|
||||
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);
|
||||
|
||||
const tvAfter = db.prepare('SELECT token_version FROM users WHERE id = ?')
|
||||
.get(studentUser.userId).token_version;
|
||||
assert.ok(tvAfter > tvBefore, `token_version should increase (was ${tvBefore}, got ${tvAfter})`);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,14 @@ app.use('/api/admin', require('../src/routes/admin'));
|
||||
app.use('/api/subjects', require('../src/routes/subjects'));
|
||||
app.use('/api/questions', require('../src/routes/questions'));
|
||||
|
||||
// Additional routes for integration tests
|
||||
app.use('/api/permissions', require('../src/routes/permissions'));
|
||||
|
||||
// Feature-gated routes (requireFeature checks app_settings in DB)
|
||||
const { requireFeature } = require('../src/middleware/features');
|
||||
app.use('/api/pet', requireFeature('pet'), require('../src/routes/pet'));
|
||||
app.use('/api/biochem', requireFeature('biochem'), require('../src/routes/biochem'));
|
||||
|
||||
// Error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
res.status(err.status || 500).json({ error: err.message || 'Server error' });
|
||||
|
||||
Reference in New Issue
Block a user