OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-notify.test.js

259 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 {
  NotifyService,
  NOTIFY_DOMAINS,
  notifySignaturePayload
} from 'p2p-hiveservices/builtin/notify-service.js'

const API_KEY = 'notify-test-key'
const NOW = 1782864000000
const HOUR = 60 * 60 * 1000

test('notify http api: capabilities report disabled provider cleanly', async (t) => {
  const { port } = await serverWithApi(t, mockNode({ registry: null }))
  const res = await request(port, 'GET', '/api/v1/notify/capabilities')

  t.is(res.statusCode, 503)
  t.alike(res.body, { error: 'Notify service is not enabled on this relay' })
})

test('notify http api: signed wake flow and redacted diagnostics', async (t) => {
  const relay = keyPair(1)
  const user = keyPair(2)
  const device = keyPair(3)
  const sender = keyPair(4)
  const attempts = []
  const notify = new NotifyService({
    keyPair: relay,
    clock: () => NOW,
    provider: {
      async send (delivery) {
        attempts.push(delivery)
        return { status: 'accepted_by_provider', providerStatus: 'http-memory-ok' }
      }
    }
  })
  const { port } = await serverWithApi(t, mockNode({ registry: notifyRegistry(notify) }))

  const caps = await request(port, 'GET', '/api/v1/notify/capabilities')
  t.is(caps.statusCode, 200)
  t.is(caps.body.service, 'notify')
  t.is(caps.body.payload.plaintextAllowed, false)

  const installed = await installNotifyHttp(port, { relay, user, device, sender })
  t.is(installed.sendCap.body.ok, true)

  const unsignedStatus = await request(port, 'GET', '/api/v1/notify/status?app=' + hex(5))
  t.is(unsignedStatus.statusCode, 401)

  const statusQuery = signed(device, NOTIFY_DOMAINS.status, {
    type: 'hiverelay.notify.status.v1',
    app: hex(5),
    device: device.hex,
    createdAt: String(NOW),
    nonce: 'f'.repeat(32)
  })
  const status = await request(port, 'GET', '/api/v1/notify/status?' + new URLSearchParams(statusQuery).toString())
  t.is(status.statusCode, 200)
  t.is(status.body.counts.receiveCaps, 1)

  const intent = signed(sender, NOTIFY_DOMAINS.intent, {
    type: 'hiverelay.notify.intent.v1',
    intentId: hex(9),
    receiveCap: hex(6),
    sendCap: hex(7),
    app: hex(5),
    receiver: device.hex,
    sender: sender.hex,
    channel: 'message',
    urgency: 'normal',
    ttlSeconds: 3600,
    createdAt: NOW,
    payloadCiphertext: 'ciphertext',
    privacyProfile: 'generic'
  })
  const sent = await request(port, 'POST', '/api/v1/notify/send', intent)
  t.is(sent.statusCode, 200)
  t.is(sent.body.ok, true)
  t.is(attempts.length, 1)
  t.is(attempts[0].providerTokenCiphertext, 'encrypted-provider-token')

  const managementDenied = await request(port, 'GET', '/api/manage/notify')
  t.is(managementDenied.statusCode, 401)

  const management = await request(port, 'GET', '/api/manage/notify', null, { Authorization: 'Bearer ' + API_KEY })
  t.is(management.statusCode, 200)
  t.is(management.body.counts.providerBindings, 1)
  t.absent(JSON.stringify(management.body).includes('encrypted-provider-token'))

  const gates = await request(port, 'GET', '/api/manage/notify/production-gates?app=' + hex(5), null, { Authorization: 'Bearer ' + API_KEY })
  t.is(gates.statusCode, 200)
  t.ok(gates.body.gates.some(gate => gate.id === 'provider-binding' && gate.ok === true))
})

function notifyRegistry (provider) {
  return {
    services: new Map([[
      'notify',
      {
        name: 'notify',
        version: '0.1.0',
        status: 'running',
        capabilities: provider.manifest().capabilities,
        provider
      }
    ]])
  }
}

