OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / ws-feed-payload.test.js

705 lines · javascript ref:
/**
 * Tests that the WebSocket dashboard feed surfaces AutoHeal + custody state
 * in its broadcast payload. Tests the payload builder directly with a mock
 * node — avoids spinning up a real HTTP server / WebSocket clients.
 */

import test from 'brittle'
import { EventEmitter, once } from 'node:events'
import { createServer } from 'node:http'
import { WebSocket } from 'ws'
import { DashboardFeed } from 'p2p-hiverelay/core/relay-node/ws-feed.js'

function makeMockNode (opts = {}) {
  const node = new EventEmitter()
  Object.assign(node, {
    running: true,
    config: { regions: ['NA'], maxStorageBytes: 1024 * 1024 * 1024 },
    swarm: { keyPair: { publicKey: 'mockpubkey' } },
    startedAt: Date.now() - 60_000,
    getStats: () => ({
      publicKey: 'mockpubkey',
      connections: { active: 0, total: 0 },
      seededApps: 0,
      relay: { activeCircuits: 0, totalCircuitsServed: 0, totalBytesRelayed: 0 },
      seeder: { coresSeeded: 0, totalBytesStored: 0, totalBytesServed: 0 }
    }),
    autoHeal: opts.autoHeal,
    seedingRegistry: opts.seedingRegistry,
    metrics: null,
    reputation: null,
    torTransport: null,
    holesailTransport: null,
    creditManager: null,
    serviceMeter: null,
    invoiceManager: null,
    paymentManager: null,
    networkDiscovery: opts.networkDiscovery || null
  })
  return node
}

function makeFeed (node) {
  // Construct the feed without starting (no server needed for _buildPayload)
  const feed = new DashboardFeed({ node, server: null })
  feed.clientCount = 1 // pretend a client is connected
  return feed
}

test('ws-feed: payload omits autoHeal block when node has no autoHeal', async (t) => {
  const node = makeMockNode({ autoHeal: null })
  const payload = makeFeed(node)._buildPayload()
  t.absent(payload.autoHeal, 'no autoHeal in payload')
})

test('ws-feed: payload includes autoHeal snapshot when present', async (t) => {
  const autoHealSnap = {
    enabled: true,
    running: true,
    tickMs: 60_000,
    thresholds: { minReplicas: 7, minRegions: 4, minOperators: 5, secret: 'should-not-leak' },
    tracked: 3,
    below: 1,
    backoffs: 0,
    verifyProofs: true,
    proofCacheSize: 12,
    secret: 'should-not-leak',
    drives: [
      {
        appKey: 'not-hex',
        replicas: 1,
        secret: 'should-not-leak'
      },
      {
        appKey: 'a'.repeat(64),
        replicas: 7,
        regions: ['NA', 'EU', 'AS', 'OC', 'bad\nregion'],
        operators: ['alice', 'bob', 'carol', 'dave', 'erin', 'bad\noperator'],
        meetsThreshold: true,
        haveLocally: true,
        backoff: { failures: 1, retryInMs: 1000, reason: 'should-not-leak' },
        raw: [{ secret: 'should-not-leak' }]
      }
    ]
  }
  const node = makeMockNode({
    autoHeal: { snapshot: () => autoHealSnap }
  })
  const payload = makeFeed(node)._buildPayload()
  t.ok(payload.autoHeal, 'autoHeal block present')
  t.is(payload.autoHeal.tracked, 3)
  t.is(payload.autoHeal.below, 1)
  t.is(payload.autoHeal.verifyProofs, true)
  t.is(payload.autoHeal.proofCacheSize, 12)
  t.is(payload.autoHeal.drives.length, 1)
  t.alike(payload.autoHeal.drives[0], {
    appKey: 'a'.repeat(64),
    replicas: 7,
    regions: ['NA', 'EU', 'AS', 'OC'],
    operators: ['alice', 'bob', 'carol', 'dave', 'erin'],
    meetsThreshold: true,
    haveLocally: true,
    backoff: { failures: 1, retryInMs: 1000 }
  })
  t.absent(JSON.stringify(payload.autoHeal).includes('should-not-leak'), 'raw AutoHeal fields are removed from live feed')
})

