OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / accounting-receipt-runtime.test.js

287 lines · javascript ref:
import test from 'brittle'
import http from 'http'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { RelayAPI } from 'p2p-hiverelay/core/relay-node/api.js'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import { verifyAccountingReceipt } from 'p2p-hiverelay/core/protocol/accounting-receipt.js'
import {
  accountingReceiptRefreshFlag,
  buildAccountingReceiptPayload,
  buildAccountingReceiptRoutePayload,
  resolveAccountingReceiptRoute
} from 'p2p-hiverelay/core/relay-node/api-accounting-receipt.js'

const API_KEY = 'accounting-runtime-test-key'
const AUTH = { Authorization: 'Bearer ' + API_KEY }

function keypairFromSeed (byte) {
  const seed = b4a.alloc(sodium.crypto_sign_SEEDBYTES, byte)
  const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed)
  return { publicKey, secretKey }
}

function runtimeNode (opts = {}) {
  const node = Object.create(RelayNode.prototype)
  let measureCount = 0
  Object.assign(node, {
    keyPair: opts.keyPair || keypairFromSeed(4),
    storageAccounting: {
      async measureDisk () { measureCount++ },
      getSummary () {
        return opts.summary || {
          totalBytes: 4096,
          diskBytes: 3072,
          perEntryBytes: 2048,
          measuredEntries: 2
        }
      }
    },
    servedAccounting: {
      getSummary () {
        return { totalBytesServed: 900, totalBlocksServed: 3 }
      }
    },
    seeder: {
      getStats () {
        return { totalBytesServed: 100 }
      }
    },
    leaseManager: {
      getSummary () {
        return { leaseCount: 7 }
      }
    },
    measureCount: () => measureCount
  })
  return node
}

function apiNode (opts = {}) {
  return {
    running: true,
    config: { storage: null, plugins: [], trustProxy: true },
    metrics: { getSummary () { return { uptime: 1 } } },
    seededApps: new Map(),
    appRegistry: { apps: new Map(), catalog () { return [] }, catalogForBroadcast () { return [] } },
    getStats () { return { running: true } },
    getHealthStatus () { return { healthy: true } },
    createAccountingReceipt: opts.createAccountingReceipt,
    async stop () {},
    async start () {},
    on () {},
    emit () {}
  }
}

function request (port, method, path, headers = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({
      hostname: '127.0.0.1',
      port,
      method,
      path,
      headers
    }, (res) => {
      let body = ''
      res.on('data', (chunk) => { body += chunk })
      res.on('end', () => {
        let parsed
        try { parsed = JSON.parse(body) } catch { parsed = body }
        resolve({ statusCode: res.statusCode, body: parsed })
      })
    })
    req.on('error', reject)
    req.end()
  })
}

async function server (t, node) {
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY, trustProxy: true })
  await api.start()
  const port = api.server.address().port
  t.teardown(async () => {
    if (api._rateLimitCleanup) clearInterval(api._rateLimitCleanup)
    if (api._dashboardFeed) { try { api._dashboardFeed.stop() } catch (_) {} }
    if (api._pokerFeed) { try { api._pokerFeed.stop() } catch (_) {} }
    await new Promise((resolve) => api.server.close(resolve))
  })
  return { port }
}

test('RelayNode.createAccountingReceipt signs the OS-grounded runtime summary', async (t) => {
  const node = runtimeNode()
  const receipt = await node.createAccountingReceipt({
    periodStart: 100,
    periodEnd: 200,
    measuredAt: 201,
    nonce: '11'.repeat(16)
  })

  t.is(node.measureCount(), 1, 'refreshes disk measurement before signing')
  t.is(receipt.storageBytes, 4096)
  t.is(receipt.diskBytes, 3072)
  t.is(receipt.perEntryBytes, 2048)
  t.is(receipt.bytesServed, 900, 'prefers served-accounting over seeder fallback')
  t.is(receipt.leaseCount, 7)
  t.is(receipt.seededCount, 2)
  t.ok(verifyAccountingReceipt(receipt).valid, 'runtime receipt verifies')
})

test('RelayNode.createAccountingReceipt refuses to sign without OS disk measurement', async (t) => {
  const node = runtimeNode({
    summary: {
      totalBytes: 4096,
      diskBytes: null,
      perEntryBytes: 2048,
      measuredEntries: 2
    }
  })

  await t.exception(node.createAccountingReceipt({ nonce: '22'.repeat(16) }), /OS disk measurement unavailable/)
})

test('RelayNode.createAccountingReceipt rejects malformed runtime counter overrides', async (t) => {
  const node = runtimeNode()
  await t.exception(node.createAccountingReceipt({
    bytesServed: -1,
    nonce: '44'.repeat(16)
  }), /bad bytesServed/)
})

