OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-device-pairing.test.js

324 lines · javascript ref:
import test from 'brittle'
import {
  DEVICE_NAME_MAX_LENGTH,
  MAX_DEVICE_LIST_ENTRIES,
  devicePersistFailureResult,
  normalizeDeviceName,
  resolveDevicePairingManagementRoute,
  runDeviceManagementAction,
  runDevicePairingManagementRouteAction,
  runPairingManagementAction,
  sanitizeDeviceList
} from '../../packages/core/core/relay-node/api-device-pairing.js'

test('api device pairing: route helper maps exact device and pairing mutation routes', (t) => {
  t.alike(resolveDevicePairingManagementRoute('POST', '/api/manage/devices'), {
    kind: 'device-management'
  })
  t.alike(resolveDevicePairingManagementRoute('POST', '/api/manage/pairing'), {
    kind: 'pairing-management'
  })

  t.is(resolveDevicePairingManagementRoute('GET', '/api/manage/devices'), null)
  t.is(resolveDevicePairingManagementRoute('POST', '/api/manage/device'), null)
  t.is(resolveDevicePairingManagementRoute('POST', '/api/manage/pairing/start'), null)
})

test('api device pairing: route action helper dispatches device and pairing mutations', async (t) => {
  const node = {
    mode: 'private',
    accessControl: {
      isPairing: true,
      _pairingState: { expiresAt: 123 }
    },
    listDevices: () => [{
      pubkey: 'A'.repeat(64),
      name: '  Operator phone  ',
      pairedAt: 1,
      lastSeen: 2,
      secretToken: 'do-not-leak'
    }]
  }

  let result = await runDevicePairingManagementRouteAction({
    route: resolveDevicePairingManagementRoute('POST', '/api/manage/devices'),
    body: { action: 'list' },
    node
  })
  t.is(result.ok, true)
  t.is(result.payload.count, 1)
  t.is(result.payload.devices[0].pubkey, 'a'.repeat(64))
  t.absent(result.payload.devices[0].secretToken)

  result = await runDevicePairingManagementRouteAction({
    route: resolveDevicePairingManagementRoute('POST', '/api/manage/pairing'),
    body: { action: 'status' },
    node
  })
  t.is(result.ok, true)
  t.alike(result.payload, { ok: true, active: true, expiresAt: 123 })

  const unknown = await runDevicePairingManagementRouteAction({
    route: { kind: 'unknown' },
    body: {},
    node
  })
  t.is(unknown.status, 404)
  t.alike(unknown.payload, { error: 'unknown device/pairing management route' })
})

test('api device pairing: rejects unavailable access-control modes', async (t) => {
  const node = { mode: 'public' }

  const devices = await runDeviceManagementAction({ body: { action: 'list' }, node })
  t.is(devices.status, 400)
  t.alike(devices.payload, {
    error: 'Access control is not active in current mode',
    mode: 'public'
  })

  const pairing = runPairingManagementAction({ body: { action: 'status' }, node })
  t.is(pairing.status, 400)
  t.alike(pairing.payload, {
    error: 'Pairing is not available in current mode',
    mode: 'public'
  })
})

test('api device pairing: normalizes device names and canonicalizes pubkeys', async (t) => {
  t.is(DEVICE_NAME_MAX_LENGTH, 80)
  t.alike(normalizeDeviceName('  Phone  '), { ok: true, value: 'Phone' })
  t.alike(normalizeDeviceName(''), { ok: true, value: 'manual' })
  t.alike(normalizeDeviceName({ label: 'Phone' }), { ok: false, error: 'name must be a string' })
  t.alike(normalizeDeviceName('bad\nname'), { ok: false, error: 'name must not contain control characters' })
  t.alike(normalizeDeviceName('x'.repeat(81)), {
    ok: false,
    error: 'name exceeds max length (80)'
  })

  const devices = new Map()
  const node = {
    mode: 'private',
    accessControl: {},
    listDevices: () => Array.from(devices.values()),
    async addDevice (pubkey, name) {
      devices.set(pubkey, { pubkey, name, pairedAt: 1, lastSeen: null })
    },
    async removeDevice (pubkey) {
      if (!devices.has(pubkey)) throw new Error('Device not in allowlist')
      devices.delete(pubkey)
    }
  }

  const added = await runDeviceManagementAction({
    body: { action: 'add', pubkey: 'A'.repeat(64), name: '  Operator phone  ' },
    node
  })
  t.is(added.ok, true)
  t.is(added.payload.pubkey, 'a'.repeat(64))
  t.is(added.payload.name, 'Operator phone')
  t.is(devices.get('a'.repeat(64)).name, 'Operator phone')

  const listed = await runDeviceManagementAction({ body: { action: 'list' }, node })
  t.is(listed.payload.count, 1)
  t.is(listed.payload.total, 1)
  t.absent(listed.payload.truncated)
  t.is(listed.payload.devices[0].pubkey, 'a'.repeat(64))

  const removed = await runDeviceManagementAction({
    body: { action: 'remove', pubkey: 'A'.repeat(64) },
    node
  })
  t.is(removed.ok, true)
  t.is(removed.payload.pubkey, 'a'.repeat(64))
  t.is(devices.size, 0)
})

