OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / privacy-policy.test.js

127 lines · javascript ref:
import test from 'brittle'
import {
  validateIntent,
  resolvePath,
  candidatesFromCapabilityDoc,
  DEFAULT_INTENT,
  PATH_DIRECT,
  PATH_FORWARD_RELAY,
  PATH_TOR_ONION,
  PATH_NYM_MIXNET
} from 'p2p-hiverelay-client/privacy-policy.js'

const directPath = { id: PATH_DIRECT, available: true, transportPrivacy: 'direct', relayLocation: 'exposed', coverTraffic: false, coverage: 'full' }
const forwardPath = { id: PATH_FORWARD_RELAY, available: true, transportPrivacy: 'source-ip-hidden', relayLocation: 'hidden-1hop', coverTraffic: false, coverage: 'full' }
const onionPath = { id: PATH_TOR_ONION, available: true, transportPrivacy: 'source-ip-hidden', relayLocation: 'hidden-onion', coverTraffic: false, coverage: 'full' }
const nymPath = { id: PATH_NYM_MIXNET, available: true, transportPrivacy: 'traffic-analysis-resistant', relayLocation: 'hidden-mixnet', coverTraffic: true, coverage: 'control-only' }
const ALL = [directPath, forwardPath, onionPath, nymPath]

test('validateIntent rejects unknown axis values', async (t) => {
  t.exception(() => validateIntent({ transportPrivacy: 'invisible' }), /invalid transportPrivacy/)
  t.exception(() => validateIntent({ relayLocation: 'hidden-2hop' }), /invalid relayLocation/)
  t.exception(() => validateIntent({ downgradePolicy: 'sure' }), /invalid downgradePolicy/)
  t.is(validateIntent({}), true)
})

test('default intent resolves to direct path', async (t) => {
  const r = resolvePath({}, ALL)
  t.is(r.selectedTransport, PATH_DIRECT)
  t.is(r.downgraded, false)
  t.ok(r.satisfied.length > 0)
  t.alike(r.unsatisfied, [])
})

test('stronger path satisfies weaker requirement (hidden-onion covers source-ip-hidden)', async (t) => {
  const r = resolvePath({ transportPrivacy: 'source-ip-hidden', relayLocation: 'hidden-onion', downgradePolicy: 'deny' }, ALL)
  t.is(r.selectedTransport, PATH_TOR_ONION)
  t.is(r.downgraded, false)
})

test('traffic-analysis-resistant requires cover traffic and mixnet location', async (t) => {
  const r = resolvePath({ transportPrivacy: 'traffic-analysis-resistant', relayLocation: 'hidden-mixnet', metadataShaping: 'cover-and-mix', pathCoverage: 'control-only' }, ALL)
  t.is(r.selectedTransport, PATH_NYM_MIXNET)
  t.is(r.coverage, 'control-only') // nym lane never claims full bulk coverage
})

test('fail-closed: required mixnet with no nym candidate and deny', async (t) => {
  const r = resolvePath({ transportPrivacy: 'traffic-analysis-resistant', downgradePolicy: 'deny' }, [directPath, onionPath])
  t.is(r.selectedTransport, null)
  t.is(r.downgraded, false)
  t.ok(r.unsatisfied.includes('no-satisfying-path'))
})

test('fail-closed: unavailable candidate is not selected silently', async (t) => {
  const r = resolvePath({ relayLocation: 'hidden-onion', downgradePolicy: 'deny' }, [directPath, { ...onionPath, available: false }])
  t.is(r.selectedTransport, null)
  t.ok(r.unsatisfied.includes('no-satisfying-path'))
})

test('explicit ordered fallback: used in order, recorded as downgrade', async (t) => {
  const r = resolvePath(
    { relayLocation: 'hidden-onion', downgradePolicy: [PATH_FORWARD_RELAY, PATH_DIRECT] },
    [directPath, forwardPath] // no onion available
  )
  t.is(r.selectedTransport, PATH_FORWARD_RELAY)
  t.is(r.downgraded, true)
  t.ok(r.unsatisfied.includes('relayLocation:hidden-onion'))
})

test('fallback never silently picks an undeclared path', async (t) => {
  const r = resolvePath(
    { relayLocation: 'hidden-onion', downgradePolicy: [PATH_DIRECT] },
    [directPath, forwardPath]
  )
  t.is(r.selectedTransport, PATH_DIRECT)
  t.is(r.downgraded, true)
})

test('declared-but-unavailable fallback fails closed with reason', async (t) => {
  const r = resolvePath(
    { relayLocation: 'hidden-mixnet', downgradePolicy: [PATH_NYM_MIXNET] },
    [directPath]
  )
  t.is(r.selectedTransport, null)
  t.ok(r.unsatisfied.includes('no-available-fallback'))
})

test('candidatesFromCapabilityDoc maps relay advertisement to candidates', async (t) => {
  const doc = {
    supported_transports: ['hyperswarm', 'tor'],
    gatewayUrl: 'https://relay.example',
    privacyTransports: [
      { id: PATH_TOR_ONION, auth: { mode: 'client-auth-v3' }, vports: [80, 19737] },
      { id: PATH_NYM_MIXNET, replyModes: ['surb'] }
    ]
  }
  const c = candidatesFromCapabilityDoc(doc, { direct: true, tor: true, nym: false })
  const ids = c.map((x) => x.id)
  t.ok(ids.includes(PATH_DIRECT))
  t.ok(ids.includes(PATH_TOR_ONION))
  t.ok(ids.includes(PATH_NYM_MIXNET))
  const nym = c.find((x) => x.id === PATH_NYM_MIXNET)
  t.is(nym.available, false) // local nym not running → not selectable
  const onion = c.find((x) => x.id === PATH_TOR_ONION)
  t.is(onion.relayLocation, 'hidden-onion')
  t.alike(onion.vports, [80, 19737])
})

test('candidatesFromCapabilityDoc tolerates junk input', async (t) => {
  t.alike(candidatesFromCapabilityDoc(null), [])
  t.alike(candidatesFromCapabilityDoc({}), [])
})

test('full-journey composition: nym control + onion bulk evaluate independently', async (t) => {
  // control op: nym lane satisfies
  const control = resolvePath({ transportPrivacy: 'traffic-analysis-resistant', pathCoverage: 'control-only', downgradePolicy: 'deny' }, ALL)
  t.is(control.selectedTransport, PATH_NYM_MIXNET)
  // bulk op: same intent with full coverage required — nym lane cannot claim it
  const bulk = resolvePath({ transportPrivacy: 'traffic-analysis-resistant', pathCoverage: 'full', downgradePolicy: 'deny' }, ALL)
  t.is(bulk.selectedTransport, null) // fails closed rather than mislabeling
})

test('DEFAULT_INTENT is a valid, resolvable intent', async (t) => {
  t.is(validateIntent(DEFAULT_INTENT), true)
  const r = resolvePath(DEFAULT_INTENT, ALL)
  t.ok(r.selectedTransport)
})