OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / issues-mirror-writer.test.js

546 lines · javascript ref:
// Proof test for the per-repo issue tracker mirror-writer flow.
//
// HYPOTHESIS: stortron's "Not writable" error on issues comes from
// `setupMirrorWriterCoordination`'s announce side only emitting
// `{ orgs: orgs.localKeyHex }` (lib/pear-git.js:1478). The watcher
// already handles `issues:repo:<name>` and `issues:org:<name>`
// namespaces (lib/pear-git.js:1422-1441), so adding those keys to
// the announce is sufficient.
//
// Test models the END-TO-END flow with TWO real startPearGit
// daemons (primary + mirror) sharing identity via mounts.
//
// Phase 1 — control: NO mirror-writer announce. Confirms B's
//   per-repo tracker is NOT writable. Reproduces the production bug.
//
// Phase 2 — fix: B announces { 'issues:repo:demo': trackerB.localKeyHex }
//   via identityRegistry.registerMirrorWriters. A's
//   setupMirrorWriterCoordination watcher fires, calls
//   tracker.addWriter on its own tracker with B's localKeyHex.
//   After replication, B's tracker.base.writable === true.
//   B writes an issue, A sees it.

import test from 'brittle'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'

import { startPearGit } from '../index.js'

let n = 0
function tmpStorage () {
  return mkdtempSync(join(tmpdir(), 'pgv3-issues-mw-' + (n++) + '-'))
}
function tmpSockPath () {
  return join(mkdtempSync(join(tmpdir(), 'pgv3-issues-mw-sock-' + (n++) + '-')), 'svc.sock')
}

async function waitFor (predicate, timeoutMs = 8000) {
  const t0 = Date.now()
  while (Date.now() - t0 < timeoutMs) {
    try { if (await predicate()) return true } catch {}
    await new Promise((r) => setTimeout(r, 50))
  }
  return false
}

test('per-repo tracker: mirror is NOT writable without registering its localKeyHex (control)', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-issues-mw-control')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // A creates a per-repo tracker (= primary, owns the autobase root)
  const trackerA = await nodeA.issueRegistry.getForRepo('demo')
  await trackerA.create({ title: 'a from primary', author: 'alice' })
  const demoKey = trackerA.keyHex

  // B mounts A's tracker bootstrap, but does NOT announce its localKeyHex
  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false,
    mounts: { issueRepos: { demo: demoKey } }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  const trackerB = await nodeB.issueRegistry.getForRepo('demo')

  // Wait for replication so B sees A's issue (read path works)
  const replicated = await waitFor(async () => {
    const c = await trackerB.counts()
    return c.total === 1
  }, 6000)
  t.ok(replicated, 'B receives A\'s issue via swarm replication')

  // But B is NOT a writer on this tracker — its localKeyHex was never
  // addWritered by A. Direct write must fail.
  let writeFailed = false
  let errMessage = null
  try {
    await trackerB.create({ title: 'b without enrollment', author: 'bob' })
  } catch (err) {
    writeFailed = true
    errMessage = err.message
  }
  t.ok(writeFailed, 'B.tracker.create FAILS when B is not enrolled as writer')
  t.is(errMessage, 'Not writable',
    'error message matches production symptom: "Not writable"')
})

