'use strict'; // BL-0053: Greatest covers are served from the reader's own copy, with a redirect until one exists. const test = require('node:test'), assert = require('node:assert/strict'); const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'), crypto = require('crypto'); const { spawn } = require('child_process'); const { loadCatalog } = require('../greatest'); const { GreatestCovers } = require('../greatest/covers'); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const hashPw = pw => { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); }; const sha = s => crypto.createHash('sha256').update(s).digest('hex'); const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); 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('Greatest covers come from the local copy, else redirect to the catalog URL', async t => { const entries = loadCatalog().entries.filter(entry => entry.cover); const [cached, missing] = entries; const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-greatest-cover-')); const library = path.join(root, 'library'), data = path.join(root, 'data'); fs.mkdirSync(library, { recursive: true }); fs.mkdirSync(path.join(data, 'greatest-covers'), { recursive: true }); const version = new GreatestCovers(path.join(data, 'greatest-covers')).version(cached.cover); fs.writeFileSync(path.join(data, 'greatest-covers', `${version}.png`), PNG); const token = crypto.randomBytes(32).toString('base64url'); fs.writeFileSync(path.join(data, 'db.json'), JSON.stringify({ users: { reader: { name: 'reader', hash: hashPw('reader-password'), admin: false, created: Date.now(), settings: {} } }, sessions: { [sha(token)]: { u: 'reader', t: Date.now() } } })); const port = await freePort(), output = []; const 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))); t.after(async () => { await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); }); fs.rmSync(root, { recursive: true, force: true }); }); 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 sleep(50); } assert.ok(ready, output.join('')); const get = (url, cookie = `sid=${token}`) => fetch(base + '/' + url, { headers: cookie ? { cookie } : {}, redirect: 'manual' }); const listing = await (await get('api/greatest')).json(); const coverOf = id => listing.entries.find(entry => entry.id === id).cover; assert.equal(coverOf(cached.id), `api/greatest-cover?id=${encodeURIComponent(cached.id)}&v=${version}`); assert.match(coverOf(missing.id), /^api\/greatest-cover\?id=.+&v=[0-9a-f]{16}$/); const hit = await get(coverOf(cached.id)); assert.equal(hit.status, 200); assert.equal(hit.headers.get('content-type'), 'image/png'); assert.equal(hit.headers.get('cache-control'), 'private, max-age=31536000, immutable'); assert.deepEqual(Buffer.from(await hit.arrayBuffer()), PNG); const miss = await get(coverOf(missing.id)); assert.equal(miss.status, 302); assert.equal(miss.headers.get('location'), missing.cover); assert.equal(miss.headers.get('cache-control'), 'no-store'); assert.equal((await get(coverOf(cached.id), null)).status, 401); assert.equal((await get('api/greatest-cover?id=not-in-the-catalog')).status, 404); assert.equal((await get('api/greatest-cover?id=../../db.json')).status, 404); });