Files
MangaCircus/peering/test/peering.test.js

215 lines
15 KiB
JavaScript

'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' } }));
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');
assert.equal(fs.existsSync(path.join(libB, 'Sample Manga', 'v01', 'c0001 The first chapter [Peer ServerA ' + inB.id.slice(1, 7) + ']', '0001.png')), true);
const second = await run(info, { scope: { mode: 'missing' }, existingPolicy: 'skip-any' },
{ libraryRoot: libB, settings: { chapterMinMs: 500 }, shouldStop: () => false });
assert.equal(second.publishedCount, 0);
// 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/);
});