test('ws-feed: payload sanitizes and caps autoHeal.drives at 50 to bound payload size', async (t) => {
  const drives = [{ appKey: 'not-hex', replicas: 0 }]
  for (let i = 0; i < 200; i++) {
    drives.push({
      appKey: (i % 2 === 0 ? 'a' : 'b').repeat(64),
      replicas: i,
      regions: ['NA'],
      operators: ['operator-a'],
      meetsThreshold: false,
      haveLocally: false,
      backoff: null,
      secret: 'should-not-leak'
    })
  }
  const node = makeMockNode({
    autoHeal: {
      snapshot: () => ({
        enabled: true, running: true, tickMs: 60_000, thresholds: {}, tracked: 200, below: 200, backoffs: 0, verifyProofs: true, proofCacheSize: 0, drives
      })
    }
  })
  const payload = makeFeed(node)._buildPayload()
  t.is(payload.autoHeal.drives.length, 50, 'drives capped at 50')
  t.is(payload.autoHeal.drives[0].appKey, 'a'.repeat(64), 'invalid rows do not consume the cap')
  t.is(payload.autoHeal.tracked, 200, 'tracked count preserved (full count surfaced)')
  t.absent(JSON.stringify(payload.autoHeal).includes('should-not-leak'), 'raw drive fields are removed')
})

test('ws-feed: payload includes custody snapshot when registry has one', async (t) => {
  const node = makeMockNode({
    seedingRegistry: {
      custodySnapshot: () => ({
        intents: 5,
        withQuorum: 3,
        committed: 2,
        retired: 1,
        withProof: 2,
        withNonServingProof: 0,
        withWitnessTombstone: 1,
        totalReceipts: 9,
        totalProofs: 4,
        totalNonServingProofs: 0,
        totalWitnessTombstones: 2,
        commitRate: 0.4,
        proofs: [{ secret: 'should-not-leak' }],
        intentsById: { hidden: 'should-not-leak' }
      })
    }
  })
  const payload = makeFeed(node)._buildPayload()
  t.ok(payload.custody, 'custody block present')
  t.alike(payload.custody, {
    intents: 5,
    withQuorum: 3,
    committed: 2,
    retired: 1,
    withProof: 2,
    withNonServingProof: 0,
    withWitnessTombstone: 1,
    totalReceipts: 9,
    totalProofs: 4,
    totalNonServingProofs: 0,
    totalWitnessTombstones: 2,
    commitRate: 0.4
  })
  t.absent(JSON.stringify(payload.custody).includes('should-not-leak'), 'raw custody fields are removed from live feed')
})

test('ws-feed: payload normalizes malformed custody snapshot counters', async (t) => {
  const node = makeMockNode({
    seedingRegistry: {
      custodySnapshot: () => ({
        intents: '5',
        withQuorum: -1,
        committed: Infinity,
        retired: 1.8,
        withProof: 2,
        withNonServingProof: null,
        withWitnessTombstone: undefined,
        totalReceipts: Number.MAX_SAFE_INTEGER + 1000,
        totalProofs: NaN,
        totalNonServingProofs: 3.9,
        totalWitnessTombstones: -4,
        commitRate: 1.5,
        receipts: [{ secret: 'should-not-leak' }]
      })
    }
  })
  const payload = makeFeed(node)._buildPayload()

  t.alike(payload.custody, {
    intents: 0,
    withQuorum: 0,
    committed: 0,
    retired: 1,
    withProof: 2,
    withNonServingProof: 0,
    withWitnessTombstone: 0,
    totalReceipts: Number.MAX_SAFE_INTEGER,
    totalProofs: 0,
    totalNonServingProofs: 3,
    totalWitnessTombstones: 0,
    commitRate: null
  })
  t.absent(JSON.stringify(payload.custody).includes('should-not-leak'), 'malformed custody snapshot raw fields are removed')
})

test('ws-feed: payload omits custody when no registry', async (t) => {
  const node = makeMockNode({ seedingRegistry: null })
  const payload = makeFeed(node)._buildPayload()
  t.absent(payload.custody, 'no custody in payload')
})

