Files
MangaCircus/test/admin-role.test.js
T
MaashkinoozheandClaude Opus 5.5 e9da27e688 Greatest: serve catalog covers from a local copy (BL-0053)
The page hotlinked ~100 AniList CDN covers per load and some failed at random.
The reader now downloads each catalog cover once (sequential, ~1/s, stops on
401/403/429, images up to 2 MB) into data/greatest-covers/ and serves it from
/api/greatest-cover with a year-long browser cache; until a copy exists the
route redirects to the catalog URL. The page also accepts the server's
relative cover URLs, so owned titles show their library cover again.
GREATEST_COVER_WARM=0 turns the downloads off (tests, offline installs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:38:55 +01:00

89 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: '',
GREATEST_COVER_WARM: '0' } });
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 });
}
});