Files
MangaCircus/test/admin-role.test.js
MaashkinoozheandClaude Opus 5.5 d6254a8be5 Admins can grant or revoke admin rights in Settings (BL-0045)
New POST /api/admin/user/role {name, admin}: admin-only (checked on the
server), keeps the password and sessions, takes effect on the target's
current session at once, logs who changed whom. Revoking your own
rights is refused, so at least one admin always remains. Settings ->
Users & access shows "Make admin" / "Remove admin" next to every other
user, with a confirmation. Test: normal users and anonymous callers are
refused, input checks, promotion and revocation apply immediately and
are saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:35:04 +02:00

88 lines
5.0 KiB
JavaScript

'use strict';
// BL-0045: admins grant or revoke admin rights in Settings -> Users & access.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
test('only admins can promote or demote, nobody can demote themselves, and changes apply at once', { timeout: 20000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-admin-role-'));
let child;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
fs.mkdirSync(library); fs.mkdirSync(data);
const port = await freePort(), output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) });
const get = (cookie, url) => fetch(base + url, { headers: { cookie } });
const session = r => r.headers.get('set-cookie').split(';')[0];
const first = await post(null, '/api/signup', { name: 'boss', password: 'boss-password' });
assert.equal(first.status, 200);
const boss = session(first);
assert.equal((await post(boss, '/api/admin/user', { name: 'reader', password: 'reader-password' })).status, 200);
const login = await post(null, '/api/login', { name: 'reader', password: 'reader-password' });
assert.equal(login.status, 200);
const reader = session(login);
// a normal user can neither promote themselves nor reach admin pages
assert.equal((await post(reader, '/api/admin/user/role', { name: 'reader', admin: true })).status, 403);
assert.equal((await get(reader, '/api/admin')).status, 403);
assert.equal((await post(null, '/api/admin/user/role', { name: 'reader', admin: true })).status, 401);
// input checks
assert.equal((await post(boss, '/api/admin/user/role', { name: 'nobody', admin: true })).status, 404);
assert.equal((await post(boss, '/api/admin/user/role', { name: 'reader', admin: 'yes' })).status, 400);
assert.equal((await post(boss, '/api/admin/user/role', { name: 'boss', admin: false })).status, 400);
// promotion keeps the password and applies to the existing session immediately
const promoted = await post(boss, '/api/admin/user/role', { name: 'Reader', admin: true });
assert.equal(promoted.status, 200);
assert.deepEqual(await promoted.json(), { ok: true, name: 'reader', admin: true });
assert.equal((await (await get(reader, '/api/me')).json()).admin, true);
assert.equal((await get(reader, '/api/admin')).status, 200);
assert.equal((await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).status, 200);
const users = (await (await get(boss, '/api/admin')).json()).users;
assert.deepEqual(users.map(x => [x.name, x.admin]).sort(), [['boss', true], ['reader', true]]);
// another admin may revoke; the revoked admin loses access at once
assert.equal((await post(reader, '/api/admin/user/role', { name: 'boss', admin: false })).status, 200);
assert.equal((await get(boss, '/api/admin')).status, 403);
assert.equal((await post(boss, '/api/admin/user/role', { name: 'boss', admin: true })).status, 403);
assert.match(output.join(''), /\[users\] reader removed boss as admin/);
// db.json is written shortly after a change (batched writes)
let saved = null;
for (let n = 0; n < 60; n++) {
try { saved = JSON.parse(fs.readFileSync(path.join(data, 'db.json'), 'utf8')); } catch { saved = null; }
if (saved && saved.users.boss && saved.users.boss.admin === false) break;
await new Promise(resolve => setTimeout(resolve, 100));
}
assert.equal(saved.users.boss.admin, false);
assert.equal(saved.users.reader.admin, true);
} finally {
if (child) await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
fs.rmSync(root, { recursive: true, force: true });
}
});