OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / integration / federation-sync.test.js

453 lines · javascript ref:
/**
 * 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)
  })
})