'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 }); } });