OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-seed-core.test.js

233 lines · javascript ref:
/**
 * POST /seed-core — pin a BARE Hypercore (e.g. a replicable catalog bee) by
 * public key, via Seeder.seedCore. Distinct from /seed, which opens a
 * Hyperdrive. Operator-authed.
 */

import test from 'brittle'
import http from 'http'
import { RelayAPI } from 'p2p-hiverelay/core/relay-node/api.js'
import {
  normalizeSeedCoreKey,
  resolveSeedCoreRoute,
  SEED_CORE_AUTH_MESSAGE,
  runSeedCoreAction
} from 'p2p-hiverelay/core/relay-node/api-seed-core.js'

const API_KEY = 'seed-core-test-key'
const HEX64 = 'a'.repeat(64)
const STORAGE_BOUND = 1024 * 1024

function mockRelayNode (seeder, opts = {}) {
  return {
    running: true,
    config: { storage: null },
    metrics: { getSummary () { return { uptime: 100 } } },
    seededApps: new Map(),
    appRegistry: { apps: new Map(), catalog () { return [] }, catalogForBroadcast () { return [] } },
    getStats () { return { running: true, seededApps: 0, connections: 0 } },
    getHealthStatus () { return { healthy: true } },
    async stop () {},
    async start () {},
    async seedApp () { return { ok: true } },
    async unseedApp () {},
    seeder,
    swarm: null,
    federation: null,
    _resolveAcceptMode () { return 'open' },
    catalogBeeKey: opts.catalogBeeKey || null,
    _catalogSet: [],
    async setCatalogBeeKey (k) { this._catalogSet.push(k); this.catalogBeeKey = k; return k },
    on () {},
    emit () {}
  }
}

function request (port, method, path, body, 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', (c) => { data += c })
      res.on('end', () => {
        let parsed
        try { parsed = JSON.parse(data) } catch (_) { parsed = data }
        resolve({ statusCode: res.statusCode, headers: res.headers, body: parsed })
      })
    })
    req.on('error', reject)
    if (body !== undefined && body !== null) req.write(JSON.stringify(body))
    req.end()
  })
}

async function makeServer (t, seeder, nodeOpts) {
  const node = mockRelayNode(seeder, nodeOpts)
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY })
  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 (_) {} }
    await new Promise(resolve => api.server.close(resolve))
  })
  return { port, node }
}

test('seed-core helper: route resolver maps only the exact operator seed-core route', (t) => {
  t.alike(resolveSeedCoreRoute('POST', '/seed-core'), {
    kind: 'seed-core',
    authMessage: SEED_CORE_AUTH_MESSAGE
  })
  t.is(resolveSeedCoreRoute('GET', '/seed-core'), null, 'wrong method falls through')
  t.is(resolveSeedCoreRoute('POST', '/seed-core/extra'), null, 'subpath falls through')
  t.is(resolveSeedCoreRoute('POST', '/seed'), null, 'adjacent operator seed route falls through')
  t.is(resolveSeedCoreRoute('POST', '/api/v1/seed'), null, 'publisher seed route falls through')
})

test('seed-core helper: normalizes aliases and validates route contract', async (t) => {
  t.is(normalizeSeedCoreKey({ coreKey: '  ' + 'A'.repeat(64) + '  ' }), HEX64)
  t.is(normalizeSeedCoreKey({ appKey: 'B'.repeat(64) }), 'b'.repeat(64))
  t.is(normalizeSeedCoreKey({ coreKey: 'not-hex' }), null)

  const unavailable = await runSeedCoreAction({ node: {}, body: { coreKey: HEX64 } })
  t.alike(unavailable, { ok: false, status: 503, payload: { error: 'seeder not available' } })

  const calls = []
  const node = mockRelayNode({
    async seedCore (key, opts) {
      calls.push({ key, opts })
      return { core: { length: 9 } }
    }
  })
  const seeded = await runSeedCoreAction({ node, body: { coreKey: 'A'.repeat(64), catalog: true, maxStorageBytes: STORAGE_BOUND } })
  t.alike(seeded, {
    ok: true,
    status: 200,
    payload: { ok: true, coreKey: HEX64, length: 9, catalogBee: true }
  })
  t.alike(calls, [{ key: HEX64, opts: { maxStorageBytes: STORAGE_BOUND } }])
  t.alike(node._catalogSet, [HEX64])
})

test('seed-core helper: delegates seed errors to caller error mapping', async (t) => {
  const err = new Error('corestore closed')
  const result = await runSeedCoreAction({
    node: mockRelayNode({
      async seedCore () {
        throw err
      }
    }),
    body: { coreKey: HEX64, maxStorageBytes: STORAGE_BOUND }
  })

  t.is(result.ok, false)
  t.is(result.kind, 'seed-error')
  t.is(result.error, err)
})

test('seed-core: auth required', async (t) => {
  const { port } = await makeServer(t, { async seedCore () { return { core: { length: 1 } } } })
  const res = await request(port, 'POST', '/seed-core', { coreKey: HEX64 })
  t.is(res.statusCode, 401)
})