test('ws-feed: overview requests redacted stats and removes transport secrets', async (t) => {
  const calls = []
  const node = makeMockNode()
  node.getStats = (opts) => {
    calls.push(opts)
    return {
      publicKey: 'mockpubkey',
      connections: 2,
      seededApps: 1,
      relay: { activeCircuits: 0, totalCircuitsServed: 0, totalBytesRelayed: 0 },
      seeder: { coresSeeded: 1, totalBytesStored: 100, totalBytesServed: 200 },
      tor: {
        running: true,
        onionAddress: 'feed-secret.onion',
        socksProxy: '127.0.0.1:9905',
        activeConnections: 2
      },
      holesail: {
        running: true,
        connected: true,
        connectionKey: 'HOLESAIL_WS_SECRET',
        apiPort: 19100
      }
    }
  }

  const payload = makeFeed(node)._buildPayload()

  t.alike(calls, [{ includeSecrets: false }], 'feed asks RelayNode for redacted stats')
  t.alike(payload.overview.tor, { running: true, activeConnections: 2 })
  t.alike(payload.overview.holesail, { running: true, connected: true })
  const overview = JSON.stringify(payload.overview)
  t.absent(overview.includes('feed-secret.onion'), 'tor onion address omitted')
  t.absent(overview.includes('127.0.0.1:9905'), 'tor local proxy omitted')
  t.absent(overview.includes('HOLESAIL_WS_SECRET'), 'holesail connection key omitted')
  t.absent(overview.includes('19100'), 'holesail API port omitted')
})

test('ws-feed: overview shapes relay and seeder counters', async (t) => {
  const node = makeMockNode()
  node.getStats = () => ({
    publicKey: 'mockpubkey',
    connections: 0,
    seededApps: 0,
    relay: {
      activeCircuits: 2.9,
      totalCircuitsServed: -1,
      totalBytesRelayed: 4096,
      capacityUsedPct: 101,
      peersWithCircuits: 3,
      circuits: [{ secret: 'should-not-leak' }]
    },
    seeder: {
      coresSeeded: 4,
      totalBytesStored: Infinity,
      totalBytesServed: 700.9,
      capacityUsedPct: '<script>',
      cores: [{ secret: 'should-not-leak' }]
    },
    served: {
      totalBytesServed: 900.8,
      totalBlocksServed: 2,
      secret: 'should-not-leak'
    }
  })

  const payload = makeFeed(node)._buildPayload()

  t.alike(payload.overview.relay, {
    activeCircuits: 2,
    totalCircuitsServed: 0,
    totalBytesRelayed: 4096,
    capacityUsedPct: 100,
    peersWithCircuits: 3
  })
  t.alike(payload.overview.seeder, {
    coresSeeded: 4,
    totalBytesStored: 0,
    totalBytesServed: 700,
    capacityUsedPct: 0,
    totalBytesServedMeasured: 900
  })
  t.absent(JSON.stringify(payload.overview).includes('should-not-leak'), 'raw relay/seeder fields are removed')
})

