#!/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'), peerData = path.join(data, 'peering'); const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token'); for (const dir of [library, scraper, 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, 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 peerSettingsUiCheck(instance, cookie, options = {}) { 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); await page.locator('.nav[data-peer-catalog-resolved="true"]').waitFor(); assert.equal(await page.locator('.nav a[href="peer-catalog.html"]').count(), options.hasPeer ? 1 : 0, 'Peer catalogs nav appears only after a peer is configured'); if (options.peerId) { await page.goto(`${instance.url}/peers.html#admin/peering/${encodeURIComponent(options.peerId)}`); await page.getByRole('heading', { name: 'Access between servers' }).waitFor(); const issue = page.getByRole('button', { name: options.outboundGrant ? 'Save grant permissions' : 'Issue grant', exact: true }); if (options.paired) { await page.getByText('Peering confirmed on both servers', { exact: true }).waitFor(); if (!options.outboundGrant) await page.getByLabel('Browse approved catalog').check(); assert.equal(await issue.isDisabled(), false, 'A selected grant permission enables the grant action after pairing'); } else { await page.getByText('Identity not verified yet', { exact: true }).waitFor(); await page.getByLabel('Browse approved catalog').check(); await page.getByLabel('Download chapters (includes catalog access)').check(); assert.equal(await issue.isDisabled(), true, 'Grant issue remains locked before identity verification'); await page.getByText(/Granting is locked until identity pairing is complete/).waitFor(); } if (options.outboundGrant) await page.getByText('Grant active', { exact: true }).waitFor(); if (options.inboundGrant) await page.getByText('Active grant from peer', { exact: true }).waitFor(); if (options.paired) { const stateCard = page.locator('.peer-state-ok').first(); const visual = await stateCard.evaluate(element => { const style = getComputedStyle(element); return { weight: getComputedStyle(element.querySelector('strong')).fontWeight, border: style.borderLeftWidth, background: style.backgroundColor }; }); assert.ok(Number(visual.weight) >= 700 && visual.border === '4px' && visual.background !== 'rgba(0, 0, 0, 0)', `Confirmed state must be visually prominent: ${JSON.stringify(visual)}`); } } assert.equal(errors.length, 0, `Peer settings 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); await peerSettingsUiCheck(A, cookieA, { hasPeer: false }); await peerSettingsUiCheck(B, cookieB, { hasPeer: false }); 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); await peerSettingsUiCheck(B, cookieB, { hasPeer: true, peerId: peerB.id, paired: false }); 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); await peerSettingsUiCheck(A, cookieA, { hasPeer: true, peerId: peerA.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); await peerSettingsUiCheck(A, cookieA, { hasPeer: true, peerId: peerA.id, paired: true, outboundGrant: true }); await peerSettingsUiCheck(B, cookieB, { hasPeer: true, peerId: peerB.id, paired: true, inboundGrant: true }); 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); assert.equal(catalog.data.items[0].workIdentity?.scheme, 'mangacircus-work-v1', 'Peer catalogs expose a stable, opaque work identity'); 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'); const peerQueueItems = queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`); assert.equal(peerQueueItems.length, 1, 'Exactly one peer queue item is added'); assert.deepEqual(peerQueueItems[0].workIdentity, catalog.data.items[0].workIdentity, 'The resolved work identity is persisted on the queued peer transfer'); 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', '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; });