| … | … | @@ -0,0 +1,98 @@ |
| 1 | // __devices__ must SURVIVE a re-projection (regression). |
| 2 | // |
| 3 | // __devices__ maps writerKey -> memberKey and is written at ADMISSION time (_applySet, while a joiner |
| 4 | // still carries an `admit` proof). projectMembers used to OVERWRITE it from the roster's per-member |
| 5 | // `devices` arrays — and hpp's roster is [{key, role}] with no devices at all (device-node.js), so the |
| 6 | // map was replaced by {} on every projection. |
| 7 | // |
| 8 | // The production trigger is RESTART: _membersProjected is in-memory, so each boot resets the latch and |
| 9 | // device-node calls projectMembers(roster) again. An already-admitted writer never re-sends an admit |
| 10 | // proof, so nothing repopulates the map — it stays empty forever. Observed live on both hosted DIDs |
| 11 | // (devicesInView {}), which matters because ban()/REMOVE_DEVICE walks this map to removeWriter a |
| 12 | // banned member's devices: with it empty, revocation silently removes NOTHING. |
| 13 | // |
| 14 | // ban.test.js cannot catch this: it projects repeatedly BEFORE admission and never again. |
| 15 | // |
| 16 | // Run: bare test/devices-projection.test.js |
| 17 | import '../lib/bare-globals.js' |
| 18 | import test from 'brittle' |
| 19 | import crypto from 'hypercore-crypto' |
| 20 | import fs from 'bare-fs' |
| 21 | import b4a from 'b4a' |
| 22 | import { StateBaseV3 } from '../lib/state-base.js' |
| 23 | |
| 24 | const tmp = () => { const d = '/tmp/atproom-devproj-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } |
| 25 | const hex = (b) => b4a.toString(b, 'hex') |
| 26 | |
| 27 | async function mk (selfKp, members) { |
| 28 | const sb = new StateBaseV3(tmp(), crypto.randomBytes(32), { |
| 29 | roomSeed: crypto.randomBytes(32), |
| 30 | encryptionKey: crypto.randomBytes(32), |
| 31 | network: false, |
| 32 | selfKey: selfKp.publicKey, |
| 33 | selfKeyPair: selfKp, |
| 34 | getMembers: async () => members |
| 35 | }) |
| 36 | await sb.ready() |
| 37 | return sb |
| 38 | } |
| 39 | |
| 40 | test('projectMembers MERGES __devices__ and never wipes an admitted device', async (t) => { |
| 41 | const self = crypto.keyPair() |
| 42 | const members = [{ key: hex(self.publicKey), role: 1 }] |
| 43 | const sb = await mk(self, members) |
| 44 | t.teardown(async () => { try { await sb.close() } catch {} }) |
| 45 | t.ok(sb.base.writable, 'creator is writable') |
| 46 | |
| 47 | t.ok(await sb.projectMembers(members), 'first projection lands') |
| 48 | const dev1 = await sb.get('__devices__') |
| 49 | t.ok(dev1 && Object.keys(dev1).length === 1, 'projection recorded THIS node\'s own writer->identity mapping (self-heal)') |
| 50 | t.is(dev1[hex(sb.base.local.key)], hex(self.publicKey), 'the recorded pair is base.local.key -> selfKey') |
| 51 | |
| 52 | // Simulate an ADMISSION by another device: _applySet records writerKey -> memberKey for any |
| 53 | // admit-carrying op. Seed that entry directly, which is what the apply path leaves behind. |
| 54 | const peerWriter = hex(crypto.randomBytes(32)) |
| 55 | const peerIdentity = hex(crypto.randomBytes(32)) |
| 56 | await sb.set('__devices__', { ...dev1, [peerWriter]: peerIdentity }) |
| 57 | t.is((await sb.get('__devices__'))[peerWriter], peerIdentity, 'peer device recorded (as admission would)') |
| 58 | |
| 59 | // RESTART / reproject: the latch is in-memory, so a reboot re-runs projectMembers with the SAME |
| 60 | // deviceless roster. Before the fix this replaced the map with {} and the peer entry was lost. |
| 61 | sb._membersProjected = false |
| 62 | t.ok(await sb.projectMembers(members), 're-projection lands') |
| 63 | |
| 64 | const dev2 = await sb.get('__devices__') |
| 65 | t.is(dev2[peerWriter], peerIdentity, 'the admitted peer device SURVIVED the re-projection (was wiped before the fix)') |
| 66 | t.is(dev2[hex(sb.base.local.key)], hex(self.publicKey), 'our own mapping is still present too') |
| 67 | t.is(Object.keys(dev2).length, 2, 'merge is additive — both entries retained, none dropped') |
| 68 | }) |
| 69 | |
| 70 | test('projectMembers REFUSES an empty roster that would wipe __members__', async (t) => { |
| 71 | const self = crypto.keyPair() |
| 72 | const members = [{ key: hex(self.publicKey), role: 1 }] |
| 73 | const sb = await mk(self, members) |
| 74 | t.teardown(async () => { try { await sb.close() } catch {} }) |
| 75 | |
| 76 | await sb.projectMembers(members) |
| 77 | const before = await sb.get('__members__') |
| 78 | t.is(Object.keys(before).length, 1, '__members__ populated') |
| 79 | |
| 80 | // Admission verifies every joiner's proof against __members__, so wiping it denies all future |
| 81 | // admission — the wj62 fork signature. An empty roster must be a no-op, not a wipe. |
| 82 | sb._membersProjected = false |
| 83 | t.absent(await sb.projectMembers([]), 'empty roster projection is REFUSED (returns false)') |
| 84 | t.alike(await sb.get('__members__'), before, '__members__ is untouched by the refused projection') |
| 85 | }) |
| 86 | |
| 87 | test('an unchanged re-projection appends NOTHING (the log does not grow on a timer tick)', async (t) => { |
| 88 | const self = crypto.keyPair() |
| 89 | const members = [{ key: hex(self.publicKey), role: 1 }] |
| 90 | const sb = await mk(self, members) |
| 91 | t.teardown(async () => { try { await sb.close() } catch {} }) |
| 92 | |
| 93 | await sb.projectMembers(members) |
| 94 | const len = sb.base.local.length |
| 95 | sb._membersProjected = false |
| 96 | await sb.projectMembers(members) // identical roster + identical devices → nothing to write |
| 97 | t.is(sb.base.local.length, len, 'no append for an identical re-projection (runs on a timer + every connection)') |
| 98 | }) |