test('api device pairing: list action sanitizes persisted device rows', async (t) => {
  const devices = [
    {
      pubkey: 'A'.repeat(64),
      name: '  Operator phone  ',
      pairedAt: 1,
      lastSeen: 2,
      token: 'do-not-leak'
    },
    {
      pubkey: 'B'.repeat(64),
      name: 'bad\nname',
      pairedAt: -1,
      lastSeen: Infinity,
      privateKey: 'hidden'
    },
    {
      pubkey: 'not-a-key',
      name: 'ignored'
    }
  ]
  for (let i = 0; i < MAX_DEVICE_LIST_ENTRIES + 4; i++) {
    devices.push({
      pubkey: (i % 16).toString(16).repeat(64),
      name: 'device-' + i,
      pairedAt: i,
      lastSeen: i + 1
    })
  }
  const node = {
    mode: 'private',
    accessControl: {},
    listDevices: () => devices
  }

  const clean = sanitizeDeviceList(devices)
  t.is(clean.length, MAX_DEVICE_LIST_ENTRIES)
  t.alike(clean[0], {
    pubkey: 'a'.repeat(64),
    name: 'Operator phone',
    pairedAt: 1,
    lastSeen: 2
  })
  t.alike(clean[1], {
    pubkey: 'b'.repeat(64),
    name: 'manual',
    pairedAt: null,
    lastSeen: null
  })
  t.absent(JSON.stringify(clean).includes('do-not-leak'))
  t.absent(JSON.stringify(clean).includes('privateKey'))

  const listed = await runDeviceManagementAction({ body: { action: 'list' }, node })
  t.is(listed.payload.count, MAX_DEVICE_LIST_ENTRIES)
  t.is(listed.payload.total, MAX_DEVICE_LIST_ENTRIES + 7)
  t.ok(listed.payload.truncated)
  t.absent(JSON.stringify(listed.payload).includes('do-not-leak'))
})

test('api device pairing: separates validation, operator, and persistence errors', async (t) => {
  const node = {
    mode: 'private',
    accessControl: {},
    listDevices: () => [],
    async addDevice () {},
    async removeDevice () {}
  }

  const malformed = await runDeviceManagementAction({
    body: { action: 'add', pubkey: 'xyz' },
    node
  })
  t.is(malformed.status, 400)
  t.alike(malformed.payload, { error: 'pubkey must be 64 hex characters' })

  node.addDevice = async () => { throw new Error('Maximum devices reached (50)') }
  const maxDevices = await runDeviceManagementAction({
    body: { action: 'add', pubkey: 'a'.repeat(64), name: 'phone' },
    node
  })
  t.is(maxDevices.status, 400)
  t.alike(maxDevices.payload, { error: 'Maximum devices reached (50)' })

  node.removeDevice = async () => { throw new Error('Device not in allowlist') }
  const missing = await runDeviceManagementAction({
    body: { action: 'remove', pubkey: 'b'.repeat(64) },
    node
  })
  t.is(missing.status, 400)
  t.alike(missing.payload, { error: 'Device not in allowlist' })

  const diskError = new Error('disk full')
  node.addDevice = async () => { throw diskError }
  const events = []
  const persist = await runDeviceManagementAction({
    body: { action: 'add', pubkey: 'c'.repeat(64), name: 'phone' },
    node,
    emit: (event, payload) => events.push({ event, payload })
  })
  t.is(persist.ok, false)
  t.is(persist.kind, 'device-persist')
  t.is(persist.status, 500)
  t.is(persist.payload.errorCode, 'persist-failed')
  t.ok(persist.payload.error.startsWith('persist-failed: '), 'public payload is stable and prefixed')
  t.absent(persist.payload.error.includes('disk full'), 'public payload does not leak local storage error')
  t.is(events.length, 1)
  t.is(events[0].event, 'device-persist-error')
  t.is(events[0].payload.message, 'disk full')
  t.is(events[0].payload.error, diskError)
})

test('api device pairing: device persist failure mapper emits internal diagnostics', (t) => {
  const err = new Error('readonly allowlist')
  const events = []
  const out = devicePersistFailureResult({
    error: err,
    emit: (event, payload) => events.push({ event, payload })
  })

  t.is(out.ok, false)
  t.is(out.kind, 'device-persist')
  t.is(out.status, 500)
  t.is(out.payload.errorCode, 'persist-failed')
  t.ok(out.payload.error.startsWith('persist-failed: '))
  t.absent(out.payload.error.includes('readonly allowlist'))
  t.alike(events, [{
    event: 'device-persist-error',
    payload: {
      message: 'readonly allowlist',
      error: err
    }
  }])
})

test('api device pairing: validates pairing actions before state changes', async (t) => {
  let enabled = 0
  let disabled = 0
  const node = {
    mode: 'private',
    accessControl: {
      isPairing: false,
      _pairingState: null,
      disablePairing () {
        disabled++
      }
    },
    enablePairing (opts = {}) {
      enabled++
      this.accessControl.isPairing = true
      this.accessControl._pairingState = { expiresAt: 12345 }
      return { token: 'a'.repeat(32), expiresAt: 12345, timeoutMs: opts.timeoutMs }
    }
  }

  const malformed = runPairingManagementAction({
    body: { action: 'start', timeoutMs: '1e3' },
    node
  })
  t.is(malformed.status, 400)
  t.alike(malformed.payload, { error: 'timeoutMs must be a valid integer' })
  t.is(enabled, 0)

  const outOfRange = runPairingManagementAction({
    body: { action: 'start', timeoutMs: 0 },
    node
  })
  t.is(outOfRange.status, 400)
  t.alike(outOfRange.payload, { error: 'timeoutMs must be between 10000 and 1800000' })
  t.is(enabled, 0)

  const started = runPairingManagementAction({
    body: { action: 'start', timeoutMs: '10000' },
    node
  })
  t.is(started.ok, true)
  t.is(started.payload.timeoutMs, 10000)
  t.is(started.payload.active, true)
  t.is(enabled, 1)

  const status = runPairingManagementAction({ body: { action: 'status' }, node })
  t.is(status.payload.active, true)
  t.is(status.payload.expiresAt, 12345)

  const stopped = runPairingManagementAction({ body: { action: 'stop' }, node })
  t.is(stopped.payload.active, false)
  t.is(disabled, 1)
})