OverviewFilesCommitsIssuesDocsReleasesPeers

autobase-doctor / test / multi-writer-hyperswarm.js

392 lines · javascript ref:
#!/usr/bin/env bare

/**
 * Integration test: two-way Autobase multi-writer over Hyperswarm.
 *
 * Proves the correct flow for federation issue sync:
 *   1. Server A creates an Autobase, writes issues
 *   2. Server B adopts the same Autobase (same bootstrap key)
 *   3. Both connect via Hyperswarm testnet and replicate
 *   4. Server A adds Server B as a writer using B's base.local.key
 *   5. Server B writes issues
 *   6. Both servers see all issues from both writers
 *
 * This is the pattern pear-git needs for LinkedRepo federation.
 */

import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import Hyperswarm from 'hyperswarm'
import createTestnet from '@hyperswarm/testnet'
import os from 'bare-os'
import path from 'bare-path'
import fs from 'bare-fs'

const tmpdir = os.tmpdir()
let idx = 0

function tmpPath (name) {
  return path.join(tmpdir, 'ab-mw-' + name + '-' + (idx++) + '-' + Date.now())
}

async function cleanup (...dirs) {
  for (const d of dirs) await fs.promises.rm(d, { recursive: true }).catch(() => {})
}

function open (store) {
  return new Hyperbee(store.get('view', { valueEncoding: 'json' }), {
    keyEncoding: 'utf-8',
    valueEncoding: 'json'
  })
}

async function apply (batch, view, host) {
  const b = view.batch()
  for (const node of batch) {
    const op = node.value
    if (op.type === 'put') {
      await b.put(op.key, op.value)
    }
    if (op.type === 'add-writer') {
      await host.addWriter(Buffer.from(op.key, 'hex'), {
        indexer: op.indexer !== false
      })
    }
  }
  await b.flush()
}

function createBase (store, key) {
  return new Autobase(store.session(), key, {
    open,
    apply,
    valueEncoding: 'json',
    ackInterval: 0,
    ackThreshold: 0
  })
}

async function swarmReplicate (storeA, storeB, topic, testnet) {
  const { bootstrap } = testnet
  const swarmA = new Hyperswarm({ bootstrap })
  const swarmB = new Hyperswarm({ bootstrap })

  swarmA.on('connection', (conn) => storeA.replicate(conn))
  swarmB.on('connection', (conn) => storeB.replicate(conn))

  await swarmA.join(topic, { server: true, client: false }).flushed()
  swarmB.join(topic, { client: true, server: false })

  await swarmB.flush()

  return { swarmA, swarmB }
}

async function waitForSync (bases, checkKey, maxWait = 10000) {
  const start = Date.now()
  while (Date.now() - start < maxWait) {
    for (const b of bases) await b.update()
    if (checkKey) {
      let allHave = true
      for (const b of bases) {
        const entry = await b.view.get(checkKey)
        if (!entry) { allHave = false; break }
      }
      if (allHave) return true
    }
    await new Promise(r => setTimeout(r, 300))
  }
  return false
}

// ─────────────────────────────────────────────────────────────
// Test 1: One-way replication over Hyperswarm (adoption only)
// ─────────────────────────────────────────────────────────────

test('one-way: server B reads server A issues via Hyperswarm', async (t) => {
  t.timeout(30000)
  const testnet = await createTestnet(3)
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')

  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  // Server A creates Autobase and writes issues
  const baseA = createBase(storeA, null)
  await baseA.ready()
  const bootstrapKey = baseA.local.key

  await baseA.append({ type: 'put', key: 'issue:1', value: { title: 'first issue' } })
  await baseA.append({ type: 'put', key: 'issue:2', value: { title: 'second issue' } })
  await baseA.update()

  const check1 = await baseA.view.get('issue:1')
  t.ok(check1, 'A has issue:1')

  // Server B adopts with same bootstrap key (like _adoptIssueAutobase)
  const baseB = createBase(storeB, bootstrapKey)
  await baseB.ready()

  // Connect via Hyperswarm
  const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, baseA.discoveryKey, testnet)

  // Wait for B to sync A's data
  await waitForSync([baseA, baseB], 'issue:2')

  const e1 = await baseB.view.get('issue:1')
  const e2 = await baseB.view.get('issue:2')
  t.ok(e1, 'B sees issue:1')
  t.ok(e2, 'B sees issue:2')
  t.is(e1.value.title, 'first issue')
  t.is(e2.value.title, 'second issue')

  // B is NOT writable (not added as writer)
  t.is(baseB.writable, false, 'B is read-only')

  await swarmA.destroy()
  await swarmB.destroy()
  await baseA.close()
  await baseB.close()
  await storeA.close()
  await storeB.close()
  await testnet.destroy()
  await cleanup(dirA, dirB)
})

