/**
* Federation Sync Integration Test
*
* Tests all 5 data transfer types between two isolated instances.
*
* Patterns from Holepunch test suite:
* - createTestnet(3, t.teardown) for isolated DHT
* - t.teardown for ALL cleanup (never explicit cleanup in test body)
* - replicateAndSync from autobase-test-helpers for convergence
* - t.plan(N) for async assertion counting
*/
import test from 'brittle'
import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import createTestnet from 'hyperdht/testnet'
import tmp from 'test-tmp'
import fs from 'bare-fs'
import path from 'bare-path'
import { spawn } from 'bare-subprocess'
import { IdentityRegistry } from '../../lib/auth/identity-registry.js'
import { NamespaceRegistry } from '../../lib/namespace-registry/namespace-registry.js'
import { SharedDeployRegistry } from '../../lib/ci/shared-deploy-registry.js'
import { LinkedRepo } from '../../lib/repo-link/linked-repo.js'
// ── Helpers (Holepunch patterns) ─────────────────────────────
function eventFlush () {
return new Promise(resolve => setImmediate(resolve))
}
/**
* Pipe two corestores for replication. Returns close function.
* Pattern from autobase-test-helpers/replicate.
*/
function replicate (storeA, storeB) {
const s1 = storeA.replicate(true)
const s2 = storeB.replicate(false)
s1.on('error', () => {})
s2.on('error', () => {})
s1.pipe(s2).pipe(s1)
return function close () {
s1.destroy()
s2.destroy()
return Promise.all([
new Promise(resolve => s1.on('close', resolve)),
new Promise(resolve => s2.on('close', resolve))
])
}
}
/**
* Wait for Autobase views to converge by polling.
* Simplified from autobase-test-helpers/sync — uses update() + retry.
*/
async function syncBases (bases, retries = 20) {
for (let i = 0; i < retries; i++) {
for (const base of bases) {
await base._base.update()
}
await eventFlush()
}
}
async function createBareRepo (reposDir, name) {
const repoPath = path.join(reposDir, name + '.git')
await runGit(['init', '--bare', repoPath])
return repoPath
}
async function pushCommit (repoPath, message, file, content) {
const workDir = repoPath + '-work'
await fs.promises.mkdir(workDir, { recursive: true })
const hasGit = await fs.promises.stat(path.join(workDir, '.git')).catch(() => null)
if (!hasGit) await runGit(['-C', workDir, 'init'])
await fs.promises.writeFile(path.join(workDir, file), content)
await runGit(['-C', workDir, 'add', '.'])
await runGit(['-C', workDir, 'commit', '-m', message, '--allow-empty'])
await runGit(['-C', workDir, 'push', repoPath, 'HEAD:refs/heads/main', '--force'])
}
function runGit (args) {
return new Promise((resolve, reject) => {
const child = spawn('git', args)
let stderr = ''
child.stderr.on('data', d => { stderr += d.toString() })
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`git ${args[0]}: ${stderr}`)))
})
}
// ── Autobase helpers ─────────────────────────────────────────
function createStores (t, n) {
const stores = []
const dirs = []
for (let i = 0; i < n; i++) {
const dir = path.join(t._tmp, 'store-' + i)
dirs.push(dir)
stores.push(new Corestore(dir, { primaryKey: Buffer.alloc(32, i), unsafe: true }))
}
t.teardown(() => Promise.all(stores.map(s => s.close())), { order: 2 })
return stores
}
// ═══════════════════════════════════════════════════════════════
// Type 1: Identity Registry
// ═══════════════════════════════════════════════════════════════
test('type 1: identity registry — A→B sync', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const regA = new IdentityRegistry({ corestore: storeA })
await regA.ready()
const regB = new IdentityRegistry({ corestore: storeB, bootstrapKey: regA.bootstrapKeyHex })
await regB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
await regA.createUser({ name: 'alice', permissions: ['admin'] })
await syncBases([regA, regB])
const users = await regB.listAllUsers()
t.is(users.length, 1, 'B sees 1 user')
t.is(users[0].name, 'alice', 'name matches')
})
test('type 1: identity registry — B→A bidirectional', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const regA = new IdentityRegistry({ corestore: storeA })
await regA.ready()
const regB = new IdentityRegistry({ corestore: storeB, bootstrapKey: regA.bootstrapKeyHex })
await regB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
// Add B as writer and sync so B knows it's writable
await regA.addWriter(regB.localKey)
await syncBases([regA, regB])
await regB.createUser({ name: 'bob', permissions: ['write'] })
await syncBases([regA, regB])
const users = await regA.listAllUsers()
const bob = users.find(u => u.name === 'bob')
t.ok(bob, 'A sees user created on B')
})
// ═══════════════════════════════════════════════════════════════
// Type 2: Namespace Registry
// ═══════════════════════════════════════════════════════════════
test('type 2: namespace registry — groups sync A→B', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const nsA = new NamespaceRegistry({ corestore: storeA, bootstrapKey: null })
await nsA.ready()
const nsB = new NamespaceRegistry({ corestore: storeB, bootstrapKey: nsA.bootstrapKeyHex })
await nsB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
await nsA.create('my-team', 'group', { displayName: 'My Team', description: 'Test group' })
await nsA.addRepo('my-team', 'pear-git')
await nsA.addRepo('my-team', 'wirecore')
await syncBases([nsA, nsB])
const groups = await nsB.list()
t.is(groups.length, 1, 'B sees 1 group')
t.is(groups[0].name, 'my-team', 'group name matches')
const group = await nsB.get('my-team')
t.ok(group.repos.includes('pear-git'), 'has pear-git')
t.ok(group.repos.includes('wirecore'), 'has wirecore')
})
test('type 2: namespace registry — B→A bidirectional', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const nsA = new NamespaceRegistry({ corestore: storeA, bootstrapKey: null })
await nsA.ready()
const nsB = new NamespaceRegistry({ corestore: storeB, bootstrapKey: nsA.bootstrapKeyHex })
await nsB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
// Pass localKey as serverKey (correct Hypercore key, not Ed25519 identity key)
// Use 'owner' role so B becomes an indexer (writer role = non-indexer = can't order ops)
await nsA.addWriter('peer-b', nsB.localKey.toString('hex'), 'owner')
await syncBases([nsA, nsB])
await nsB.create('team-b', 'group', { displayName: 'Team B' })
await syncBases([nsA, nsB])
const groups = await nsA.list()
const teamB = groups.find(g => g.name === 'team-b')
t.ok(teamB, 'A sees group created on B')
})
// ═══════════════════════════════════════════════════════════════
// Type 3: Deploy Registry
// ═══════════════════════════════════════════════════════════════
test('type 3: deploy registry — deploy state syncs A→B', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const deployA = new SharedDeployRegistry({ corestore: storeA, bootstrapKey: null })
await deployA.ready()
const deployB = new SharedDeployRegistry({ corestore: storeB, bootstrapKey: deployA.bootstrapKeyHex })
await deployB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
await deployA.registerServer('key-aaa', { hostname: 'tabertron', httpUrl: 'http://tabertron:5005' })
await deployA.deployComplete('pear-git', {
commitHash: 'abc123', branch: 'main', channel: 'release', serverKey: 'key-aaa'
})
await syncBases([deployA, deployB])
const servers = await deployB.listServers()
t.is(servers.length, 1, 'B sees 1 server')
t.is(servers[0].hostname, 'tabertron', 'hostname matches')
const app = await deployB.getApp('pear-git')
t.ok(app, 'B sees app')
t.is(app.commitHash, 'abc123', 'commit matches')
})
test('type 3: deploy registry — B→A bidirectional', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const deployA = new SharedDeployRegistry({ corestore: storeA, bootstrapKey: null })
await deployA.ready()
const deployB = new SharedDeployRegistry({ corestore: storeB, bootstrapKey: deployA.bootstrapKeyHex })
await deployB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
await deployA.addWriter(deployB.localKey)
await syncBases([deployA, deployB])
await deployB.registerServer('key-bbb', { hostname: 'stortron' })
await deployB.deployComplete('wirecore', {
commitHash: 'def456', branch: 'main', channel: 'release', serverKey: 'key-bbb'
})
await syncBases([deployA, deployB])
const app = await deployA.getApp('wirecore')
t.ok(app, 'A sees app deployed on B')
t.is(app.commitHash, 'def456', 'commit matches')
})
// ═══════════════════════════════════════════════════════════════
// Type 4: LinkedRepo / Issues
// ═══════════════════════════════════════════════════════════════
test('type 4: linked repo — issues sync A→B', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const linkedA = new LinkedRepo({ corestore: storeA, bootstrapKey: null, repoName: 'test-repo' })
await linkedA.ready()
const linkedB = new LinkedRepo({ corestore: storeB, bootstrapKey: linkedA.bootstrapKeyHex, repoName: 'test-repo' })
await linkedB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
const id1 = await linkedA.createIssue('Bug: crash', 'Stack trace', 'alice')
const id2 = await linkedA.createIssue('Feature: dark mode', 'Would be nice', 'bob')
await linkedA.createComment(id1, 'I can reproduce', 'charlie')
await linkedA.createComment(id1, 'Fixed in abc123', 'alice')
await syncBases([linkedA, linkedB])
const issues = await linkedB.listIssues()
t.is(issues.length, 2, 'B sees 2 issues')
const comments = await linkedB.getComments(id1)
t.is(comments.length, 2, 'B sees 2 comments')
const issue2 = await linkedB.getIssue(id2)
t.is(issue2.title, 'Feature: dark mode', 'title matches')
})
test('type 4: linked repo — B→A bidirectional', async (t) => {
t._tmp = await tmp(t)
const [storeA, storeB] = createStores(t, 2)
const linkedA = new LinkedRepo({ corestore: storeA, bootstrapKey: null, repoName: 'test-repo' })
await linkedA.ready()
const linkedB = new LinkedRepo({ corestore: storeB, bootstrapKey: linkedA.bootstrapKeyHex, repoName: 'test-repo' })
await linkedB.ready()
const close = replicate(storeA, storeB)
t.teardown(close, { order: 1 })
// Pass localKey as serverKey (correct Hypercore key, not Ed25519 identity key)
// Use 'owner' role so B becomes an indexer
await linkedA.addWriter('user-b', linkedB.localKey.toString('hex'), null, null, 'owner')
await syncBases([linkedA, linkedB])
await linkedB.createIssue('Issue from B', 'Created on the mirror', 'dave')
await syncBases([linkedA, linkedB])
const issues = await linkedA.listIssues()
const fromB = issues.find(i => i.title === 'Issue from B')
t.ok(fromB, 'A sees issue created on B')
if (fromB) t.is(fromB.author, 'dave', 'author matches')
})
// ═══════════════════════════════════════════════════════════════
// Type 5: Git Sync
// ═══════════════════════════════════════════════════════════════
test('type 5: git refs — read and diff', async (t) => {
const dir = await tmp(t)
const reposDir = path.join(dir, 'repos')
await fs.promises.mkdir(reposDir, { recursive: true })
const repoPath = await createBareRepo(reposDir, 'ref-test')
await pushCommit(repoPath, 'first', 'file.txt', 'hello\n')
const { readRefs } = await import('../../lib/git/ref-reader.js')
const refs = await readRefs(repoPath)
t.ok(refs['refs/heads/main'], 'main ref exists')
t.is(refs['refs/heads/main'].length, 40, 'sha is 40 chars')
const { diffRefs } = await import('../../lib/git/ref-diff.js')
const diff = diffRefs({ 'refs/heads/main': 'aaa' }, { 'refs/heads/main': 'bbb', 'refs/heads/feature': 'ccc' })
t.is(diff.synced, false, 'not synced')
t.is(diff.changed.length, 1, '1 changed')
t.is(diff.added.length, 1, '1 added')
const same = diffRefs(refs, refs)
t.is(same.synced, true, 'identical = synced')
})
test('type 5: git sync — manifest exchange + broadcast', async (t) => {
const { bootstrap } = await createTestnet(3, t.teardown)
const dirA = await tmp(t)
const dirB = await tmp(t)
const reposDirA = path.join(dirA, 'repos')
const reposDirB = path.join(dirB, 'repos')
await fs.promises.mkdir(reposDirA, { recursive: true })
await fs.promises.mkdir(reposDirB, { recursive: true })
await createBareRepo(reposDirA, 'app-one')
await pushCommit(path.join(reposDirA, 'app-one.git'), 'init', 'README.md', '# App One\n')
const storeA = new Corestore(path.join(dirA, 'cores'))
const storeB = new Corestore(path.join(dirB, 'cores'))
t.teardown(() => Promise.all([storeA.close(), storeB.close()]), { order: 3 })
const swarmA = new Hyperswarm({ bootstrap })
const swarmB = new Hyperswarm({ bootstrap })
t.teardown(async () => {
await swarmA.destroy()
await swarmB.destroy()
})
const { GitSyncClientManager } = await import('../../lib/p2p/sync/git-sync-client-manager.js')
const { GitSyncManager } = await import('../../lib/p2p/sync/git-sync-manager.js')
const { readRefs } = await import('../../lib/git/ref-reader.js')
const clientA = new GitSyncClientManager({ reposDir: reposDirA, serverKey: 'aaa' })
clientA._syncRepo = async () => {} // stub — no punch:// in test
const managerA = new GitSyncManager({ reposDir: reposDirA, serverKey: 'aaa', clientManager: clientA })
let refUpdateReceived = null
const clientB = new GitSyncClientManager({ reposDir: reposDirB, serverKey: 'bbb' })
clientB._syncRepo = async () => {} // stub
clientB.handleRefUpdate = async (repo, refs, peerKey) => {
refUpdateReceived = { repo, refs, peerKey }
}
const managerB = new GitSyncManager({ reposDir: reposDirB, serverKey: 'bbb', clientManager: clientB })
t.teardown(async () => {
await managerA.destroy()
await managerB.destroy()
})
const topic = Buffer.alloc(32).fill('git-sync-test')
swarmA.on('connection', conn => {
const stream = storeA.replicate(conn)
managerA.attachToConnection(conn, stream)
})
swarmB.on('connection', conn => {
const stream = storeB.replicate(conn)
managerB.attachToConnection(conn, stream)
})
await swarmA.join(topic, { server: true, client: false }).flushed()
swarmB.join(topic, { client: true, server: false })
await swarmB.flush()
// Wait for manifest exchange
for (let i = 0; i < 50; i++) {
const exists = await fs.promises.stat(path.join(reposDirB, 'app-one.git')).catch(() => null)
if (exists) break
await new Promise(r => setTimeout(r, 100))
}
// Verify manifest exchange auto-created repo
const manifestA = await clientA._buildManifest()
t.is(Object.keys(manifestA).length, 1, 'A manifest has 1 repo')
const repoExists = await fs.promises.stat(path.join(reposDirB, 'app-one.git')).catch(() => null)
t.ok(repoExists, 'B auto-created app-one.git')
// Verify broadcast
const refsA = await readRefs(path.join(reposDirA, 'app-one.git'))
managerA.broadcastRefUpdate('app-one', refsA, 'aaa')
await new Promise(r => setTimeout(r, 500))
t.ok(refUpdateReceived, 'B received ref-update broadcast')
t.is(refUpdateReceived.repo, 'app-one', 'correct repo')
t.ok(refUpdateReceived.refs['refs/heads/main'], 'has main ref')
})
// Force exit after all tests — testnet DHT teardown can hang indefinitely
// because HyperDHT nodes keep UDP sockets open. This is a known issue
// with createTestnet in long-running test suites.
test('cleanup: force exit', (t) => {
t.pass('all tests complete')
t.teardown(() => {
const exit = typeof Bare !== 'undefined' ? Bare.exit : (typeof process !== 'undefined' ? process.exit : null)
if (exit) setTimeout(() => exit(0), 500)
})
})