OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / test / devices-projection.test.js

99 lines · javascript ref:
// __devices__ must SURVIVE a re-projection (regression).
//
// __devices__ maps writerKey -> memberKey and is written at ADMISSION time (_applySet, while a joiner
// still carries an `admit` proof). projectMembers used to OVERWRITE it from the roster's per-member
// `devices` arrays — and hpp's roster is [{key, role}] with no devices at all (device-node.js), so the
// map was replaced by {} on every projection.
//
// The production trigger is RESTART: _membersProjected is in-memory, so each boot resets the latch and
// device-node calls projectMembers(roster) again. An already-admitted writer never re-sends an admit
// proof, so nothing repopulates the map — it stays empty forever. Observed live on both hosted DIDs
// (devicesInView {}), which matters because ban()/REMOVE_DEVICE walks this map to removeWriter a
// banned member's devices: with it empty, revocation silently removes NOTHING.
//
// ban.test.js cannot catch this: it projects repeatedly BEFORE admission and never again.
//
// Run: bare test/devices-projection.test.js
import '../lib/bare-globals.js'
import test from 'brittle'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import b4a from 'b4a'
import { StateBaseV3 } from '../lib/state-base.js'

const tmp = () => { const d = '/tmp/atproom-devproj-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const hex = (b) => b4a.toString(b, 'hex')

async function mk (selfKp, members) {
  const sb = new StateBaseV3(tmp(), crypto.randomBytes(32), {
    roomSeed: crypto.randomBytes(32),
    encryptionKey: crypto.randomBytes(32),
    network: false,
    selfKey: selfKp.publicKey,
    selfKeyPair: selfKp,
    getMembers: async () => members
  })
  await sb.ready()
  return sb
}

test('projectMembers MERGES __devices__ and never wipes an admitted device', async (t) => {
  const self = crypto.keyPair()
  const members = [{ key: hex(self.publicKey), role: 1 }]
  const sb = await mk(self, members)
  t.teardown(async () => { try { await sb.close() } catch {} })
  t.ok(sb.base.writable, 'creator is writable')

  t.ok(await sb.projectMembers(members), 'first projection lands')
  const dev1 = await sb.get('__devices__')
  t.ok(dev1 && Object.keys(dev1).length === 1, 'projection recorded THIS node\'s own writer->identity mapping (self-heal)')
  t.is(dev1[hex(sb.base.local.key)], hex(self.publicKey), 'the recorded pair is base.local.key -> selfKey')

  // Simulate an ADMISSION by another device: _applySet records writerKey -> memberKey for any
  // admit-carrying op. Seed that entry directly, which is what the apply path leaves behind.
  const peerWriter = hex(crypto.randomBytes(32))
  const peerIdentity = hex(crypto.randomBytes(32))
  await sb.set('__devices__', { ...dev1, [peerWriter]: peerIdentity })
  t.is((await sb.get('__devices__'))[peerWriter], peerIdentity, 'peer device recorded (as admission would)')

  // RESTART / reproject: the latch is in-memory, so a reboot re-runs projectMembers with the SAME
  // deviceless roster. Before the fix this replaced the map with {} and the peer entry was lost.
  sb._membersProjected = false
  t.ok(await sb.projectMembers(members), 're-projection lands')

  const dev2 = await sb.get('__devices__')
  t.is(dev2[peerWriter], peerIdentity, 'the admitted peer device SURVIVED the re-projection (was wiped before the fix)')
  t.is(dev2[hex(sb.base.local.key)], hex(self.publicKey), 'our own mapping is still present too')
  t.is(Object.keys(dev2).length, 2, 'merge is additive — both entries retained, none dropped')
})

test('projectMembers REFUSES an empty roster that would wipe __members__', async (t) => {
  const self = crypto.keyPair()
  const members = [{ key: hex(self.publicKey), role: 1 }]
  const sb = await mk(self, members)
  t.teardown(async () => { try { await sb.close() } catch {} })

  await sb.projectMembers(members)
  const before = await sb.get('__members__')
  t.is(Object.keys(before).length, 1, '__members__ populated')

  // Admission verifies every joiner's proof against __members__, so wiping it denies all future
  // admission — the wj62 fork signature. An empty roster must be a no-op, not a wipe.
  sb._membersProjected = false
  t.absent(await sb.projectMembers([]), 'empty roster projection is REFUSED (returns false)')
  t.alike(await sb.get('__members__'), before, '__members__ is untouched by the refused projection')
})

test('an unchanged re-projection appends NOTHING (the log does not grow on a timer tick)', async (t) => {
  const self = crypto.keyPair()
  const members = [{ key: hex(self.publicKey), role: 1 }]
  const sb = await mk(self, members)
  t.teardown(async () => { try { await sb.close() } catch {} })

  await sb.projectMembers(members)
  const len = sb.base.local.length
  sb._membersProjected = false
  await sb.projectMembers(members) // identical roster + identical devices → nothing to write
  t.is(sb.base.local.length, len, 'no append for an identical re-projection (runs on a timer + every connection)')
})