/**
* End-to-end federation tests against a real testnet.
*
* Verifies the full follow → /catalog.json fetch → accept-mode gate path
* with two real RelayNodes communicating over a Hyperswarm testnet.
*/
import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import { Federation } from 'p2p-hiverelay/core/federation.js'
import { EventEmitter } from 'events'
import http from 'http'
import path from 'path'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'
import { mkdirSync } from 'fs'
const FEDERATED_APP_MAX_STORAGE_BYTES = 16 * 1024 * 1024
function tmpStorage () {
const storage = path.join(tmpdir(), 'hiverelay-fed-test-' + randomBytes(8).toString('hex'))
mkdirSync(storage, { recursive: true })
return storage
}
function pickPort () {
return 50000 + Math.floor(Math.random() * 10000)
}
function createNode (testnet, overrides = {}) {
return new RelayNode({
storage: tmpStorage(),
bootstrapNodes: testnet.bootstrap,
enableMetrics: false,
...overrides
})
}
async function waitFor (fn, timeoutMs = 10000, intervalMs = 200) {
const start = Date.now()
while ((Date.now() - start) < timeoutMs) {
if (await fn()) return true
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
return false
}
test('e2e federation: follow real peer in review mode → app lands in pending queue', async (t) => {
const testnet = await createTestnet(3, t.teardown)
// Source relay: serves a /catalog.json over HTTP. Open accept mode so it
// accepts a self-seed quickly.
const srcPort = pickPort()
const src = createNode(testnet, { enableAPI: true, apiPort: srcPort, acceptMode: 'open' })
// Subscriber: in review mode, follows src.
const sub = createNode(testnet, { acceptMode: 'review', enableAPI: false })
t.teardown(async () => {
await src.stop()
await sub.stop()
await testnet.destroy()
})
await src.start()
await sub.start()
// Have src publish a fake app entry into its catalog by adding to appRegistry
// directly. We don't need a real Hyperdrive here — federation only reads the
// /catalog.json shape.
const fakeAppKey = randomBytes(32).toString('hex')
src.appRegistry.set(fakeAppKey, {
appId: 'federated-test-app',
name: 'Federation Test App',
type: 'app',
version: '1.0.0',
privacyTier: 'public',
maxStorage: FEDERATED_APP_MAX_STORAGE_BYTES,
seededAt: Date.now()
})
// Stub seedApp on subscriber so 'review' mode purely queues (no real seed).
sub.appRegistry.has = () => false
sub.seededApps.has = () => false
// sub follows src
sub.federation.follow(`http://127.0.0.1:${srcPort}`)
// Trigger an immediate poll instead of waiting for the 5-min interval.
await sub.federation._pollAll()
const queued = sub._pendingRequests.has(fakeAppKey)
t.ok(queued, 'review mode queued the discovered app')
const entry = sub._pendingRequests.get(fakeAppKey)
t.is(entry.source, 'federation', 'pending entry tagged with source=federation')
t.is(entry.sourceRelay, `http://127.0.0.1:${srcPort}`, 'source relay URL recorded')
t.is(entry.maxStorageBytes, FEDERATED_APP_MAX_STORAGE_BYTES, 'finite catalog bound reaches the review queue exactly')
// The same signed/catalogued commitment must reach an auto-accept seed
// without widening, truncation, or fallback to the subscriber default.
let accepted = null
sub._pendingRequests.delete(fakeAppKey)
sub._resolveAcceptMode = () => 'open'
sub.seedApp = async (appKey, opts) => { accepted = { appKey, opts } }
await sub.federation._pollAll()
t.ok(accepted, 'open-mode federation calls seedApp')
t.is(accepted.appKey, fakeAppKey)
t.is(accepted.opts.maxStorage, FEDERATED_APP_MAX_STORAGE_BYTES, 'finite catalog bound reaches seedApp exactly')
})
test('e2e federation: follow real peer in closed mode → app rejected, never queues', async (t) => {
const testnet = await createTestnet(3, t.teardown)
const srcPort = pickPort()
const src = createNode(testnet, { enableAPI: true, apiPort: srcPort, acceptMode: 'open' })
const sub = createNode(testnet, { acceptMode: 'closed', enableAPI: false })
t.teardown(async () => {
await src.stop()
await sub.stop()
await testnet.destroy()
})
await src.start()
await sub.start()
const fakeAppKey = randomBytes(32).toString('hex')
src.appRegistry.set(fakeAppKey, {
appId: 'closed-mode-test-app',
name: 'Closed Mode Test',
type: 'app',
version: '1.0.0',
privacyTier: 'public',
maxStorage: FEDERATED_APP_MAX_STORAGE_BYTES,
seededAt: Date.now()
})
sub.appRegistry.has = () => false
sub.seededApps.has = () => false
const rejected = []
sub.federation.on('federation-rejected', info => rejected.push(info))
sub.federation.follow(`http://127.0.0.1:${srcPort}`)
await sub.federation._pollAll()
t.is(sub._pendingRequests.size, 0, 'closed mode never queues anything')
t.is(rejected.length, 1, 'rejection event emitted for the discovered app')
t.is(rejected[0].mode, 'closed', 'positive bounded app reaches the closed-mode policy gate')
})
test('federation rejects missing, zero, and unsafe catalog storage bounds', async (t) => {
const apps = [
{ appKey: randomBytes(32).toString('hex'), type: 'app' },
{ appKey: randomBytes(32).toString('hex'), type: 'app', maxStorageBytes: 0 },
{ appKey: randomBytes(32).toString('hex'), type: 'app', maxStorageBytes: Number.MAX_SAFE_INTEGER + 1 }
]
const server = http.createServer((_req, res) => {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ apps }))
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
t.teardown(() => new Promise(resolve => server.close(resolve)))
const node = new EventEmitter()
node.seededApps = new Set()
node.appRegistry = new Map()
node._pendingRequests = new Map()
node._resolveAcceptMode = () => 'review'
node._decideAcceptance = () => 'queue'
const federation = new Federation({ node })
const rejected = []
federation.on('federation-rejected', info => rejected.push(info))
federation.follow(`http://127.0.0.1:${server.address().port}`)
await federation._pollAll()
t.is(node._pendingRequests.size, 0, 'invalid bounds never enter the review queue')
t.is(rejected.length, apps.length)
t.alike(rejected.map(info => info.reason), [
'storage-bound-invalid',
'storage-bound-invalid',
'storage-bound-invalid'
])
})
test('e2e federation: /catalog.json from a real RelayNode advertises federation field', async (t) => {
const testnet = await createTestnet(2, t.teardown)
const port = pickPort()
const node = createNode(testnet, { enableAPI: true, apiPort: port, acceptMode: 'review' })
t.teardown(async () => {
await node.stop()
await testnet.destroy()
})
await node.start()
// Prime federation state so /catalog.json has something to advertise
node.federation.follow('http://upstream-a.example')
node.federation.mirror('http://trusted-b.example', { pubkey: 'b'.repeat(64) })
// Hit the real HTTP endpoint
const ok = await waitFor(async () => {
try {
const res = await fetch(`http://127.0.0.1:${port}/catalog.json`)
return res.status === 200
} catch { return false }
}, 5000)
t.ok(ok, 'API came up')
const data = await fetch(`http://127.0.0.1:${port}/catalog.json`).then(r => r.json())
t.is(data.acceptMode, 'review', 'catalog.json advertises acceptMode')
t.ok(data.federation, 'catalog.json carries federation field')
t.is(data.federation.followed.length, 1, 'follow shows up in catalog.json')
t.is(data.federation.mirrored.length, 1, 'mirror shows up in catalog.json')
t.is(data.federation.followed[0].url, 'http://upstream-a.example')
})