logos-storage-nim/tests/js/two-node-test.js
2026-03-23 21:09:13 +11:00

367 lines
13 KiB
JavaScript

/**
* two-node-test.js — Layer 1.5: full pipeline two-node upload/download test
*
* Spins up two in-process libstorage nodes, connects them via bootstrap SPR,
* uploads content on node A, and verifies it can be downloaded from node B
* over the p2p network.
*
* This is the test that fills the TODO in tests/cbindings/storage.c:
* "// TODO: implement check_fetch — requires two nodes connected together"
*
* Usage:
* cd tests/js && npm install && npm test
*
* Environment:
* PORT_BASE Base port for node listen/disc ports (default: 8792).
* Set explicitly when running in containers so ports are
* predictable and can be pre-declared in port mappings.
*/
import { describe, it, before, after, beforeEach, afterEach } from 'mocha';
import assert from 'node:assert/strict';
import net from 'node:net';
import { rmSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import { StorageNode } from './harness.js';
// ---------------------------------------------------------------------------
// Config helpers
// ---------------------------------------------------------------------------
const TMP = tmpdir();
const PORT_BASE = parseInt(process.env.PORT_BASE ?? '8700', 10);
function nodeConfig(label, config) {
const debug = Boolean(process.env.npm_config_debug);
if (!config["data-dir"]) {
config["data-dir"] = path.join(TMP, `libstorage-test-${label}`);
}
// Clean up any leftover state from a previous run
rmSync(config["data-dir"], { recursive: true, force: true });
mkdirSync(config["data-dir"], { recursive: true });
if (!config["log-level"]) {
config["log-level"] = debug ? 'TRACE' :'ERROR';
}
if (debug) {
console.debug(`Node '${label}' config:`, config);
}
return JSON.stringify(config);
}
/**
* Finds the first free TCP port at or above `port`.
* Tries to bind a server; if the port is in use, recurses with port + 1.
*/
function findFreePort(port) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(port)));
srv.on('error', () => resolve(findFreePort(port + 1)));
});
}
// ---------------------------------------------------------------------------
// Single-node tests — node A started ONCE for the whole describe block
// ---------------------------------------------------------------------------
describe('single-node tests', () => {
let nodeA;
before(async () => {
const [listenPort, discPort] = await Promise.all([
findFreePort(PORT_BASE),
findFreePort(PORT_BASE + 1),
]);
nodeA = new StorageNode();
await nodeA.create(nodeConfig('a', {
"listen-port": listenPort,
"disc-port": discPort,
"nat": "extip:127.0.0.1",
}));
await nodeA.start();
});
after(async () => {
await nodeA.shutdown();
});
it('version is non-empty', () => {
assert.ok(nodeA.version().length > 0);
});
it('peer ID is a non-empty string', async () => {
const pid = await nodeA.peerId();
assert.ok(pid && pid.length > 0);
});
it('SPR contains "spr"', async () => {
const spr = await nodeA.spr();
assert.ok(spr.includes('spr'), `SPR should contain "spr", got: ${spr}`);
});
it('debug info has id and addrs', async () => {
const info = JSON.parse(await nodeA.debug());
assert.ok(info.id && info.id.length > 0);
assert.ok(Array.isArray(info.addrs));
});
it('upload returns a non-empty CID', async () => {
const cid = await nodeA.uploadContent('upload test content');
assert.ok(cid && cid.length > 0);
});
it('manifest has expected fields', async () => {
const cid = await nodeA.uploadContent('manifest test content');
const m = JSON.parse(await nodeA.downloadManifest(cid));
assert.ok(m.treeCid && m.treeCid.length > 0);
assert.equal(typeof m.datasetSize, 'number');
assert.equal(m.filename, 'test.txt');
});
it('list includes uploaded CID', async () => {
const cid = await nodeA.uploadContent('list test content');
const listJson = await nodeA.list();
assert.ok(listJson.includes(cid));
});
it('space info has totalBlocks', async () => {
const s = JSON.parse(await nodeA.space());
assert.equal(typeof s.totalBlocks, 'number');
});
it('exists returns true for uploaded CID', async () => {
const cid = await nodeA.uploadContent('exists test content');
assert.equal(await nodeA.exists(cid), true);
});
it('local download returns uploaded content', async () => {
const content = 'local download test content';
const cid = await nodeA.uploadContent(content);
assert.equal(await nodeA.downloadContent(cid, true), content);
});
it('delete removes content from local store', async () => {
const cid = await nodeA.uploadContent('ephemeral content for delete test', 'ephemeral.txt');
await nodeA.delete(cid);
assert.equal(await nodeA.exists(cid), false);
});
it('cancel download does not crash', async () => {
const cid = await nodeA.uploadContent('cancel test content');
await nodeA.downloadContent(cid, true); // ensure blocks are cached
await nodeA.downloadCancel(cid);
// reaching here without exception means the cancel path is working
});
});
// ---------------------------------------------------------------------------
// Two-node tests — fresh node pair started/stopped for EACH test
// ---------------------------------------------------------------------------
describe('two-node tests', () => {
let nodeA, nodeB;
// add 10 to PORT_BASE to avoid collisions with single-node tests, which use PORT_BASE and PORT_BASE + 1
let ports = {
listenA: PORT_BASE + 10,
discA: PORT_BASE + 11,
listenB: PORT_BASE + 12,
discB: PORT_BASE + 13
};
beforeEach(async () => {
const [listenA, discA, listenB, discB] = await Promise.all([
findFreePort(ports.listenA + 1),
findFreePort(ports.discA + 1),
findFreePort(ports.listenB + 1),
findFreePort(ports.discB + 1),
]);
// update base for next test run to avoid collisions
ports.listenA = listenA;
ports.discA = discA;
ports.listenB = listenB;
ports.discB = discB;
nodeA = new StorageNode();
await nodeA.create(nodeConfig('a', {
"listen-port": ports.listenA,
"disc-port": ports.discA,
"nat": "extip:127.0.0.1",
}));
await nodeA.start();
const sprA = await nodeA.spr();
nodeB = new StorageNode();
await nodeB.create(nodeConfig('b', {
"listen-port": ports.listenB,
"disc-port": ports.discB,
"nat": "extip:127.0.0.1",
"bootstrap-node": [sprA],
}));
await nodeB.start();
});
afterEach(async () => {
// allSettled ensures node B is shut down even if node A's shutdown throws
await Promise.allSettled([nodeA.shutdown(), nodeB.shutdown()]);
});
it('node B can download content from node A over p2p', async () => {
const content = 'Hello from node A — p2p test content!';
const cid = await nodeA.uploadContent(content);
assert.equal(await nodeB.downloadContent(cid, false), content);
});
it('node B can fetch a file from node A, which exists locally', async () => {
const content = 'Content for storage_fetch test';
const cid = await nodeA.uploadContent(content, 'fetch-test.txt');
assert.equal(await nodeB.exists(cid), false);
await nodeB.fetch(cid);
assert.equal(await nodeB.exists(cid), true);
assert.equal(await nodeB.downloadContent(cid, true), content);
});
it('node B can download all chunks of a file from node A, which exists locally', async () => {
// 200 KB with a 16 KB chunk size → 13 chunks; exercises the upload loop
// and multi-progress download on the receiving side.
// const chunkSize = 16 * 1024;
const content = 'A'.repeat(200 * 1024);
const cid = await nodeA.uploadContent(content, 'fetch-test.txt');//, chunkSize);
assert.equal(await nodeB.exists(cid), false);
let downloaded = await nodeB.downloadContentByChunks(cid, false);//, chunkSize);
assert.equal(await nodeB.exists(cid), true);
assert.equal(downloaded, content);
});
it('fetched content is equivalent to streamed content', async () => {
const content = 'Content for storage_fetch test';
const cid = await nodeA.uploadContent(content, 'fetch-test.txt');
await nodeB.fetch(cid);
const fetched = await nodeB.downloadContent(cid, true); // gets fetched content from local store
const streamed = await nodeB.downloadContent(cid, false); // streams content from network
assert.equal(fetched, streamed);
});
it('downloaded manifest is equivalent to fetched manifest', async () => {
const content = 'Content for storage_fetch test';
const cid = await nodeA.uploadContent(content, 'fetch-test.txt');
const fetched = await nodeB.fetch(cid);
const downloaded = await nodeB.downloadManifest(cid)
assert.equal(fetched, downloaded);
});
it('node A has node B in its routing table', async () => {
const content = 'Hello from node A — p2p test content!';
const cid = await nodeA.uploadContent(content);
assert.equal(await nodeB.downloadContent(cid), content);
const info = JSON.parse(await nodeA.debug());
assert.ok(info.table?.nodes.length == 1);
assert.equal(info.table?.nodes[0].peerId, await nodeB.peerId());
});
it('large multi-chunk file transfers intact across nodes', async () => {
// 200 KB with a 16 KB chunk size → 13 chunks; exercises the upload loop
// and multi-progress download on the receiving side.
const chunkSize = 16 * 1024;
const content = 'A'.repeat(200 * 1024);
const cid = await nodeA.uploadContent(content, 'large.txt', chunkSize);
// Verify locally on A first
const local = await nodeA.downloadContent(cid, true, chunkSize);
assert.equal(local.length, content.length, 'local: length mismatch');
assert.equal(local, content, 'local: content mismatch');
// Then verify across the p2p network
const remote = await nodeB.downloadContent(cid, false, chunkSize);
assert.equal(remote.length, content.length, 'remote: length mismatch');
assert.equal(remote, content, 'remote: content mismatch');
});
it('node B remains functional after cancelling a download init', async () => {
// Upload two independent pieces of content on A.
const cid1 = await nodeA.uploadContent('content to be cancelled');
const cid2 = await nodeA.uploadContent('content downloaded after cancel');
// Init a download for cid1, then immediately cancel it.
await nodeB.downloadInit(cid1, false);
await nodeB.downloadCancel(cid1);
// After cancelling, the library transfers blocks into B's local store as
// a side-effect of the init, so cid1 should exist locally on B.
assert.equal(await nodeB.exists(cid1), true, 'cancelled CID should be in local store after init');
// More importantly, node B must still be functional: it can download a
// completely fresh CID from the network without any issues.
const downloaded = await nodeB.downloadContent(cid2, false);
assert.equal(downloaded, 'content downloaded after cancel');
});
});
// ---------------------------------------------------------------------------
// Explicit-connect tests — nodes start with no knowledge of each other;
// connection is established by calling storage_connect with peer ID + addrs.
// ---------------------------------------------------------------------------
describe('explicit connect tests', () => {
let nodeA, nodeB;
beforeEach(async () => {
const [listenA, discA, listenB, discB] = await Promise.all([
findFreePort(PORT_BASE + 20),
findFreePort(PORT_BASE + 21),
findFreePort(PORT_BASE + 22),
findFreePort(PORT_BASE + 23),
]);
// Start A with no bootstrap; B also starts with no knowledge of A
nodeA = new StorageNode();
await nodeA.create(nodeConfig('a-exp', {
'listen-port': listenA,
'disc-port': discA,
'nat': 'extip:127.0.0.1',
}));
await nodeA.start();
nodeB = new StorageNode();
await nodeB.create(nodeConfig('b-exp', {
'listen-port': listenB,
'disc-port': discB,
'nat': 'extip:127.0.0.1',
}));
await nodeB.start();
});
afterEach(async () => {
await Promise.allSettled([nodeA.shutdown(), nodeB.shutdown()]);
});
it('node B connects to node A via peer ID and multiaddresses', async () => {
// Get A's peer identity and listen addresses from its debug info
const debugA = JSON.parse(await nodeA.debug());
const peerIdA = debugA.id;
// Filter to loopback addresses only so the connect is local
const addrsA = (debugA.addrs ?? []).filter(a => a.includes('127.0.0.1'));
assert.ok(addrsA.length > 0, 'node A must have at least one loopback address');
// B explicitly connects to A by peer ID + addresses (no DHT, no bootstrap)
await nodeB.connect(peerIdA, addrsA);
// Verify the connection works end-to-end
const content = 'Connected via explicit peer ID + addresses';
const cid = await nodeA.uploadContent(content);
const downloaded = await nodeB.downloadContent(cid, false);
assert.equal(downloaded, content);
});
});