test('per-repo tracker: A.tracker.addWriter(B.localKeyHex) makes B writable (mechanism proof)', async (t) => {
  // Direct proof of the underlying mechanism the watcher invokes.
  // Skips the identityRegistry layer — calls tracker.addWriter
  // directly with B's tracker.localKeyHex. If this works, the fix
  // (announce side adding issue keys, watcher already calling
  // addWriter on the matching tracker) is mechanically sound.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-issues-mw-mech')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  const trackerA = await nodeA.issueRegistry.getForRepo('demo')
  await trackerA.create({ title: 'a from primary', author: 'alice' })
  const demoKey = trackerA.keyHex

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false,
    mounts: { issueRepos: { demo: demoKey } }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  const trackerB = await nodeB.issueRegistry.getForRepo('demo')

  await waitFor(async () => {
    const c = await trackerB.counts()
    return c.total === 1
  }, 6000)

  t.absent(trackerB.base.writable, 'B starts NOT writable on the tracker')

  // ── Direct mechanism: A addWriters B's localKeyHex on the tracker ──
  // This is exactly what setupMirrorWriterCoordination does in
  // pear-git.js:1425 when it sees a register-mirror-writers entry
  // with `issues:repo:demo: <hex>`.
  await trackerA.addWriter('mirror-b', {
    writerKey: trackerB.localKeyHex,
    role: 'owner',
    addedBy: 'a'.repeat(64)
  })

  // Wait for the ADD_WRITER op to replicate back to B
  const becameWritable = await waitFor(
    async () => trackerB.base.writable === true,
    8000
  )
  t.ok(becameWritable,
    'B.tracker.base.writable === true after A.tracker.addWriter(B.localKeyHex)')

  // B writes — HyperIssues.create returns the assigned id (integer)
  const id = await trackerB.create({ title: 'b enrolled', author: 'bob' })
  t.ok(typeof id === 'number' && id > 0, 'B.tracker.create returns an id')

  // A sees it
  const seen = await waitFor(async () => {
    const c = await trackerA.counts()
    return c.total === 2
  }, 8000)
  t.ok(seen, 'A sees B\'s issue via replication (round-trip works)')
})

test('per-org issue (mirror mode): OrgEventLog → mountOrg replicates the org ISSUES via the record autobase (linked-core #42)', async (t) => {
  // Linked-core: org issues live IN the org RECORD autobase. The mirror's
  // OrgPuller mountOrg()s that autobase from OrgEventLog's orgBootstrapKey,
  // so the org's ISSUES replicate WITH the record — no separate issue
  // tracker key conveyed or bound. Mirror-mode analog of
  // org-issues-linked-core.test.js (which proves the peer-mode path).
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-org-issue-mirror')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // Create the org through the real handler so create-org appends the
  // org-record key (orgBootstrapKey) to OrgEventLog. issueBootstrapKey is
  // null now — there is no separate tracker.
  const { Client } = await import('hyper-svc')
  const ca = await Client.connectUds(nodeA.svc.uds.path)
  await ca.call('create-org', { name: 'shared-org', type: 'group', displayName: 'Shared' })
  await ca.close()
  const orgKey = nodeA.orgs.getOrgRoom('shared-org').keyHex

  // The org issue tracker IS the org record autobase (no separate tracker).
  const trackerA = await nodeA.issueRegistry.getForOrg('shared-org')
  t.is(trackerA.keyHex, orgKey,
    'org issue tracker == org record autobase (no separate tracker)')

  // Create an ORG ISSUE in it (lives in the org record autobase).
  const id = await trackerA.create({ title: 'org issue', body: 'b', author: 'owner' })

  // B is a mirror — gets A's orgEventLogKey via origin config. No
  // mounts.orgs (that single-autobase surface is gone).
  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false,
    origin: { serverKey: 'a'.repeat(64), orgEventLogKey: nodeA.orgEventLog.keyHex }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // OrgPuller streams the log, mountOrg()s the record autobase; the org's
  // ISSUES replicate with it. B sees the issue via getForOrg — no separate
  // tracker key was ever conveyed or bound.
  const saw = await waitFor(async () => {
    const trB = await nodeB.issueRegistry.getForOrg('shared-org')
    const iss = await trB.get(id)
    return !!(iss && iss.title === 'org issue')
  }, 10000)
  t.ok(saw,
    'mirror received the org ISSUE via OrgEventLog → mountOrg (record autobase carries issues)')

  // Same autobase root, not a sibling.
  const trackerB = await nodeB.issueRegistry.getForOrg('shared-org')
  t.is(trackerB.keyHex, orgKey,
    'mirror org issue tracker == primary org record autobase')
})

