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>
89 lines
5.0 KiB
JavaScript
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 });
|
|
}
|
|
});
|