test('ws-feed: overview shapes payment and service accounting telemetry', async (t) => {
  const accountEntries = []
  for (let i = 0; i < 30; i++) accountEntries.push(['relay-' + i, {}])
  const node = makeMockNode()
  node.getStats = () => ({
    publicKey: 'mockpubkey',
    connections: 0,
    seededApps: 0,
    relay: { activeCircuits: 0, totalCircuitsServed: 0, totalBytesRelayed: 0 },
    seeder: { coresSeeded: 0, totalBytesStored: 0, totalBytesServed: 0 },
    payment: { enabled: true, active: true, experimental: true, settlementIntervalMs: 86400000, secret: 'should-not-leak' }
  })
  node.creditManager = {
    stats: () => ({
      totalWallets: 3.9,
      totalBalance: 1200,
      totalDeposited: -1,
      totalSpent: 4,
      totalWelcomeCredits: Infinity,
      welcomeCreditsPerWallet: 1000,
      frozenWallets: 1,
      avgBalance: 400,
      wallets: [{ secret: 'should-not-leak' }]
    })
  }
  node.serviceMeter = {
    stats: () => ({
      totalApps: 2.9,
      totalCalls: 42,
      totalRevenue: 900,
      windowStart: -1,
      rates: { secret: 'should-not-leak' }
    })
  }
  node.invoiceManager = {
    stats: () => ({
      total: 5,
      pending: 1,
      settled: 2,
      expired: 1.9,
      cancelled: '<script>',
      totalSettledSats: 700,
      invoices: [{ secret: 'should-not-leak' }]
    })
  }
  node.paymentManager = {
    accounts: new Map(accountEntries),
    paymentProvider: { constructor: { name: 'bad\nprovider' } },
    getAccountSummary: (pubkey) => ({
      relay: pubkey,
      monthsActive: 7.8,
      heldPercentage: 125,
      totalEarned: 1000,
      totalPaid: -25,
      currentlyHeld: Infinity,
      pendingPayout: 300,
      lastSettlement: 12345,
      secret: 'should-not-leak'
    })
  }

  const payload = makeFeed(node)._buildPayload()

  t.alike(payload.overview.credits, {
    totalWallets: 3,
    totalBalance: 1200,
    totalDeposited: 0,
    totalSpent: 4,
    totalWelcomeCredits: 0,
    welcomeCreditsPerWallet: 1000,
    frozenWallets: 1,
    avgBalance: 400
  })
  t.alike(payload.overview.metering, {
    totalApps: 2,
    totalCalls: 42,
    totalRevenue: 900,
    windowStart: null
  })
  t.alike(payload.overview.invoices, {
    total: 5,
    pending: 1,
    settled: 2,
    expired: 1,
    cancelled: 0,
    totalSettledSats: 700
  })
  t.is(payload.overview.payment.provider, 'none', 'unsafe provider label falls back')
  t.is(payload.overview.payment.accounts.length, 25, 'payment accounts are capped')
  t.alike(payload.overview.payment.accounts[0], {
    monthsActive: 7,
    heldPercentage: 100,
    totalEarned: 1000,
    totalPaid: 0,
    currentlyHeld: 0,
    pendingPayout: 300,
    lastSettlement: 12345
  })
  const overview = JSON.stringify(payload.overview)
  t.absent(overview.includes('should-not-leak'), 'raw payment/service manager fields are removed')
  t.absent(overview.includes('relay-0'), 'payment account relay ids are not emitted')
  t.absent(overview.includes('bad\\nprovider'), 'unsafe provider constructor name is not emitted')
})

test('ws-feed: overview sanitizes reputation and bandwidth telemetry', async (t) => {
  const node = makeMockNode()
  node.reputation = {
    export: () => ({ a: {}, b: {}, secret: { token: 'should-not-leak' } }),
    getLeaderboard: () => [{
      relay: 'a'.repeat(64),
      score: 9.876,
      reliability: '<script>',
      avgLatencyMs: -1,
      uptimeHours: 3.9,
      bytesServed: 2048,
      totalChallenges: 5,
      passedChallenges: 7,
      failedChallenges: 99,
      region: 'NA',
      secretToken: 'should-not-leak'
    }]
  }
  node._bandwidthReceipt = {
    _issuedReceipts: new Array(3).fill({ secret: 'should-not-leak' }),
    getTotalProvenBandwidth: () => Infinity
  }

  const payload = makeFeed(node)._buildPayload()

  t.alike(payload.overview.reputation, {
    trackedRelays: 3,
    topRelay: {
      relay: 'a'.repeat(64),
      score: 9.88,
      reliability: '100%',
      avgLatencyMs: 0,
      uptimeHours: 3,
      bytesServed: 2048,
      totalChallenges: 5,
      passedChallenges: 5,
      failedChallenges: 0,
      region: 'NA',
      lastActivity: null,
      firstSeen: null
    }
  })
  t.alike(payload.overview.bandwidth, {
    totalProvenBytes: 0,
    receiptsIssued: 3
  })
  t.absent(JSON.stringify(payload.overview).includes('should-not-leak'), 'raw reputation/bandwidth fields are removed')
})