// ─────────────────────────────────────────────────────────────
// Test 2: Two-way multi-writer over Hyperswarm
// ─────────────────────────────────────────────────────────────

test('two-way: A adds B as writer, both write issues via Hyperswarm', async (t) => {
  t.timeout(30000)
  const testnet = await createTestnet(3)
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')

  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  // Server A creates Autobase
  const baseA = createBase(storeA, null)
  await baseA.ready()
  const bootstrapKey = baseA.local.key

  // Server B adopts
  const baseB = createBase(storeB, bootstrapKey)
  await baseB.ready()

  t.comment('A local key: ' + baseA.local.key.toString('hex').slice(0, 16))
  t.comment('B local key: ' + baseB.local.key.toString('hex').slice(0, 16))

  // Connect via Hyperswarm
  const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, baseA.discoveryKey, testnet)

  // Wait for initial connection
  await new Promise(r => setTimeout(r, 2000))

  // A writes initial issue
  await baseA.append({ type: 'put', key: 'issue:1', value: { title: 'from server A' } })

  // A adds B as a writer — using B's ACTUAL local core key
  // This is the critical part: must be base.local.key, not a server identity key
  await baseA.append({
    type: 'add-writer',
    key: baseB.local.key.toString('hex'),
    indexer: true
  })

  // Sync so B gets the add-writer op
  await waitForSync([baseA, baseB], 'issue:1')

  // Ack from both sides to confirm the writer set
  if (baseA.writable) await baseA.append(null)
  await new Promise(r => setTimeout(r, 1000))
  for (const b of [baseA, baseB]) await b.update()

  if (baseB.writable) await baseB.append(null)
  await new Promise(r => setTimeout(r, 1000))
  for (const b of [baseA, baseB]) await b.update()

  t.comment('B writable after add-writer: ' + baseB.writable)
  t.comment('A members: ' + baseA.system.members)
  t.comment('B members: ' + baseB.system.members)

  t.ok(baseB.writable, 'B is writable after being added')
  t.is(baseA.system.members, 2, 'A sees 2 members')

  // Now B writes an issue
  if (baseB.writable) {
    await baseB.append({ type: 'put', key: 'issue:2', value: { title: 'from server B' } })
  }

  // Sync again
  await waitForSync([baseA, baseB], 'issue:2')

  // Both should see both issues
  const a1 = await baseA.view.get('issue:1')
  const a2 = await baseA.view.get('issue:2')
  const b1 = await baseB.view.get('issue:1')
  const b2 = await baseB.view.get('issue:2')

  t.ok(a1, 'A sees issue:1')
  t.ok(b1, 'B sees issue:1')
  t.is(a1.value.title, 'from server A')
  t.is(b1.value.title, 'from server A')

  if (baseB.writable) {
    t.ok(a2, 'A sees issue:2 from B')
    t.ok(b2, 'B sees issue:2 from B')
    t.is(a2.value.title, 'from server B')
    t.is(b2.value.title, 'from server B')
  }

  await swarmA.destroy()
  await swarmB.destroy()
  await baseA.close()
  await baseB.close()
  await storeA.close()
  await storeB.close()
  await testnet.destroy()
  await cleanup(dirA, dirB)
})

// ─────────────────────────────────────────────────────────────
// Test 3: Wrong key type causes writable=false (the bug we hit)
// ─────────────────────────────────────────────────────────────