test('per-org RECORD: OrgEventLog carries the org autobase key → mirror mountOrg replicates the record (#37 item 3)', async (t) => {
  // Per-org-autobase model: there is no single orgs autobase to mount.
  // The mirror-mode loop is: primary's create-org appends the org's OWN
  // autobase key to OrgEventLog; the mirror's OrgPuller mountOrg()s it,
  // so the org RECORD replicates over the shared swarm — same shape as
  // RepoEventLog/RepoPuller for repos.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-org-record-prop')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // Create the org through the real handler (UDS bypasses auth) so the
  // create-org append carries the org-record key, not just the issue key.
  const { Client } = await import('hyper-svc')
  const ca = await Client.connectUds(nodeA.svc.uds.path)
  await ca.call('create-org', { name: 'recorg', type: 'group', displayName: 'RecOrg' })
  await ca.close()
  const orgKey = nodeA.orgs.getOrgRoom('recorg').keyHex

  // B is a mirror — gets A's orgEventLogKey via origin config. No
  // mounts.orgs (that single-autobase surface is gone).
  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false,
    origin: { serverKey: 'a'.repeat(64), orgEventLogKey: nodeA.orgEventLog.keyHex }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // OrgPuller streams the log, mountOrg()s the record autobase, and the
  // record replicates over the swarm.
  const replicated = await waitFor(async () => {
    const org = await nodeB.orgs.get('recorg')
    return !!(org && org.displayName === 'RecOrg')
  }, 8000)
  t.ok(replicated, 'mirror received the org RECORD via OrgEventLog → mountOrg')

  // The mounted org is the SAME autobase root (not a sibling).
  const roomB = nodeB.orgs.getOrgRoom('recorg')
  t.ok(roomB, 'mirror tracks the org')
  t.is(roomB.keyHex, orgKey, 'mirror mounted the primary autobase root')

  // HA writer-enrollment (mechanism the mirror-writer announce automates):
  // the primary enrolls the mirror's per-org local key → the mirror becomes
  // a writer on that org and its writes replicate back.
  await nodeA.orgs.addWriter('recorg', 'mirror-b', {
    writerKey: roomB.localKeyHex, role: 'owner'
  })
  const writable = await waitFor(async () => {
    await nodeB.orgs.sync()
    const r = nodeB.orgs.getOrgRoom('recorg')
    return !!(r && r.writable)
  }, 8000)
  t.ok(writable, 'mirror becomes a writer on the org after primary addWriter')

  await nodeB.orgs.addResource('recorg', 'repo-from-mirror')
  const seenBack = await waitFor(async () => {
    await nodeA.orgs.sync()
    const o = await nodeA.orgs.get('recorg')
    return !!(o && o.resources.includes('repo-from-mirror'))
  }, 8000)
  t.ok(seenBack, 'mirror write replicated back to primary (HA symmetric writer)')
})

test('per-org scope over swarm: peer joins ONE org, a primary with 3 orgs does NOT crash, no leak (#37 item 4)', async (t) => {
  // Regression for the cross-machine crash: SwarmTransport._onConnection
  // read `orgs.base`, but the per-org getter THROWS on >1 org, aborting
  // the daemon on the first incoming connection. (Single-org tests never
  // hit it.) Fix: the transport uses orgs.replicate(conn). This also
  // proves peer-mode scope isolation over a real swarm.
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-scope-peer-swarm')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  await nodeA.orgs.create('alpha', 'group', { displayName: 'Alpha' })
  await nodeA.orgs.create('beta', 'group', {})
  await nodeA.orgs.create('gamma', 'group', {})
  await nodeA.orgs.sync()
  const { invitation } = await nodeA.orgs.createOrgInvite('alpha', { role: 'reader', swarm: nodeA.svc.swarm.swarm })

  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // Peer joins ONLY alpha. The ensuing swarm connection would, pre-fix,
  // abort nodeA via the throwing orgs.base getter.
  const joined = await nodeB.orgs.joinOrg(invitation, { swarm: nodeB.svc.swarm.swarm })
  t.is(joined.name, 'alpha', 'peer joined alpha')

  const sawAlpha = await waitFor(async () => {
    await nodeB.orgs.sync()
    return !!(await nodeB.orgs.get('alpha'))
  }, 8000)
  t.ok(sawAlpha, 'peer replicated alpha over the swarm')

  // Primary survived the incoming connection (no SIGABRT) and still has all 3.
  t.is((await nodeA.orgs.list()).length, 3, 'primary alive with 3 orgs after peer connect')

  // Scope isolation: peer never mounted beta/gamma.
  t.absent(await nodeB.orgs.get('beta'), 'beta not visible (never joined)')
  t.absent(await nodeB.orgs.get('gamma'), 'gamma not visible (never joined)')
  t.alike(nodeB.orgs.listOrgKeys().map((o) => o.name), ['alpha'], 'peer tracks only alpha')
})