test('ws-feed: unauthenticated feed redacts network connection metadata', async (t) => {
  const node = makeMockNode({
    networkDiscovery: {
      getNetworkState () {
        return {
          timestamp: 12345,
          summary: { totalRelays: 1, onlineRelays: 1, totalConnections: 1, totalStorage: 0, totalStorageMax: 0 },
          relays: [{
            publicKey: 'a'.repeat(64),
            host: '203.0.113.22',
            apiPort: 9100,
            region: 'NA',
            online: true,
            tor: { running: true, onionAddress: 'feed-secret.onion' },
            holesailKey: 'feed-holesail-secret',
            memory: { rss: 42 }
          }]
        }
      }
    }
  })
  const payload = makeFeed(node)._buildPayload()

  t.ok(payload.network, 'network block present')
  t.is(payload.network.relays[0].apiReachable, true, 'public reachability boolean preserved')
  t.absent(Object.prototype.hasOwnProperty.call(payload.network.relays[0], 'host'))
  t.absent(Object.prototype.hasOwnProperty.call(payload.network.relays[0], 'apiPort'))
  t.absent(Object.prototype.hasOwnProperty.call(payload.network.relays[0], 'memory'))
  t.absent(JSON.stringify(payload.network).includes('feed-holesail-secret'))
  t.absent(JSON.stringify(payload.network).includes('feed-secret.onion'))
})

test('ws-feed: authenticated feed preserves detailed network state', async (t) => {
  const node = makeMockNode({
    networkDiscovery: {
      getNetworkState () {
        return {
          timestamp: 12345,
          summary: { totalRelays: 1, onlineRelays: 1, totalConnections: 1, totalStorage: 0, totalStorageMax: 0 },
          relays: [{
            publicKey: 'b'.repeat(64),
            host: '203.0.113.23',
            apiPort: 9100,
            holesailKey: 'authed-feed-holesail',
            memory: { heapUsed: 42, rss: 84, external: 'should-not-leak' },
            tor: { running: true, onionAddress: 'authed-feed.onion', socksProxy: 'should-not-leak' },
            raw: { secret: 'should-not-leak' }
          }]
        }
      }
    }
  })
  const feed = new DashboardFeed({ node, server: null, apiKey: 'secret' })
  feed.clientCount = 1
  const payload = feed._buildPayload()

  t.is(payload.network.relays[0].host, '203.0.113.23')
  t.is(payload.network.relays[0].holesailKey, 'authed-feed-holesail')
  t.alike(payload.network.relays[0].memory, { heapUsed: 42, rss: 84 })
  t.alike(payload.network.relays[0].tor, { running: true, onionAddress: 'authed-feed.onion', activeConnections: 0 })
  t.absent(JSON.stringify(payload.network).includes('should-not-leak'), 'authenticated network frames are detailed but shaped')
})

test('ws-feed: payload swallows snapshot errors (does not crash broadcast)', async (t) => {
  const node = makeMockNode({
    autoHeal: { snapshot: () => { throw new Error('boom') } },
    seedingRegistry: { custodySnapshot: () => { throw new Error('boom') } }
  })
  const payload = makeFeed(node)._buildPayload()
  // Must still produce a valid payload — autoHeal/custody just absent
  t.ok(payload.overview, 'overview still present')
  t.absent(payload.autoHeal, 'autoHeal absent on snapshot error')
  t.absent(payload.custody, 'custody absent on snapshot error')
})

test('ws-feed: authenticated clients must send in-band auth before snapshots', async (t) => {
  const { feed, server, url } = await startFeed({ apiKey: 'secret', authTimeoutMs: 250 })
  t.teardown(async () => {
    await feed.stop()
    await closeServer(server)
  })

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })

  const messages = []
  ws.on('message', (data) => messages.push(data.toString()))

  await once(ws, 'open')
  await delay(30)
  t.is(feed.clientCount, 0, 'client is not counted before auth')
  t.is(messages.length, 0, 'snapshot is not sent before auth')

  ws.send(JSON.stringify({ type: 'auth', token: 'secret' }))
  await waitUntil(() => messages.length > 0)

  t.is(feed.clientCount, 1, 'client is counted after valid auth')
  const payload = JSON.parse(messages[0])
  t.is(payload.type, 'update')
  t.ok(payload.overview, 'authenticated client receives initial snapshot')
})

