OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / registry-index-bee.test.js

180 lines · javascript ref:
// registry-index-bee: v0.8.26 regression tests for the SeedingRegistry
// Hyperbee indexed-views sidecar.
//
// The sidecar mirrors _applyEntry output to a sibling Hypercore so
// startup hydration doesn't need to replay every log block. Logs
// remain canonical; bee is a cache.

import test from 'brittle'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import Corestore from 'corestore'
import createTestnet from '@hyperswarm/testnet'
import Hyperswarm from 'hyperswarm'
import { SeedingRegistry } from 'p2p-hiverelay/core/registry/index.js'
import { createCustodyIntent } from 'p2p-hiverelay/core/custody-signing.js'
import b4a from 'b4a'
import sodium from 'sodium-universal'

function newKeyPair () {
  const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey }
}

async function freshSetup () {
  const dir = await mkdtemp(join(tmpdir(), 'hiverelay-reg-idx-'))
  const testnet = await createTestnet(2)
  return { dir, testnet }
}

async function makeRegistry (dir, testnet) {
  const store = new Corestore(dir)
  await store.ready()
  const swarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
  const reg = new SeedingRegistry(store, swarm)
  await reg.start()
  return { reg, store, swarm }
}

// closeRelay shuts down a registry + swarm + store + testnet but
// DOES NOT remove the storage dir. Use rmDir at end-of-test for cleanup.
async function closeRelay ({ reg, store, swarm, testnet }) {
  try { await reg.stop() } catch (_) {}
  try { await swarm.destroy() } catch (_) {}
  try { await store.close() } catch (_) {}
  try { await testnet.destroy() } catch (_) {}
}

async function rmDir (dir) {
  try { await rm(dir, { recursive: true, force: true }) } catch (_) {}
}

test('index bee: populated by _applyEntry; survives restart via hydration', async (t) => {
  const setup = await freshSetup()
  const { dir, testnet } = setup
  const publisherKey = newKeyPair()

  // Phase 1 — publish a custody intent
  const r1 = await makeRegistry(dir, testnet)
  const intent = createCustodyIntent({
    blindContentId: b4a.toString(b4a.alloc(32, 0x11), 'hex'),
    ciphertextRoot: b4a.toString(b4a.alloc(32, 0x22), 'hex'),
    requiredReplicas: 3,
    contentVersion: 1,
    deadline: Date.now() + 60_000,
    retainUntil: Date.now() + 3_600_000
  }, publisherKey)

  await r1.reg.publishCustodyIntent(intent, publisherKey)
  // Give the fire-and-forget index-bee write a chance to land
  await r1.reg._flushIndexBee()

  t.is(r1.reg._custodyIntents.size, 1, 'phase 1 — intent in memory')
  await closeRelay({ ...r1, testnet })

  // Phase 2 — fresh registry on same corestore. The bee should hydrate
  // the intent before the log replay catches up.
  const testnet2 = await createTestnet(2)
  const r2 = await makeRegistry(dir, testnet2)
  t.is(r2.reg._custodyIntents.size, 1, 'phase 2 — intent restored from bee or log')
  const got = r2.reg.getCustodyIntent(intent.intentId)
  t.ok(got, 'intent looked up by intentId')
  t.is(got.intentId, intent.intentId)

  await closeRelay({ ...r2, testnet: testnet2 })
  await rmDir(dir)
})

test('index bee: _entryKey returns a stable composite key per type', (t) => {
  // Direct unit test of the key-derivation helper. Skips registry start.
  const reg = new SeedingRegistry({ get: () => null, ready: async () => {} }, null)

  t.is(reg._entryKey({ type: 'seed-request', appKey: 'aa' }), 'entry:seed-request:aa')
  t.is(
    reg._entryKey({ type: 'seed-accept', appKey: 'aa', relayPubkey: 'rr' }),
    'entry:seed-accept:aa:rr'
  )
  t.is(reg._entryKey({ type: 'custody-intent', intentId: 'ii' }), 'entry:custody-intent:ii')
  t.is(
    reg._entryKey({ type: 'custody-receipt', intentId: 'ii', relayPubkey: 'rr' }),
    'entry:custody-receipt:ii:rr'
  )
  t.is(
    reg._entryKey({ type: 'custody-proof', intentId: 'ii', observerPubkey: 'oo', relayPubkey: 'rr', challengeNonce: 'nn' }),
    'entry:custody-proof:ii:oo:rr:nn'
  )
  t.is(reg._entryKey({ type: 'unknown-type' }), null, 'unknown types skipped')
  t.is(reg._entryKey({ type: 'seed-request' /* no appKey */ }), null, 'missing required id returns null')
  t.is(reg._entryKey(null), null, 'null entry')
})

test('index bee: hydration is idempotent — dedup via timestamp', async (t) => {
  // Hydrating + then replaying logs MUST not produce duplicates. The
  // _applyEntry timestamp check is the safety net.
  const setup = await freshSetup()
  const { dir, testnet } = setup
  const publisherKey = newKeyPair()

  const r1 = await makeRegistry(dir, testnet)
  const intent = createCustodyIntent({
    blindContentId: b4a.toString(b4a.alloc(32, 0x33), 'hex'),
    ciphertextRoot: b4a.toString(b4a.alloc(32, 0x44), 'hex'),
    requiredReplicas: 2,
    contentVersion: 1,
    deadline: Date.now() + 60_000,
    retainUntil: Date.now() + 3_600_000
  }, publisherKey)
  await r1.reg.publishCustodyIntent(intent, publisherKey)
  await r1.reg._flushIndexBee()
  await closeRelay({ ...r1, testnet })

  // Phase 2: hydration runs FIRST, then _indexLog replays the same
  // intent block. The replay's _applyEntry sees the same timestamp
  // and doesn't double-count.
  const testnet2 = await createTestnet(2)
  const r2 = await makeRegistry(dir, testnet2)
  t.is(r2.reg._custodyIntents.size, 1, 'single intent — no duplicate from log replay')

  await closeRelay({ ...r2, testnet: testnet2 })
  await rmDir(dir)
})

test('index bee: emits hydrated event on startup', async (t) => {
  const setup = await freshSetup()
  const { dir, testnet } = setup
  const publisherKey = newKeyPair()

  // Phase 1 — seed some state
  const r1 = await makeRegistry(dir, testnet)
  await r1.reg.publishCustodyIntent(createCustodyIntent({
    blindContentId: b4a.toString(b4a.alloc(32, 0x55), 'hex'),
    ciphertextRoot: b4a.toString(b4a.alloc(32, 0x66), 'hex'),
    requiredReplicas: 3,
    contentVersion: 2,
    deadline: Date.now() + 60_000,
    retainUntil: Date.now() + 3_600_000
  }, publisherKey), publisherKey)
  await r1.reg._flushIndexBee()
  await closeRelay({ ...r1, testnet })

  // Phase 2 — fresh registry; attach a listener for the 'hydrated' event
  const testnet2 = await createTestnet(2)
  const store = new Corestore(dir)
  await store.ready()
  const swarm = new Hyperswarm({ bootstrap: testnet2.bootstrap })
  const reg = new SeedingRegistry(store, swarm)
  let hydratedEvent = null
  reg.on('hydrated', (e) => { hydratedEvent = e })
  await reg.start()

  t.ok(hydratedEvent, 'hydrated event fired during start()')
  t.is(hydratedEvent.source, 'index-bee')
  t.ok(hydratedEvent.count >= 1, 'at least one entry hydrated')

  await closeRelay({ reg, store, swarm, testnet: testnet2 })
  await rmDir(dir)
})