test('seed-core: valid key pins via seeder.seedCore', async (t) => {
  const calls = []
  const seeder = { async seedCore (key) { calls.push(key); return { core: { length: 7 } } } }
  const { port } = await makeServer(t, seeder)
  const res = await request(port, 'POST', '/seed-core', { coreKey: HEX64, maxStorageBytes: STORAGE_BOUND }, { Authorization: 'Bearer ' + API_KEY })
  t.is(res.statusCode, 200)
  t.is(res.body.ok, true)
  t.is(res.body.coreKey, HEX64)
  t.is(res.body.length, 7)
  t.alike(calls, [HEX64], 'seedCore called once with the lowercased key')
})

test('seed-core: accepts appKey alias + lowercases', async (t) => {
  const calls = []
  const seeder = { async seedCore (key) { calls.push(key); return { core: { length: 0 } } } }
  const { port } = await makeServer(t, seeder)
  const res = await request(port, 'POST', '/seed-core', { appKey: 'B'.repeat(64), maxStorageBytes: STORAGE_BOUND }, { Authorization: 'Bearer ' + API_KEY })
  t.is(res.statusCode, 200)
  t.alike(calls, ['b'.repeat(64)])
})

test('seed-core: rejects a non-64-hex key', async (t) => {
  const { port } = await makeServer(t, { async seedCore () { return { core: { length: 1 } } } })
  const res = await request(port, 'POST', '/seed-core', { coreKey: 'not-hex' }, { Authorization: 'Bearer ' + API_KEY })
  t.is(res.statusCode, 400)
  t.ok(/64 hex/.test(res.body.error))
})

test('seed-core: rejects missing or unsafe storage bounds before seeding', async (t) => {
  const calls = []
  const { port } = await makeServer(t, {
    async seedCore (...args) {
      calls.push(args)
      return { core: { length: 1 } }
    }
  })
  const headers = { Authorization: 'Bearer ' + API_KEY }

  for (const body of [
    { coreKey: HEX64 },
    { coreKey: HEX64, maxStorageBytes: 0 },
    { coreKey: HEX64, maxStorageBytes: Number.MAX_SAFE_INTEGER + 1 }
  ]) {
    const res = await request(port, 'POST', '/seed-core', body, headers)
    t.is(res.statusCode, 400)
    t.ok(/positive safe integer/.test(res.body.error))
  }
  t.alike(calls, [], 'invalid bounds never reach the seeder')
})

test('seed-core: 503 when seeder is unavailable', async (t) => {
  const { port } = await makeServer(t, null)
  const res = await request(port, 'POST', '/seed-core', { coreKey: HEX64, maxStorageBytes: STORAGE_BOUND }, { Authorization: 'Bearer ' + API_KEY })
  t.is(res.statusCode, 503)
})

test('seed-core: transient seed errors stay retryable through the route', async (t) => {
  const seeder = {
    async seedCore () {
      throw new Error('The corestore is closed')
    }
  }
  const { port } = await makeServer(t, seeder)
  const res = await request(port, 'POST', '/seed-core', { coreKey: HEX64, maxStorageBytes: STORAGE_BOUND }, { Authorization: 'Bearer ' + API_KEY })
  t.is(res.statusCode, 503)
  t.is(res.headers['retry-after'], '5')
  t.is(res.body.retryable, true)
})

test('seed-core: catalog:true registers the catalog-bee pointer', async (t) => {
  const seeder = { async seedCore () { return { core: { length: 2 } } } }
  const { port, node } = await makeServer(t, seeder)
  const res = await request(port, 'POST', '/seed-core', { coreKey: HEX64, catalog: true, maxStorageBytes: STORAGE_BOUND }, { Authorization: 'Bearer ' + API_KEY })
  t.is(res.statusCode, 200)
  t.is(res.body.catalogBee, true)
  t.alike(node._catalogSet, [HEX64], 'setCatalogBeeKey called with the core key')
  t.is(node.catalogBeeKey, HEX64)
})

test('seed-core: without catalog flag, no pointer is set', async (t) => {
  const seeder = { async seedCore () { return { core: { length: 2 } } } }
  const { port, node } = await makeServer(t, seeder)
  const res = await request(port, 'POST', '/seed-core', { coreKey: HEX64, maxStorageBytes: STORAGE_BOUND }, { Authorization: 'Bearer ' + API_KEY })
  t.is(res.body.catalogBee, false)
  t.alike(node._catalogSet, [], 'setCatalogBeeKey not called')
})

test('catalog.json surfaces catalogBeeKey', async (t) => {
  const { port } = await makeServer(t, null, { catalogBeeKey: HEX64 })
  const res = await request(port, 'GET', '/catalog.json')
  t.is(res.statusCode, 200)
  t.is(res.body.catalogBeeKey, HEX64, 'catalog pointer surfaced for consumers')
})

test('catalog.json: catalogBeeKey is null until a catalog bee is published', async (t) => {
  const { port } = await makeServer(t, null)
  const res = await request(port, 'GET', '/catalog.json')
  t.is(res.body.catalogBeeKey, null)
})