test.skip('resync-from-origin writes refreshed origin-bundle.json and hot-boots OrgPuller (0.1.55) — skipped pending swarm-bootstrap plumbing in Client.connectSwarm', async (t) => {
  // Mirror was enrolled before OrgEventLog existed (origin-bundle.json
  // has no orgEventLog field). Calling pgh app resync should:
  //   1. Pull dump-keys from origin
  //   2. Persist a refreshed bundle to disk (now includes orgEventLog.coreKey)
  //   3. Hot-boot OrgPuller in-process so future orgs auto-propagate
  // No identity rotation, no daemon restart.
  const fs = await import('#node:fs')
  const path = await import('#node:path')
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-resync-bundle-test')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })
  await nodeA.orgs.create('hyper-fleeky-dev', 'group', {})
  await nodeA.issueRegistry.getForOrg('hyper-fleeky-dev')

  // STALE bundle: has orgs/identity/swarm but NO orgEventLog field.
  // Mirrors the production state of a peer that joined before 0.1.54.
  const bStorage = tmpStorage()
  fs.default.mkdirSync(bStorage, { recursive: true })
  const bundlePath = path.default.join(bStorage, 'origin-bundle.json')
  const staleBundle = {
    orgs: { bootstrapKey: nodeA.svc.bootstrapKeys.orgs },
    identity: { bootstrapKey: nodeA.svc.bootstrapKeys.identity },
    issueRegistry: { repos: {}, orgs: {} }, // empty — stale
    repoEventLog: { coreKey: nodeA.svc.bootstrapKeys.repoEventLog },
    // orgEventLog: intentionally absent
    swarm: { topic: topic.toString('hex') },
    sourceServerKey: nodeA.identity.publicKeyHex
  }
  fs.default.writeFileSync(bundlePath, JSON.stringify(staleBundle, null, 2))

  // Pass the bundle explicitly via deploymentBundle — production
  // server.js reads it from disk at startup and sets this opt; the
  // test bypasses that, so wire it directly.
  const nodeB = await startPearGit({
    storage: bStorage,
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false,
    deploymentBundle: staleBundle
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // Pre-condition: orgSync was NOT booted (no orgEventLogKey at boot).
  t.absent(nodeB.svc._orgSync, 'OrgPuller not started at boot (stale bundle)')

  // Call resync-from-origin via the daemon's UDS — same path the
  // `pgh app resync` CLI takes.
  const { Client } = await import('hyper-svc')
  const client = await Client.connectUds(nodeB.svc.uds.path)
  const result = await client.call('resync-from-origin', {})
  await client.close()

  t.ok(result.bundleWritten, 'resync reports bundleWritten=true')
  t.ok(result.orgPullerStarted, 'resync reports orgPullerStarted=true')

  // Bundle on disk now has orgEventLog.coreKey
  const refreshed = JSON.parse(fs.default.readFileSync(bundlePath, 'utf8'))
  t.ok(refreshed.orgEventLog && refreshed.orgEventLog.coreKey,
    'origin-bundle.json now contains orgEventLog.coreKey')
  t.is(refreshed.orgEventLog.coreKey, nodeA.svc.bootstrapKeys.orgEventLog,
    'orgEventLog.coreKey matches origin')

  // Identity NOT changed — still the original
  t.is(refreshed.identity.bootstrapKey, nodeA.svc.bootstrapKeys.identity,
    'identity.bootstrapKey preserved (no rotation)')

  // OrgPuller is now running in-process
  t.ok(nodeB.svc._orgSync, 'OrgPuller booted live (no restart needed)')

  // hyper-fleeky-dev's tracker key was bound + tracker opened
  t.ok(result.orgsAdded.includes('hyper-fleeky-dev'),
    'orgsAdded reports hyper-fleeky-dev')
  const trackerB = await nodeB.issueRegistry.getForOrg('hyper-fleeky-dev')
  const trackerA = await nodeA.issueRegistry.getForOrg('hyper-fleeky-dev')
  t.is(trackerB.keyHex, trackerA.keyHex,
    'mirror tracker joined origin autobase root (not sibling)')
})

test.skip('per-repo tracker: full announce-via-identity-registry flow (TODO: needs device-invite scaffold)', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const topic = b4a.alloc(32, 'pgv3-issues-mw-fix')

  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  const trackerA = await nodeA.issueRegistry.getForRepo('demo')
  await trackerA.create({ title: 'a from primary', author: 'alice' })
  const demoKey = trackerA.keyHex

  // B is configured as a mirror — gets origin config for setupMirrorWriterCoordination
  // and shares A's identityRegistry via mounts.identity.
  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { topic, swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false,
    mounts: {
      identity: { bootstrapKey: nodeA.svc.bootstrapKeys.identity },
      issueRepos: { demo: demoKey }
    },
    origin: { serverKey: 'a'.repeat(64), repoEventLogKey: 'a'.repeat(64) }
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  const trackerB = await nodeB.issueRegistry.getForRepo('demo')

  // Wait for swarm to connect both peers' identityRegistry views
  await waitFor(async () => nodeA.svc.swarm.connections >= 1, 6000)

  // Wait for B's identityRegistry to become writable (A's pre-auth
  // ADD_WRITER for orgs/identity propagates via replication first).
  // For this test we'll just announce after a short replication settle.
  await waitFor(async () => trackerB.localKeyHex && true, 4000)
  await new Promise((r) => setTimeout(r, 1000))

  // ── THE FIX in test form ─────────────────────────────────────
  // Announce B's per-repo tracker localKeyHex. This is what the
  // proposed patch in setupMirrorWriterCoordination's announce side
  // would do automatically.
  const myDeviceHex = b4a.toString(nodeB.svc.identity.publicKey, 'hex')
  const keys = {
    orgs: nodeB.orgs.localKeyHex,
    'issues:repo:demo': trackerB.localKeyHex
  }

  // B's identityRegistry must be writable for the announce to land.
  // If primary's pre-auth hasn't replicated, skip with diagnostic.
  if (!nodeB.svc.identityRegistry.base.writable) {
    t.comment('B.identityRegistry not yet writable — skipping (would need full device-invite flow)')
    t.pass('test scaffold acknowledges identity-writer setup gap')
    return
  }

  await nodeB.svc.identityRegistry.registerMirrorWriters(myDeviceHex, keys)

  // Wait for A's setupMirrorWriterCoordination watcher to enroll B
  // on the per-repo tracker. trackerB becomes writable once the
  // ADD_WRITER op replicates back.
  const enrolled = await waitFor(async () => trackerB.base.writable, 8000)
  t.ok(enrolled, 'B.tracker.base.writable becomes true after announce')

  // Now B can write
  const result = await trackerB.create({ title: 'b enrolled', author: 'bob' })
  t.is(result.title, 'b enrolled', 'B.tracker.create succeeds after enrollment')

  // A sees B's issue via replication
  const seen = await waitFor(async () => {
    const c = await trackerA.counts()
    return c.total === 2
  }, 6000)
  t.ok(seen, 'A sees B\'s issue via swarm replication')
})