test('wrong key: using identity key instead of local.key fails gracefully', async (t) => {
  t.timeout(30000)
  const testnet = await createTestnet(3)
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')

  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  const baseA = createBase(storeA, null)
  await baseA.ready()
  const bootstrapKey = baseA.local.key

  const baseB = createBase(storeB, bootstrapKey)
  await baseB.ready()

  const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, baseA.discoveryKey, testnet)
  await new Promise(r => setTimeout(r, 2000))

  // Simulate the bug: use a random key (like an Ed25519 identity key)
  // instead of baseB.local.key
  const fakeIdentityKey = Buffer.alloc(32, 0xf5).toString('hex')
  t.comment('fake key: ' + fakeIdentityKey.slice(0, 16))
  t.comment('correct key would be: ' + baseB.local.key.toString('hex').slice(0, 16))

  await baseA.append({
    type: 'add-writer',
    key: fakeIdentityKey,
    indexer: true
  })

  // Sync
  for (let i = 0; i < 10; i++) {
    for (const b of [baseA, baseB]) await b.update()
    await new Promise(r => setTimeout(r, 300))
  }

  // B should still NOT be writable — wrong key was used
  t.is(baseB.writable, false, 'B is NOT writable (wrong key used)')

  // A should still have member count reflecting the bogus key
  t.comment('A members: ' + baseA.system.members + ' (includes bogus)')

  await swarmA.destroy()
  await swarmB.destroy()
  await baseA.close()
  await baseB.close()
  await storeA.close()
  await storeB.close()
  await testnet.destroy()
  await cleanup(dirA, dirB)
})

// ─────────────────────────────────────────────────────────────
// Test 4: Correct key vs wrong key — side by side
// ─────────────────────────────────────────────────────────────

test('correct key = writable, wrong key = not writable', async (t) => {
  t.timeout(15000)

  // Correct: use base.local.key
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')
  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  const a = createBase(storeA, null)
  await a.ready()
  const b = createBase(storeB, a.local.key)
  await b.ready()

  // Add B with correct key
  await a.append({ type: 'add-writer', key: b.local.key.toString('hex'), indexer: true })

  // Pipe replicate (fast, no swarm needed for this test)
  const s1 = a.replicate(true)
  const s2 = b.replicate(false)
  s1.pipe(s2).pipe(s1)

  // Confirm
  for (let i = 0; i < 3; i++) {
    for (const base of [a, b]) await base.update()
    if (a.writable) await a.append(null)
    if (b.writable) await b.append(null)
    await new Promise(r => setTimeout(r, 200))
    for (const base of [a, b]) await base.update()
  }

  t.ok(b.writable, 'correct key → writable')
  t.is(a.system.members, 2, '2 members')

  s1.destroy()
  s2.destroy()
  await a.close()
  await b.close()
  await storeA.close()
  await storeB.close()

  // Wrong: use random key
  const dirC = tmpPath('c')
  const dirD = tmpPath('d')
  const storeC = new Corestore(dirC, { primaryKey: Buffer.alloc(32, 2), unsafe: true })
  const storeD = new Corestore(dirD, { primaryKey: Buffer.alloc(32, 3), unsafe: true })

  const c = createBase(storeC, null)
  await c.ready()
  const d = createBase(storeD, c.local.key)
  await d.ready()

  // Add D with WRONG key
  const wrongKey = Buffer.alloc(32, 0xab).toString('hex')
  await c.append({ type: 'add-writer', key: wrongKey, indexer: true })

  const s3 = c.replicate(true)
  const s4 = d.replicate(false)
  s3.pipe(s4).pipe(s3)

  for (let i = 0; i < 3; i++) {
    for (const base of [c, d]) await base.update()
    await new Promise(r => setTimeout(r, 200))
  }

  t.is(d.writable, false, 'wrong key → NOT writable')

  s3.destroy()
  s4.destroy()
  await c.close()
  await d.close()
  await storeC.close()
  await storeD.close()
  await cleanup(dirA, dirB, dirC, dirD)
})