215 lines
15 KiB
JavaScript
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/);
|
|
});
|