test('api accounting receipt helper maps exact operator receipt route', (t) => {
  t.alike(resolveAccountingReceiptRoute('GET', '/api/accounting/receipt'), {
    kind: 'receipt',
    authMessage: 'Unauthorized — accounting receipt requires API key or localhost'
  })

  t.is(resolveAccountingReceiptRoute('POST', '/api/accounting/receipt'), null)
  t.is(resolveAccountingReceiptRoute('GET', '/api/accounting/receipt/extra'), null)
  t.is(resolveAccountingReceiptRoute('GET', '/api/accounting/receipts'), null)
})

test('api accounting receipt helper preserves refresh and error contract', async (t) => {
  t.is(accountingReceiptRefreshFlag(null), true)
  t.is(accountingReceiptRefreshFlag('1'), true)
  t.is(accountingReceiptRefreshFlag('0'), false)

  const unavailable = await buildAccountingReceiptPayload({ node: null })
  t.alike(unavailable, {
    status: 503,
    payload: { error: 'accounting receipts unavailable' }
  })

  const calls = []
  const ok = await buildAccountingReceiptPayload({
    node: {
      async createAccountingReceipt (opts) {
        calls.push(opts)
        return { kind: 'hiverelay-accounting-receipt-v1' }
      }
    },
    refresh: false
  })
  t.alike(ok, {
    status: 200,
    payload: { receipt: { kind: 'hiverelay-accounting-receipt-v1' } }
  })
  t.alike(calls, [{ refresh: false }])

  const failed = await buildAccountingReceiptPayload({
    node: {
      async createAccountingReceipt () {
        throw new Error('ACCOUNTING_RECEIPT_UNAVAILABLE: OS disk measurement unavailable')
      }
    }
  })
  t.alike(failed, {
    status: 503,
    payload: { error: 'ACCOUNTING_RECEIPT_UNAVAILABLE: OS disk measurement unavailable' }
  })
})

test('api accounting receipt route payload helper dispatches receipt reads', async (t) => {
  const calls = []
  const node = {
    async createAccountingReceipt (opts) {
      calls.push(opts)
      return { kind: 'hiverelay-accounting-receipt-v1', refresh: opts.refresh }
    }
  }

  const noRefresh = await buildAccountingReceiptRoutePayload({
    route: resolveAccountingReceiptRoute('GET', '/api/accounting/receipt'),
    node,
    url: new URL('http://relay.test/api/accounting/receipt?refresh=0')
  })
  t.alike(noRefresh, {
    status: 200,
    payload: { receipt: { kind: 'hiverelay-accounting-receipt-v1', refresh: false } }
  })

  const defaultRefresh = await buildAccountingReceiptRoutePayload({
    route: resolveAccountingReceiptRoute('GET', '/api/accounting/receipt'),
    node,
    url: new URL('http://relay.test/api/accounting/receipt')
  })
  t.alike(defaultRefresh, {
    status: 200,
    payload: { receipt: { kind: 'hiverelay-accounting-receipt-v1', refresh: true } }
  })
  t.alike(calls, [{ refresh: false }, { refresh: true }])

  const unknown = await buildAccountingReceiptRoutePayload({
    route: null,
    node: {
      async createAccountingReceipt () {
        throw new Error('should not create receipt')
      }
    }
  })
  t.alike(unknown, {
    status: 404,
    payload: { error: 'unknown accounting receipt route' }
  })
})

test('GET /api/accounting/receipt is auth-gated and returns a verifiable receipt', async (t) => {
  const node = runtimeNode()
  const refreshes = []
  const { port } = await server(t, apiNode({
    createAccountingReceipt: (opts) => {
      refreshes.push(opts.refresh)
      return node.createAccountingReceipt({
        ...opts,
        periodStart: 100,
        periodEnd: 200,
        measuredAt: 201,
        nonce: '33'.repeat(16)
      })
    }
  }))

  t.is((await request(port, 'GET', '/api/accounting/receipt')).statusCode, 401, 'no auth is rejected')
  const ok = await request(port, 'GET', '/api/accounting/receipt', AUTH)
  t.is(ok.statusCode, 200)
  t.ok(verifyAccountingReceipt(ok.body.receipt).valid)
  t.is(ok.body.receipt.diskBytes, 3072)

  const noRefresh = await request(port, 'GET', '/api/accounting/receipt?refresh=0', AUTH)
  t.is(noRefresh.statusCode, 200)
  t.alike(refreshes, [true, false])
})

test('GET /api/accounting/receipt reports unavailable receipts as 503', async (t) => {
  const { port } = await server(t, apiNode({
    async createAccountingReceipt () {
      throw new Error('ACCOUNTING_RECEIPT_UNAVAILABLE: OS disk measurement unavailable')
    }
  }))

  const res = await request(port, 'GET', '/api/accounting/receipt', AUTH)
  t.is(res.statusCode, 503)
  t.is(res.body.error, 'ACCOUNTING_RECEIPT_UNAVAILABLE: OS disk measurement unavailable')
})