OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / device-linker.test.js

82 lines · javascript ref:
// End-to-end + unit test for backend/device-linker.js (blind-pairing device
// linking, adopted from hyper-identity and hardened with an approval gate).
//
// device-linker.js is CommonJS and depends only on blind-pairing + b4a +
// bip39-mnemonic (all Node-loadable), so unlike identity.js it CAN be imported
// under Node and exercised over a real hyperdht testnet with two Hyperswarms.
import test from 'node:test'
import assert from 'node:assert/strict'
import createTestnet from 'hyperdht/testnet.js'
import Hyperswarm from 'hyperswarm'
import bip39 from 'bip39-mnemonic'
import b4a from 'b4a'
import linkerMod from '../backend/device-linker.js'

const { DeviceLinker } = linkerMod

// a stub Identity: exposes the 32-byte entropy and records what gets restored
function stubIdentity (entropy) {
  return {
    _restored: null,
    getEntropy () { return b4a.from(entropy) },
    async restoreFromMnemonic (m) { this._restored = m }
  }
}

test('approval gate fails closed (no autoAccept, no onRequest => deny)', async () => {
  const l = new DeviceLinker({}, { identity: stubIdentity(b4a.alloc(32)) })
  assert.equal(await l._shouldApprove({ device: 'x' }), false)

  const l2 = new DeviceLinker({}, { identity: stubIdentity(b4a.alloc(32)), autoAccept: true })
  assert.equal(await l2._shouldApprove({ device: 'x' }), true)

  let sawInfo = null
  const l3 = new DeviceLinker({}, {
    identity: stubIdentity(b4a.alloc(32)),
    onRequest: async (info) => { sawInfo = info; return info.device === 'trusted' }
  })
  assert.equal(await l3._shouldApprove({ device: 'trusted' }), true)
  assert.equal(await l3._shouldApprove({ device: 'stranger' }), false)
  assert.deepEqual(sawInfo, { device: 'stranger' })
})

test('createInvite rejects a non-32-byte (pre-v2) identity', async () => {
  const l = new DeviceLinker({}, { identity: stubIdentity(b4a.alloc(16)) })
  await assert.rejects(() => l.createInvite(), /32-byte/)
})

test('end-to-end: root seed transfers source -> target over a testnet', async () => {
  const testnet = await createTestnet(3)
  const bootstrap = testnet.bootstrap

  // real 24-word identity on the source device
  const entropy = b4a.from(bip39.mnemonicToEntropy(bip39.generateMnemonic()))
  const expectedMnemonic = bip39.entropyToMnemonic(entropy)

  const srcSwarm = new Hyperswarm({ bootstrap })
  const tgtSwarm = new Hyperswarm({ bootstrap })

  const srcId = stubIdentity(entropy)
  const tgtId = stubIdentity(b4a.alloc(32)) // target starts with a throwaway identity

  const source = new DeviceLinker(srcSwarm, { identity: srcId, autoAccept: true, poll: 1000 })
  const target = new DeviceLinker(tgtSwarm, { identity: tgtId, poll: 1000 })

  try {
    const { invite, done } = await source.createInvite()
    const joined = await target.joinWithInvite(invite, { device: 'laptop' })

    await done // source confirms the transfer completed

    assert.equal(joined.mnemonic, expectedMnemonic, 'target reconstructed the source phrase')
    assert.equal(joined.restartRequired, true)
    assert.equal(tgtId._restored, expectedMnemonic, 'target adopted the linked identity')
  } finally {
    await source.close().catch(() => {})
    await target.close().catch(() => {})
    await srcSwarm.destroy().catch(() => {})
    await tgtSwarm.destroy().catch(() => {})
    await testnet.destroy().catch(() => {})
  }
})