test('ws-feed: query-string tokens do not authenticate dashboard sockets', async (t) => {
  const { feed, server, node, url } = await startFeed({ apiKey: 'secret', authTimeoutMs: 100 })
  t.teardown(async () => {
    await feed.stop()
    await closeServer(server)
  })

  const failures = []
  node.on('dashboard-ws-auth-failed', (evt) => failures.push(evt.reason))

  const ws = new WebSocket(url + '?token=secret')
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })

  const [err] = await once(ws, 'error')
  t.ok(err.message.includes('401'), 'upgrade rejects query-token auth')
  t.alike(failures, ['query-token'])
  t.is(feed.clientCount, 0)
})

test('ws-feed: restricted CORS allows same-origin dashboard sockets only', async (t) => {
  const { feed, server, url, origin } = await startFeed({
    apiKey: 'secret',
    authTimeoutMs: 100,
    corsOrigins: []
  })
  t.teardown(async () => {
    await feed.stop()
    await closeServer(server)
  })

  const crossSite = new WebSocket(url, {
    headers: { Origin: 'https://attacker.example' }
  })
  t.teardown(() => {
    try { crossSite.terminate() } catch {}
  })

  const [err] = await once(crossSite, 'error')
  t.ok(err.message.includes('403'), 'cross-origin dashboard socket is rejected')

  const sameOrigin = new WebSocket(url, {
    headers: { Origin: origin }
  })
  t.teardown(() => {
    try { sameOrigin.terminate() } catch {}
  })

  await once(sameOrigin, 'open')
  sameOrigin.send(JSON.stringify({ type: 'auth', token: 'secret' }))
  const [msg] = await once(sameOrigin, 'message')
  const payload = JSON.parse(msg)
  t.is(payload.type, 'update')
  t.ok(payload.overview, 'same-origin dashboard socket receives authenticated update')
  t.is(feed.clientCount, 1)
})

test('ws-feed: bad auth frames close without counting the client', async (t) => {
  const { feed, server, node, url } = await startFeed({ apiKey: 'secret', authTimeoutMs: 100 })
  t.teardown(async () => {
    await feed.stop()
    await closeServer(server)
  })

  const failures = []
  node.on('dashboard-ws-auth-failed', (evt) => failures.push(evt.reason))

  const ws = new WebSocket(url)
  t.teardown(() => {
    try { ws.terminate() } catch {}
  })

  await once(ws, 'open')
  ws.send(JSON.stringify({ type: 'auth', token: 'wrong' }))
  const [code, reason] = await once(ws, 'close')

  t.is(code, 1008)
  t.is(reason.toString(), 'auth-failed')
  t.alike(failures, ['invalid'])
  t.is(feed.clientCount, 0)
})

test('ws-feed: stop tolerates lightweight event stubs used by API unit tests', async (t) => {
  const server = new EventEmitter()
  const node = {
    relay: null,
    on () {},
    getStats: () => ({ running: true })
  }
  const feed = new DashboardFeed({ server, node })

  feed.start()
  await feed.stop()

  t.pass('stopped without removeListener on node stub')
})

async function startFeed (opts = {}) {
  const server = createServer((req, res) => {
    res.statusCode = 404
    res.end()
  })
  const node = makeMockNode()
  const feed = new DashboardFeed({
    server,
    node,
    apiKey: opts.apiKey,
    authTimeoutMs: opts.authTimeoutMs || 100,
    corsOrigins: Object.prototype.hasOwnProperty.call(opts, 'corsOrigins') ? opts.corsOrigins : undefined
  })
  feed.start()
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
  const port = server.address().port
  return {
    feed,
    server,
    node,
    url: `ws://127.0.0.1:${port}/ws`,
    origin: `http://127.0.0.1:${port}`
  }
}

async function closeServer (server) {
  if (!server.listening) return
  await new Promise((resolve) => server.close(resolve))
}

function delay (ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function waitUntil (fn, timeoutMs = 500) {
  const started = Date.now()
  return new Promise((resolve, reject) => {
    const tick = () => {
      if (fn()) return resolve()
      if (Date.now() - started > timeoutMs) return reject(new Error('timed out waiting for condition'))
      setTimeout(tick, 10)
    }
    tick()
  })
}