| … | … | @@ -0,0 +1,95 @@ |
| 1 | // REPRO of the canary failure: in the MULTI-DID daemon path the StateBases run network:false (the |
| 2 | // daemon owns ONE swarm + store.replicate + wakeup and drives replication — see hpp device-node |
| 3 | // bootMultiDevice). So StateBaseV3's OWN swarm connection handler NEVER runs, and the __members__ |
| 4 | // re-projection connection-hook I added there NEVER FIRES. Only the promote-timer path is left. |
| 5 | // |
| 6 | // This test mirrors multi-did.test.js's daemon wiring (network:false + external store.replicate + |
| 7 | // shared wakeup) and the FIX scenario (a single projectMembers call that NO-OPS at first because the |
| 8 | // creator isn't writable yet). It asserts what the canary saw: the member is NOT admitted via the |
| 9 | // connection-hook (it can't fire). The fix is to ALSO drive _ensureMembersProjected from the daemon's |
| 10 | // connection handler (device-node) AND make the timer path robust. |
| 11 | |
| 12 | import '../lib/bare-globals.js' |
| 13 | import test from 'brittle' |
| 14 | import crypto from 'hypercore-crypto' |
| 15 | import fs from 'bare-fs' |
| 16 | import b4a from 'b4a' |
| 17 | import Hyperswarm from 'hyperswarm' |
| 18 | import ProtomuxWakeup from 'protomux-wakeup' |
| 19 | import createTestnet from '@hyperswarm/testnet' |
| 20 | import { StateBaseV3 } from '../lib/state-base.js' |
| 21 | import { openCorestore } from 'atproto-store/lib/corestore.js' |
| 22 | import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js' |
| 23 | |
| 24 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 25 | const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow' |
| 26 | const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' |
| 27 | const tmp = () => { const d = '/tmp/hpp-mdid-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } |
| 28 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) |
| 29 | |
| 30 | async function setup () { |
| 31 | const rootPub = await rootIdentityPublicKey(ROOT) |
| 32 | const da = await deviceKeetKeyPair(DEVA); const pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey }) |
| 33 | const db = await deviceKeetKeyPair(DEVB); const pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey }) |
| 34 | let A = { dev: da, proof: pa }, B = { dev: db, proof: pb } |
| 35 | if (b4a.compare(A.dev.publicKey, B.dev.publicKey) > 0) { const t = A; A = B; B = t } |
| 36 | const members = [{ key: b4a.toString(A.dev.publicKey, 'hex'), role: 1 }, { key: b4a.toString(B.dev.publicKey, 'hex'), role: 1 }] |
| 37 | const roomKey = crypto.randomBytes(32); const roomSeed = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32) |
| 38 | // network:false — exactly the multi-DID daemon mode. NO StateBase swarm ⇒ NO connection-hook. |
| 39 | const mk = (dir, dev, proof, store, wakeup) => new StateBaseV3(null, roomKey, { |
| 40 | store, wakeup, network: false, roomSeed, encryptionKey, |
| 41 | selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub), |
| 42 | getMembers: async () => members, promoteRetryMs: 2000 |
| 43 | }) |
| 44 | return { A, B, members, mk, roomKey } |
| 45 | } |
| 46 | |
| 47 | // daemon-style wiring: ONE swarm, store.replicate + wakeup.addStream per conn (like device-node bootMultiDevice) |
| 48 | function wireDaemon (swarm, store, wakeup) { |
| 49 | swarm.on('connection', (conn) => { |
| 50 | conn.on('error', () => {}) |
| 51 | try { wakeup.addStream(conn) } catch {} |
| 52 | try { store.replicate(conn) } catch {} |
| 53 | }) |
| 54 | } |
| 55 | |
| 56 | test('REPRO: multi-DID network:false ⇒ connection-hook cannot fire; timer path alone must admit', { timeout: 120000 }, async (t) => { |
| 57 | const testnet = await createTestnet(3) |
| 58 | const { members, mk, A, B, roomKey } = await setup() |
| 59 | const storeC = openCorestore(tmp()); await storeC.ready(); const wkC = new ProtomuxWakeup() |
| 60 | const storeM = openCorestore(tmp()); await storeM.ready(); const wkM = new ProtomuxWakeup() |
| 61 | const swarmC = new Hyperswarm({ bootstrap: testnet.bootstrap }) |
| 62 | const swarmM = new Hyperswarm({ bootstrap: testnet.bootstrap }) |
| 63 | |
| 64 | const sbA = mk('C', A.dev, A.proof, storeC, wkC) |
| 65 | // single projectMembers BEFORE ready (no-op; retains roster) — models the creator not-writable-yet. |
| 66 | await sbA.projectMembers(members) |
| 67 | await sbA.ready() |
| 68 | const sbB = mk('M', B.dev, B.proof, storeM, wkM); await sbB.ready() |
| 69 | |
| 70 | wireDaemon(swarmC, storeC, wkC) |
| 71 | wireDaemon(swarmM, storeM, wkM) |
| 72 | swarmC.join(sbA.base.discoveryKey, { server: true, client: true }) |
| 73 | swarmM.join(sbA.base.discoveryKey, { server: true, client: true }) |
| 74 | await swarmC.flush().catch(() => {}); await swarmM.flush().catch(() => {}) |
| 75 | t.teardown(async () => { try { await sbA.close(); await sbB.close(); await swarmC.destroy(); await swarmM.destroy() } catch {} ; await testnet.destroy() }) |
| 76 | |
| 77 | // Give the timer path (2s promoteRetryMs) chances + drive updates like the daemon does. Log the |
| 78 | // creator's __members__/projected state each round so we see whether the TIMER ever projects. |
| 79 | let admitted = false |
| 80 | for (let i = 0; i < 12 && !admitted; i++) { |
| 81 | const r = await sbB.requestWriter({ timeoutMs: 2000 }).catch(() => ({ writable: false })) |
| 82 | admitted = !!r.writable |
| 83 | const mv = await sbA.getNow('__members__').catch(() => null) |
| 84 | console.log(`[repro] round ${i}: creatorWritable=${sbA.base && sbA.base.writable} __members__=${mv ? 'present' : 'ABSENT'} membersProjected=${sbA._membersProjected} roster=${sbA._roster ? sbA._roster.length : 'null'} memberAdmitted=${admitted}`) |
| 85 | if (!admitted) { await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(500) } |
| 86 | } |
| 87 | const membersInView = await sbA.get('__members__').catch(() => null) |
| 88 | console.log('[repro] __members__ in creator view:', membersInView ? Object.keys(membersInView || {}).length : 'ABSENT', '| member admitted:', admitted) |
| 89 | t.comment('membersInView=' + JSON.stringify(membersInView) + ' membersProjected=' + sbA._membersProjected + ' writable=' + (sbA.base && sbA.base.writable)) |
| 90 | |
| 91 | // This is the DIAGNOSIS assertion — record the actual behavior. If admitted=true the timer path |
| 92 | // suffices (bug is elsewhere); if false, the connection-hook was load-bearing and multi-DID needs |
| 93 | // the daemon to drive _ensureMembersProjected. |
| 94 | t.ok(true, 'diagnostic recorded: admitted=' + admitted + ' __members__=' + (membersInView ? 'present' : 'ABSENT')) |
| 95 | }) |