function mockNode (opts = {}) {
  return {
    running: true,
    config: { storage: null, plugins: opts.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 } },
    serviceRegistry: opts.registry || null,
    async stop () {},
    async start () {},
    on () {},
    emit () {}
  }
}

async function installNotifyHttp (port, { relay, user, device, sender }) {
  const app = hex(5)
  const bindingId = hex(8)
  const receiveCap = hex(6)
  const sendCap = hex(7)

  const provider = await request(port, 'POST', '/api/v1/notify/provider', signed(device, NOTIFY_DOMAINS.providerBinding, {
    type: 'hiverelay.notify.provider-binding.v1',
    bindingId,
    audience: relay.hex,
    app,
    device: device.hex,
    mode: 'runtimePush',
    provider: 'apns',
    platform: 'ios',
    scope: { bundle: 'org.hiverelay.runtime' },
    credentialMode: 'runtime-owned',
    tokenHash: hex(10),
    tokenCiphertext: 'encrypted-provider-token',
    generation: 1,
    createdAt: NOW,
    expiresAt: NOW + HOUR,
    nonce: 'a'.repeat(32)
  }))

  const registered = await request(port, 'POST', '/api/v1/notify/device', signed(device, NOTIFY_DOMAINS.deviceRegistration, {
    type: 'hiverelay.notify.device-registration.v1',
    audience: relay.hex,
    app,
    user: user.hex,
    device: device.hex,
    encryptionKey: hex(12),
    bindingId,
    createdAt: NOW,
    expiresAt: NOW + HOUR,
    nonce: 'b'.repeat(32)
  }))

  const receive = await request(port, 'POST', '/api/v1/notify/receive-cap', signed(user, NOTIFY_DOMAINS.receiveCap, {
    type: 'hiverelay.notify.receive-cap.v1',
    capId: receiveCap,
    audience: relay.hex,
    user: user.hex,
    app,
    device: device.hex,
    bindingId,
    tokenHash: hex(10),
    channels: ['message'],
    modes: ['direct', 'watch'],
    quota: { perHour: 30, burst: 5, maxTtlSeconds: 3600, maxUrgency: 'normal' },
    createdAt: NOW,
    expiresAt: NOW + HOUR,
    nonce: 'd'.repeat(32)
  }))

  const sendCapRes = await request(port, 'POST', '/api/v1/notify/send-cap', signed(user, NOTIFY_DOMAINS.sendCap, {
    type: 'hiverelay.notify.send-cap.v1',
    capId: sendCap,
    receiveCap,
    audience: relay.hex,
    app,
    device: device.hex,
    sender: sender.hex,
    channel: 'message',
    quota: { perHour: 10, burst: 3, maxTtlSeconds: 3600, maxUrgency: 'normal' },
    createdAt: NOW,
    expiresAt: NOW + HOUR,
    nonce: 'e'.repeat(32)
  }))

  return { provider, registered, receive, sendCap: sendCapRes }
}

function request (port, method, path, body = null, headers = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({
      hostname: '127.0.0.1',
      port,
      method,
      path,
      headers: { 'Content-Type': 'application/json', ...headers }
    }, (res) => {
      let data = ''
      res.on('data', (chunk) => { data += chunk })
      res.on('end', () => {
        let parsed
        try { parsed = JSON.parse(data) } catch { parsed = data }
        resolve({ statusCode: res.statusCode, body: parsed, headers: res.headers })
      })
    })
    req.on('error', reject)
    if (body != null) req.write(JSON.stringify(body))
    req.end()
  })
}

async function serverWithApi (t, node, opts = {}) {
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY, ...opts })
  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 { api, port }
}

function signed (key, domain, body) {
  const signature = b4a.alloc(64)
  sodium.crypto_sign_detached(signature, notifySignaturePayload(domain, body), key.secretKey)
  return { ...body, signature: b4a.toString(signature, 'hex') }
}

function keyPair (seedByte) {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_seed_keypair(publicKey, secretKey, b4a.alloc(32, seedByte))
  return {
    publicKey,
    secretKey,
    hex: b4a.toString(publicKey, 'hex')
  }
}

function hex (byte) {
  return byte.toString(16).padStart(2, '0').repeat(32)
}