'use strict'; const test = require('node:test'); 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 net = require('node:net'); const https = require('node:https'); const { spawn } = require('node:child_process'); const { PeeringState } = require('../state'); const client = require('../client'); const ROOT = path.resolve(__dirname, '../..'); const OPENSSL = process.env.OPENSSL_BIN || (process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe' : 'openssl'); const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zr5sAAAAASUVORK5CYII=', 'base64'); async function port() { const server = net.createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const value = server.address().port; await new Promise(resolve => server.close(resolve)); return value; } async function health(value) { return new Promise((resolve, reject) => { https.get({ hostname: '127.0.0.1', port: value, path: '/health', rejectUnauthorized: false }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }).on('error', reject); }); } async function gateway(data, library, value, usage) { const child = spawn(process.execPath, ['peering/gateway.js'], { cwd: ROOT, env: { ...process.env, PEER_DATA: data, LIBRARY: library, PEER_PORT: String(value), PEER_HOST: '127.0.0.1', PEER_USAGE_FILE: usage }, stdio: ['ignore', 'pipe', 'pipe'] }); let error = ''; child.stderr.on('data', chunk => { error += chunk.toString(); }); for (let i = 0; i < 50; i++) { if (child.exitCode !== null) throw new Error(`Gateway exited: ${error}`); try { if (await health(value) === 200) return child; } catch {} await new Promise(resolve => setTimeout(resolve, 100)); } child.kill(); throw new Error(`Gateway failed to start: ${error}`); } async function stop(child) { if (child.exitCode !== null) return; child.kill('SIGTERM'); await new Promise(resolve => { child.once('exit', resolve); setTimeout(resolve, 3000).unref(); }); } test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { timeout: 30000 }, async t => { const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'mangacircus-peering-')); const dataA = path.join(root, 'a-data'), dataB = path.join(root, 'b-data'); const libA = path.join(root, 'a-library'), libB = path.join(root, 'b-library'); await Promise.all([dataA, dataB, libA, libB].map(dir => fsp.mkdir(dir, { recursive: true }))); t.after(async () => { await fsp.rm(root, { recursive: true, force: true }); }); const a = new PeeringState(dataA), b = new PeeringState(dataB); process.env.OPENSSL_BIN = OPENSSL; a.generateIdentity('ServerA'); b.generateIdentity('ServerB'); const beforeInvalidInvite = a.load().invitations.length; assert.throws(() => a.createInvitation('https://localhost:8080/path'), /HTTPS origin/); assert.throws(() => a.createInvitation('localhost:8080'), /HTTPS origin/); assert.equal(a.load().invitations.length, beforeInvalidInvite, 'Invalid URLs must not record unusable invitations'); const portA = await port(), portB = await port(); const inviteA = a.createInvitation(`https://127.0.0.1:${portA}/`); const inviteB = b.createInvitation(`https://127.0.0.1:${portB}/`); const inA = a.importInvitation(inviteB), inB = b.importInvitation(inviteA); assert.equal(inA.paired, false); assert.equal(a.importInvitation(inviteB).id, inA.id, 'Importing one certificate must not duplicate a peer'); assert.throws(() => a.importInvitation({ ...inviteB, fingerprint: '0'.repeat(64) }), /fingerprint mismatch/); assert.throws(() => a.importInvitation({ ...inviteB, expiresAt: new Date(Date.now() - 1000).toISOString() }), /expired/); await assert.rejects(() => client.catalog(b, inB.id), /Verify peer identity first/); const mangaDir = path.join(libA, 'Sample Manga'); const chapterDir = path.join(mangaDir, 'v01', 'c0001 [Local]'); await fsp.mkdir(chapterDir, { recursive: true }); await fsp.writeFile(path.join(mangaDir, 'manga.json'), JSON.stringify({ title: 'Sample Manga', direction: 'ltr' })); await fsp.writeFile(path.join(mangaDir, 'cover.png'), PNG); await fsp.writeFile(path.join(chapterDir, 'chapter.json'), JSON.stringify({ title: 'The first chapter', source: { name: 'Local' }, added: '2026-10-01T08:00:00.000Z', 'x-dates': { version: 1, source: { at: '2026-09-30T15:00:00.000Z', name: 'WeebCentral', batch: false } } })); await fsp.writeFile(path.join(chapterDir, '0001.png'), PNG); const ga = await gateway(dataA, libA, portA, path.join(root, 'usage-a.json')); let gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json')); t.after(async () => { await stop(ga); await stop(gb); }); await client.probe(b, inB.id); await client.probe(a, inA.id); assert.equal(a.publicState().peers[0].paired, true); a.setShare(inA.id, 'Sample Manga', true); const c = new PeeringState(path.join(root, 'c-data')); c.generateIdentity('ServerC'); const inC = a.importInvitation(c.createInvitation('https://third-peer.example:8093/')); assert.equal(a.publicState().peers.length, 2); assert.deepEqual(a.publicState().peers.find(p => p.id === inC.id).shares, []); assert.equal(a.publicState().peers.find(p => p.id === inA.id).shares.length, 1); a.updatePeer(inA.id, { serve: true }); const grant = a.issueGrant(inA.id, ['catalog']); b.acceptGrant(inB.id, grant.token); const listing = await client.catalog(b, inB.id); assert.equal(listing.length, 1); assert.equal(listing[0].title, 'Sample Manga'); assert.equal(listing[0].coverAvailable, true); assert.deepEqual(listing[0].workIdentity, require('../../scraper/lib/work-identity').resolveWorkIdentity({}, 'Sample Manga', a.identity().id)); assert.deepEqual(listing[0].tags, []); assert.equal(JSON.stringify(listing).includes('Sample Manga\\v01'), false); const remote = await client.manga(b, inB.id, listing[0].id); assert.equal(remote.chapters.length, 1); const cover = await client.cover(b, inB.id, listing[0].id); assert.equal(cover.mime, 'image/png'); assert.deepEqual(cover.buffer, PNG, 'Catalog-only grants may browse approved covers'); const remoteAccess = await client.access(b, inB.id); assert.equal(remoteAccess.grant.status, 'active'); assert.deepEqual(remoteAccess.grant.scopes, ['catalog']); assert.throws(() => client.connection(b, inB.id, true, true), /Downloads from this peer are disabled/); b.updatePeer(inB.id, { receive: true }); const manifest = await client.chapter(b, inB.id, listing[0].id, remote.chapters[0].id); assert.equal(manifest.pages.length, 1); assert.equal(manifest.pages[0].sha256, require('node:crypto').createHash('sha256').update(PNG).digest('hex')); await assert.rejects(() => client.downloadPage(b, inB.id, manifest.pages[0], path.join(root, 'denied.png')), /Peer HTTP 403: Grant does not allow download/); a.updatePeer(inA.id, { grantScopes: ['catalog', 'download'] }); assert.deepEqual((await client.access(b, inB.id)).grant.scopes, ['catalog', 'download'], 'Permission edits apply without exchanging a new token'); a.updatePeer(inA.id, { grantScopes: ['download'] }); assert.deepEqual((await client.access(b, inB.id)).grant.scopes, ['catalog', 'download'], 'Download always implies catalog in canonical order'); await assert.rejects(() => client.jsonRequest(b, inB.id, '/peer/v1/manga/m000000000000000000000000'), /Peer HTTP 404/); await assert.rejects(() => client.jsonRequest(b, inB.id, '/peer/v1/catalog', { headers: { authorization: 'Bearer ' + 'x'.repeat(43) } }), /Peer HTTP 401/); const noCert = await new Promise((resolve, reject) => https.get({ hostname: '127.0.0.1', port: portA, path: '/peer/v1/catalog', rejectUnauthorized: false }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }).on('error', reject)); assert.equal(noCert, 401); const page = manifest.pages[0], { agent } = client.connection(b, inB.id); const partial = await new Promise((resolve, reject) => https.get(`https://127.0.0.1:${portA}/peer/v1/blobs/${page.id}`, { agent, headers: { authorization: `Bearer ${b.outbound()[inB.id]}`, range: 'bytes=4-', 'if-match': page.etag } }, res => { const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks), range: res.headers['content-range'] })); }).on('error', reject)); assert.equal(partial.status, 206); assert.equal(partial.range, `bytes 4-${PNG.length - 1}/${PNG.length}`); assert.deepEqual(partial.body, PNG.subarray(4)); const { infoFor, run } = require('../adapter'); process.env.PEER_DATA = dataB; const uri = `peer://${inB.id}/${listing[0].id}`; const info = infoFor(uri); const result = await run(info, { scope: { mode: 'missing' }, existingPolicy: 'skip-any' }, { libraryRoot: libB, settings: { chapterMinMs: 500 }, shouldStop: () => false }); assert.equal(result.publishedCount, 1); assert.deepEqual(JSON.parse(await fsp.readFile(path.join(libB, 'Sample Manga', 'manga.json'), 'utf8'))['x-work-identity'], remote.workIdentity, 'The stable work identity is stored with the imported peer edition'); const receivedDir = path.join(libB, 'Sample Manga', 'v01', 'c0001 The first chapter [Peer ServerA ' + inB.id.slice(1, 7) + ']'); assert.equal(fs.existsSync(path.join(receivedDir, '0001.png')), true); const received = JSON.parse(await fsp.readFile(path.join(receivedDir, 'chapter.json'), 'utf8')); assert.deepEqual(received['x-dates'].ecosystem, { at: '2026-10-01T08:00:00.000Z', via: 'peer ServerA' }, 'The chapter keeps the date it first entered Manga Circus on A, not its arrival on B'); assert.equal(received['x-dates'].source.at, '2026-09-30T15:00:00.000Z'); assert.notEqual(received.added, '2026-10-01T08:00:00.000Z', '"added" is the arrival in B\'s own library'); // B claims it got the chapter later on its own: the next sync lowers it to A's date again. await fsp.writeFile(path.join(receivedDir, 'chapter.json'), JSON.stringify({ ...received, 'x-dates': { ...received['x-dates'], ecosystem: { at: '2026-10-20T00:00:00.000Z', via: 'this server' } } })); const second = await run(info, { scope: { mode: 'missing' }, existingPolicy: 'skip-any' }, { libraryRoot: libB, settings: { chapterMinMs: 500 }, shouldStop: () => false }); assert.equal(second.publishedCount, 0); assert.equal(second.ecosystemLowered, 1, 'An existing chapter adopts the peer\'s earlier ecosystem date'); assert.deepEqual(JSON.parse(await fsp.readFile(path.join(receivedDir, 'chapter.json'), 'utf8'))['x-dates'].ecosystem, { at: '2026-10-01T08:00:00.000Z', via: 'peer ServerA' }); // Simulate B adding one chapter to its imported edition, then sharing it back. // A must identify its original folder by the carried origin identity and fetch // only the new chapter, rather than creating a title-conflict quarantine. const chapterTwo = path.join(libB, 'Sample Manga', 'v01', 'c0002 Chapter from B'); await fsp.mkdir(chapterTwo, { recursive: true }); await fsp.writeFile(path.join(chapterTwo, 'chapter.json'), JSON.stringify({ title: 'Chapter from B', source: { name: 'Local' } })); await fsp.writeFile(path.join(chapterTwo, '0001.png'), PNG); b.setShare(inB.id, 'Sample Manga', true); b.updatePeer(inB.id, { serve: true }); const reverseGrant = b.issueGrant(inB.id, ['download']); a.acceptGrant(inA.id, reverseGrant.token); a.updatePeer(inA.id, { receive: true }); const reverseCatalog = await client.catalog(a, inA.id); assert.equal(reverseCatalog.length, 1); const reverseManga = await client.manga(a, inA.id, reverseCatalog[0].id); assert.deepEqual(reverseManga.workIdentity, remote.workIdentity, 'The original work identity survives a peer round trip'); process.env.PEER_DATA = dataA; const reverseInfo = require('../adapter').infoFor(`peer://${inA.id}/${reverseCatalog[0].id}`); const returned = await require('../adapter').run(reverseInfo, { scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: reverseManga.workIdentity }, { libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false }); assert.equal(returned.finalPath, path.join(libA, 'Sample Manga'), 'The round-trip publication targets the original library folder'); assert.equal(returned.selectedCount, 1, 'Only the chapter added on B is selected for sync-missing'); assert.equal(returned.publishedCount, 1); assert.equal(fs.existsSync(path.join(libA, 'Sample Manga', 'v01', 'c0002 Chapter from B [Peer ServerB ' + inA.id.slice(1, 7) + ']', '0001.png')), true); const noChanges = await require('../adapter').run(reverseInfo, { scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: reverseManga.workIdentity }, { libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false }); assert.equal(noChanges.selectedCount, 0, 'A repeated round trip queues no duplicate chapters'); // Simulate an older B copy which predates persisted identities. Exact shared // page hashes may bridge that legacy gap, but title similarity alone may not. const legacyMetadataPath = path.join(libB, 'Sample Manga', 'manga.json'); const legacyMetadata = JSON.parse(await fsp.readFile(legacyMetadataPath, 'utf8')); delete legacyMetadata['x-work-identity']; await fsp.writeFile(legacyMetadataPath, JSON.stringify(legacyMetadata)); const chapterThree = path.join(libB, 'Sample Manga', 'v01', 'c0003 Chapter from B'); await fsp.mkdir(chapterThree, { recursive: true }); await fsp.writeFile(path.join(chapterThree, 'chapter.json'), JSON.stringify({ title: 'Chapter from B', source: { name: 'Local' } })); await fsp.writeFile(path.join(chapterThree, '0001.png'), PNG); await stop(gb); // Clear the gateway's short-lived manifest cache after this legacy-data migration. gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json')); const legacyManga = await client.manga(a, inA.id, reverseCatalog[0].id); assert.notDeepEqual(legacyManga.workIdentity, remote.workIdentity, 'Legacy copy has no propagated identity and gets a peer-local fallback'); const legacySync = await require('../adapter').run(reverseInfo, { scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: legacyManga.workIdentity }, { libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false }); assert.equal(legacySync.finalPath, path.join(libA, 'Sample Manga'), 'Content evidence safely reuses the original folder for legacy peer copies'); assert.equal(legacySync.selectedCount, 1, 'Legacy matching still selects only the chapter absent on A'); assert.equal(legacySync.publishedCount, 1); assert.equal(fs.existsSync(path.join(libA, 'Sample Manga', 'v01', 'c0003 Chapter from B [Peer ServerB ' + inA.id.slice(1, 7) + ']', '0001.png')), true); for (const book of await fsp.readdir(path.join(libB, 'Sample Manga'))) { const bookPath = path.join(libB, 'Sample Manga', book); if (!(await fsp.stat(bookPath)).isDirectory()) continue; for (const chapter of await fsp.readdir(bookPath)) { const page = path.join(bookPath, chapter, '0001.png'); if (fs.existsSync(page)) await fsp.writeFile(page, Buffer.from(`different-page-${chapter}`)); } } await stop(gb); gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json')); const unverifiedLegacy = await client.manga(a, inA.id, reverseCatalog[0].id); await assert.rejects(() => require('../adapter').run(reverseInfo, { scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: unverifiedLegacy.workIdentity }, { libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false }), /neither its canonical identity nor shared chapter page hashes verify.*No files were downloaded or changed/); assert.equal((await require('../../scraper/lib/library-inventory').scanLibrary(libA)).mangas[0].chapters.length, 3, 'A title collision with differing chapter bytes leaves the local library untouched'); a.updatePeer(inA.id, { servingPaused: true }); assert.equal((await client.access(b, inB.id)).grant.status, 'paused'); await assert.rejects(() => client.catalog(b, inB.id), /Peer HTTP 403/); assert.equal(a.publicState().peers.find(p => p.id === inA.id).grantStatus, 'paused', 'Pausing must retain the grant'); a.revokeGrant(inA.id); assert.equal(a.publicState().peers.find(p => p.id === inA.id).grantStatus, 'none'); await assert.rejects(() => client.chapter(b, inB.id, listing[0].id, remote.chapters[0].id), /Peer HTTP 403/); });