Redesign admin peering settings and catalogs
This commit is contained in:
1 parent
492477f86a
commit
da40d73f02
16 files changed
+1037
-85
No files matched your search
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// Disposable, two-project Compose acceptance. No live library, db or queue is mounted.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const fsp = fs.promises;
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { PeeringState } = require('../peering/state');
|
||||
const { Canvas } = require('./png');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const APP_IMAGE = process.env.PEER_AB_IMAGE || `mangacircus:peer-ab-${crypto.randomBytes(3).toString('hex')}`;
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'mangacircus-peer-ab-'));
|
||||
const projects = ['mc-peer-a-' + crypto.randomBytes(3).toString('hex'), 'mc-peer-b-' + crypto.randomBytes(3).toString('hex')];
|
||||
const OPENSSL_BIN = process.env.OPENSSL_BIN || (process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe' : 'openssl');
|
||||
process.env.OPENSSL_BIN = OPENSSL_BIN;
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function freePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => server.once('error', reject).listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
function command(bin, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { cwd: ROOT, windowsHide: true, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.on('data', data => stdout += data);
|
||||
child.stderr.on('data', data => stderr += data);
|
||||
child.on('error', reject);
|
||||
child.once('exit', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(`${bin} ${args[0]} failed (${code}): ${stderr.slice(-4000)}`)));
|
||||
});
|
||||
}
|
||||
function composeArgs(project) { return ['compose', '-p', project, '-f', path.join(ROOT, 'docker-compose.yml'), '--profile', 'peering']; }
|
||||
async function docker(project, env, ...args) { return command('docker', [...composeArgs(project), ...args], { env }); }
|
||||
async function until(check, message, attempts = 120) {
|
||||
let last;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try { if (await check()) return; } catch (error) { last = error; }
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`${message}${last ? `: ${last.message}` : ''}`);
|
||||
}
|
||||
async function setup(label, readerPort, peerPort) {
|
||||
const root = path.join(TEST_ROOT, label), library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||
const scraper = path.join(data, 'scraper'), sentinel = path.join(data, 'sentinel'), peerData = path.join(data, 'peering');
|
||||
const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token');
|
||||
for (const dir of [library, scraper, sentinel, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
|
||||
await fsp.writeFile(path.join(scraper, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
|
||||
await fsp.writeFile(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
|
||||
const png = new Canvas(180, 270, [55, 105, 150]).png();
|
||||
const manga = `${label === 'a' ? 'Peer A' : 'Peer B'} Test Manga`;
|
||||
await fsp.writeFile(path.join(library, '.fixture'), '');
|
||||
const mangaDir = path.join(library, manga);
|
||||
await fsp.mkdir(mangaDir);
|
||||
await fsp.writeFile(path.join(mangaDir, 'cover.png'), png);
|
||||
await fsp.writeFile(path.join(mangaDir, 'manga.json'), JSON.stringify({ title: manga, year: 2024,
|
||||
status: 'ongoing', description: 'A disposable peer test title.', tags: ['Fixture', 'Peering'], genres: ['Test'], direction: 'ttb' }));
|
||||
for (let n = 1; n <= 2; n++) {
|
||||
const chapter = path.join(mangaDir, 'v01', `c${String(n).padStart(4, '0')} Chapter ${n}`);
|
||||
await fsp.mkdir(chapter, { recursive: true });
|
||||
await fsp.writeFile(path.join(chapter, 'chapter.json'), JSON.stringify({ title: `Chapter ${n}`, source: { name: 'LocalFixture' } }));
|
||||
await fsp.writeFile(path.join(chapter, '0001.png'), png);
|
||||
}
|
||||
new PeeringState(peerData).generateIdentity(label === 'a' ? 'PeerA' : 'PeerB');
|
||||
const env = { ...process.env, APP_IMAGE, VCS_REF: 'peer-redesign-test', LIBRARY_PATH: library, DATA_PATH: data,
|
||||
SCRAPER_DATA_PATH: scraper, SENTINEL_DATA_PATH: sentinel, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
|
||||
SCRAPER_TOKEN_PATH: token, READER_PORT: String(readerPort), READER_BIND: '127.0.0.1',
|
||||
PEER_PORT: String(peerPort), PEER_BIND: '127.0.0.1', COOKIE_SECURE: '0', TZ: 'UTC' };
|
||||
return { label, root, library, data, peerData, manga, readerPort, peerPort, env, url: `http://127.0.0.1:${readerPort}` };
|
||||
}
|
||||
async function json(url, cookie, body) {
|
||||
const response = await fetch(url, { headers: { ...(cookie ? { cookie } : {}), 'content-type': 'application/json' },
|
||||
...(body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }) });
|
||||
const data = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.arrayBuffer();
|
||||
return { response, data };
|
||||
}
|
||||
async function login(instance) {
|
||||
const { response } = await json(`${instance.url}/api/signup`, null, { name: 'qa', password: 'DisposablePeerTest42' });
|
||||
assert.equal(response.status, 200, `${instance.label}: first admin signup`);
|
||||
return response.headers.get('set-cookie').split(';')[0];
|
||||
}
|
||||
async function browserCheck(instance, cookie, peerId, shareId) {
|
||||
const { chromium } = require('playwright');
|
||||
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url: instance.url }]);
|
||||
const page = await context.newPage(), errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`${instance.url}/peer-catalog.html?peer=${encodeURIComponent(peerId)}#browse`);
|
||||
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
|
||||
await page.locator('.peer-catalog-card').filter({ hasText: 'Peer A Test Manga' }).waitFor();
|
||||
assert.equal(await page.locator('.peer-cover').count(), 1, 'Remote cover must render in the catalog');
|
||||
await page.getByRole('button', { name: 'Details', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByText('A disposable peer test title.').waitFor();
|
||||
assert.equal(await dialog.locator('.peer-chapter-option').count(), 2);
|
||||
await dialog.locator('.peer-chapter-picker summary').click();
|
||||
await dialog.getByLabel('Queue scope').selectOption('after');
|
||||
await dialog.getByLabel('Chapter threshold').waitFor({ state: 'visible' });
|
||||
const exactQueue = dialog.getByRole('button', { name: 'Queue checked chapters' });
|
||||
assert.equal(await exactQueue.isDisabled(), true, 'Exact chapter queue starts disabled');
|
||||
await dialog.getByLabel('Select chapter 1').check();
|
||||
assert.equal(await exactQueue.isDisabled(), false);
|
||||
await dialog.getByLabel('Select chapter 1').uncheck();
|
||||
await dialog.getByRole('button', { name: 'Close details' }).click();
|
||||
await page.getByRole('checkbox', { name: 'Select Peer A Test Manga' }).check();
|
||||
await page.getByRole('button', { name: 'Queue 1 missing' }).click();
|
||||
await page.getByText(/1 manga queued; 0 unavailable or failed/).waitFor();
|
||||
assert.equal(errors.length, 0, `Peer catalog browser errors: ${errors.join('; ')}`);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let started = [], succeeded = false;
|
||||
try {
|
||||
const ports = [];
|
||||
while (ports.length < 4) { const value = await freePort(); if (!ports.includes(value)) ports.push(value); }
|
||||
const [portA, portB, gateA, gateB] = ports;
|
||||
const A = await setup('a', portA, gateA), B = await setup('b', portB, gateB);
|
||||
const args = ['build', '-t', APP_IMAGE, '--build-arg', 'VCS_REF=peer-redesign-test', '.'];
|
||||
await command('docker', args);
|
||||
for (const [index, instance] of [A, B].entries()) {
|
||||
const project = projects[index];
|
||||
await docker(project, instance.env, 'config', '-q');
|
||||
await docker(project, instance.env, 'up', '-d'); started.push({ project, env: instance.env });
|
||||
await until(async () => (await fetch(`${instance.url}/health`)).ok, `${instance.label}: reader health`);
|
||||
await until(async () => new Promise(resolve => {
|
||||
const https = require('node:https');
|
||||
https.get({ hostname: '127.0.0.1', port: instance.peerPort, path: '/health', rejectUnauthorized: false }, res => { res.resume(); resolve(res.statusCode === 200); }).on('error', () => resolve(false));
|
||||
}), `${instance.label}: peer gateway health`);
|
||||
}
|
||||
const cookieA = await login(A), cookieB = await login(B);
|
||||
const stateA = (await json(`${A.url}/api/admin/peering`, cookieA)).data;
|
||||
const stateB = (await json(`${B.url}/api/admin/peering`, cookieB)).data;
|
||||
const hostUrlA = `https://host.docker.internal:${gateA}/`, hostUrlB = `https://host.docker.internal:${gateB}/`;
|
||||
const inviteA = (await json(`${A.url}/api/admin/peering`, cookieA, { command: 'invite', url: hostUrlA })).data.invitation;
|
||||
const inviteB = (await json(`${B.url}/api/admin/peering`, cookieB, { command: 'invite', url: hostUrlB })).data.invitation;
|
||||
let importedA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'import', invitation: inviteB });
|
||||
let importedB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'import', invitation: inviteA });
|
||||
assert.equal(importedA.response.status, 200); assert.equal(importedB.response.status, 200);
|
||||
const peerA = importedA.data.peers.find(peer => peer.fingerprint === inviteB.fingerprint);
|
||||
const peerB = importedB.data.peers.find(peer => peer.fingerprint === inviteA.fingerprint);
|
||||
assert.ok(peerA && peerB);
|
||||
const probeA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'probe', id: peerA.id });
|
||||
const probeB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'probe', id: peerB.id });
|
||||
if (probeA.response.status !== 200 || probeB.response.status !== 200) {
|
||||
console.error('Peer probe responses:', JSON.stringify({ a: probeA.data, b: probeB.data }));
|
||||
for (const [index, instance] of [A, B].entries()) {
|
||||
console.error(`${instance.label} gateway logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'peer-gateway').catch(() => '(unavailable)')}`);
|
||||
console.error(`${instance.label} reader logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'reader').catch(() => '(unavailable)')}`);
|
||||
}
|
||||
}
|
||||
assert.equal(probeA.response.status, 200); assert.equal(probeB.response.status, 200);
|
||||
assert.equal(probeA.data.peers.find(peer => peer.id === peerA.id).paired, true);
|
||||
assert.equal(probeB.data.peers.find(peer => peer.id === peerB.id).paired, true);
|
||||
assert.ok(stateA.identity?.fingerprint && stateB.identity?.fingerprint);
|
||||
|
||||
let libraryA = [];
|
||||
await until(async () => { libraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || []; return libraryA.some(item => item.title === A.manga); }, 'A fixture manga was not scanned');
|
||||
const latestLibraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || libraryA;
|
||||
const mangaA = latestLibraryA.find(item => item.title === A.manga);
|
||||
assert.ok(mangaA, 'A fixture manga is indexed');
|
||||
let saved = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'share-set', id: peerA.id, mangaIds: [mangaA.id] });
|
||||
assert.equal(saved.response.status, 200); assert.equal(saved.data.peers.find(peer => peer.id === peerA.id).shares.length, 1);
|
||||
const grant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'grant', id: peerA.id, scopes: ['catalog'] });
|
||||
assert.equal(grant.response.status, 200);
|
||||
assert.equal((await json(`${B.url}/api/admin/peering`, cookieB, { command: 'accept-grant', id: peerB.id, token: grant.data.grant.token })).response.status, 200);
|
||||
const catalog = await json(`${B.url}/api/peering/catalog?peer=${encodeURIComponent(peerB.id)}`, cookieB);
|
||||
assert.equal(catalog.response.status, 200); assert.equal(catalog.data.items.length, 1);
|
||||
const shareId = catalog.data.items[0].id;
|
||||
const cover = await fetch(`${B.url}/api/peering/cover?peer=${encodeURIComponent(peerB.id)}&manga=${shareId}`, { headers: { cookie: cookieB } });
|
||||
assert.equal(cover.status, 200); assert.match(cover.headers.get('content-type'), /^image\/png/);
|
||||
const denied = await json(`${B.url}/api/peering/queue`, cookieB, { peer: peerB.id, manga: shareId, scope: { mode: 'missing' } });
|
||||
assert.equal(denied.response.status, 403, 'Catalog grant alone must not permit chapter queueing');
|
||||
const updateGrant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'set', id: peerA.id, patch: { grantScopes: ['download'] } });
|
||||
assert.equal(updateGrant.response.status, 200);
|
||||
const accessB = await json(`${B.url}/api/peering/access?peer=${encodeURIComponent(peerB.id)}`, cookieB);
|
||||
assert.deepEqual(accessB.data.grant.scopes, ['catalog', 'download'], 'Grant scope updates must not need token reimport');
|
||||
const receiving = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'set', id: peerB.id, patch: { receive: true } });
|
||||
assert.equal(receiving.response.status, 200);
|
||||
await browserCheck(B, cookieB, peerB.id, shareId);
|
||||
const queue = (await json(`${B.url}/api/admin/scraper`, cookieB)).data;
|
||||
assert.equal(queue.paused, true, 'Peer testing must leave the scraper queue paused');
|
||||
assert.equal(queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`).length, 1, 'Exactly one peer queue item is added');
|
||||
const volumes = await Promise.all(started.map(({ project, env }) => docker(project, env, 'ps', '--format', '{{.Service}}')));
|
||||
for (const services of volumes) for (const name of ['reader', 'scraper', 'sentinel', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
|
||||
console.log('PASS: isolated Compose A↔B pairing, catalog-only access, cover proxy, grant update, browser details/bulk queue, one paused queue writer, and admin-only API/UI boundary.');
|
||||
console.log(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
|
||||
succeeded = true;
|
||||
} finally {
|
||||
if (!succeeded && process.argv.includes('--keep-on-failure')) console.error(`Failure fixtures retained for cleanup: ${TEST_ROOT}; projects: ${projects.join(', ')}`);
|
||||
else {
|
||||
for (const { project, env } of [...started].reverse()) await docker(project, env, 'down').catch(error => console.error(`Cleanup warning for ${project}: ${error.message}`));
|
||||
await fsp.rm(TEST_ROOT, { recursive: true, force: true });
|
||||
if (!process.env.PEER_AB_IMAGE) await command('docker', ['image', 'rm', APP_IMAGE]).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user