OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / coowner-members-string.test.mjs

95 lines · javascript ref:
// CONTROL: a comma-string roster must survive `coowner-invite`.
//
// wj62 in production stores its roster as a comma-separated STRING of two device pubkeys, not an array:
//
//   "members": "71555d85…3583,81e1cbbf…5715"
//
// The BOOT path copes — `config.js normalizeMembers` accepts "a comma-string of hex pubkeys or an array".
// The INVITE path does not: `coowner.js` reads the file with its own raw `JSON.parse` (it imports only
// `configPath` from config.js), and `addMember` gates on `Array.isArray(entry.members)`. A string fails
// that test, falls through to `[]`, reports `needsSelf`, and `coownerInvite` then rewrites the roster as
// `[self, joiner]` — silently DROPPING every other device already in it.
//
// That is a roster truncation on a live DID, performed by the command whose whole job is to grow the
// roster, and it does not error. Test 1 is the CONTROL — it reproduces the truncation before any fix, so
// the fix is measured against a real failure rather than an assumption. Test 2 pins the array case that
// already works, so the fix cannot regress it.
//
// Run: bare test/coowner-members-string.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
import { coownerInvite } from '../lib/coowner.js'

const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
// A THIRD device that is already in the roster and has nothing to do with this invite. It must still be
// there afterwards — this is the device wj62 would have lost.
const OTHER_DEV = 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above'
const DID = 'did:plc:coowner00000000000000000'
const SIGNING = 'a'.repeat(64)
const JOINER = 'f'.repeat(64)

const tmpdir = () => { const d = '/tmp/hpp-members-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))

// `members` is written EXACTLY as the caller gives it — string or array — to mirror what is on disk.
async function hostConfig (dir, members) {
  const dev = await deviceKeetKeyPair(HOST_DEV)
  const other = await deviceKeetKeyPair(OTHER_DEV)
  const proof = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: dev.publicKey })
  const p = dir + '/config.json'
  fs.writeFileSync(p, JSON.stringify({
    storage: dir + '/data',
    swarm: true,
    dids: [{
      did: DID,
      handle: 'coowner.test',
      pdsUrl: 'https://pds.test',
      storeBackend: 'overlay',
      deviceMnemonic: HOST_DEV,
      deviceProof: hex(proof),
      signingKey: SIGNING,
      rootIdentityKey: hex(await rootIdentityPublicKey(ROOT)),
      members: members(hex(dev.publicKey), hex(other.publicKey))
    }]
  }, null, 2))
  return { path: p, selfHex: hex(dev.publicKey), otherHex: hex(other.publicKey) }
}

const rosterOf = (entry) => (Array.isArray(entry.members) ? entry.members : String(entry.members || '').split(','))
  .map((m) => String(typeof m === 'object' && m ? m.key : m).trim().toLowerCase())
  .filter(Boolean)

test('CONTROL — a comma-string roster is not truncated by the invite', async (t) => {
  const dir = tmpdir()
  const { path, selfHex, otherHex } = await hostConfig(dir, (self, other) => self + ',' + other)

  const r = await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: path })

  const written = rosterOf(readJson(path).dids[0])
  t.ok(written.includes(otherHex), 'the third device SURVIVES the invite — roster was ' + written.length +
    ' entries, expected 3 (self, other, joiner); dropping ' + otherHex.slice(0, 16) + '… is the truncation')
  t.ok(written.includes(selfHex), 'the inviting device is still in its own roster')
  t.ok(written.includes(JOINER), 'the joiner was added')
  t.is(written.length, 3, 'exactly three devices')

  // The bundle is what the JOINER writes into its own config, so a truncation here propagates.
  const bundled = rosterOf({ members: r.bundle.members })
  t.ok(bundled.includes(otherHex), 'the bundle handed to the joiner also keeps the third device')
})

test('an ARRAY roster still works — the fix must not regress the shape that already worked', async (t) => {
  const dir = tmpdir()
  const { path, selfHex, otherHex } = await hostConfig(dir, (self, other) => [self, other])

  await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: path })

  const written = rosterOf(readJson(path).dids[0])
  t.is(written.length, 3, 'three devices')
  t.ok(written.includes(otherHex) && written.includes(selfHex) && written.includes(JOINER), 'all three present')
})