OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-custody-management.test.js

375 lines · javascript ref:
import test from 'brittle'
import {
  resolveOperatorCustodyRoute,
  resolvePublisherCustodyRoute,
  runOperatorCustodyAction,
  runOperatorCustodyRouteAction,
  runPublisherCustodyAction,
  runPublisherCustodyRouteAction
} from '../../packages/core/core/relay-node/api-custody-management.js'

const INTENT_ID = 'a'.repeat(64)

function makeNode () {
  const calls = []
  const keyPair = { publicKey: Buffer.alloc(32, 1) }
  const node = {
    swarm: { keyPair },
    calls,
    seedingRegistry: {
      async publishCustodyIntent (body, signer) {
        calls.push(['intent', body, signer])
        return { type: 'custody-intent', intentId: body.intentId || INTENT_ID }
      },
      async publishCustodyCommit (body, signer) {
        calls.push(['commit', body, signer])
        return { type: 'custody-commit', intentId: body.intentId }
      },
      async publishSourceRetired (body, signer) {
        calls.push(['source-retired', body, signer])
        return { type: 'source-retired', intentId: body.intentId }
      },
      async recordCustodyProof (body, signer) {
        calls.push(['proof', body, signer])
        return { type: 'custody-proof', proof: body.proof }
      },
      async recordCustodyExpiryWitness (body, signer) {
        calls.push(['witness', body, signer])
        return { type: 'custody-expiry-witness', intentId: body.intentId }
      }
    },
    async createCustodyNonServingProof (intentId, body) {
      calls.push(['non-serving-proof', { ...body, intentId }])
      return { type: 'custody-non-serving-proof', intentId }
    }
  }
  return node
}

test('api custody management: operator route resolver preserves auth messages and path ids', (t) => {
  t.alike(resolveOperatorCustodyRoute('POST', '/api/custody/intent'), {
    action: 'intent',
    intentId: null,
    authMessage: 'Unauthorized — API key required for /api/custody/intent'
  })

  t.alike(resolveOperatorCustodyRoute('POST', `/api/custody/${INTENT_ID}/commit`), {
    action: 'commit',
    intentId: INTENT_ID,
    authMessage: 'Unauthorized — API key required for /api/custody/:intentId/commit'
  })

  t.alike(resolveOperatorCustodyRoute('POST', `/api/custody/${INTENT_ID}/source-retired`), {
    action: 'source-retired',
    intentId: INTENT_ID,
    authMessage: 'Unauthorized — API key required for /api/custody/:intentId/source-retired'
  })

  t.alike(resolveOperatorCustodyRoute('POST', '/api/custody/proof'), {
    action: 'proof',
    intentId: null,
    authMessage: 'Unauthorized — API key required for /api/custody/proof'
  })

  t.alike(resolveOperatorCustodyRoute('POST', `/api/custody/${INTENT_ID}/witness`), {
    action: 'witness',
    intentId: INTENT_ID,
    authMessage: 'Unauthorized — API key required for /api/custody/:intentId/witness'
  })

  t.alike(resolveOperatorCustodyRoute('POST', '/api/custody/not-hex/non-serving-proof'), {
    action: 'non-serving-proof',
    intentId: 'not-hex',
    authMessage: 'Unauthorized — API key required for /api/custody/:intentId/non-serving-proof'
  })

  t.is(resolveOperatorCustodyRoute('GET', '/api/custody/intent'), null)
  t.is(resolveOperatorCustodyRoute('POST', '/api/custody'), null)
  t.is(resolveOperatorCustodyRoute('POST', '/api/v1/custody/intent'), null)
})

test('api custody management: publisher route resolver keeps signed custody route scope narrow', (t) => {
  t.alike(resolvePublisherCustodyRoute('POST', '/api/v1/custody/intent'), {
    action: 'intent',
    intentId: null
  })

  t.alike(resolvePublisherCustodyRoute('POST', `/api/v1/custody/${INTENT_ID}/commit`), {
    action: 'commit',
    intentId: INTENT_ID,
    authMessage: null
  })

  t.alike(resolvePublisherCustodyRoute('POST', '/api/v1/custody/not-hex/source-retired'), {
    action: 'source-retired',
    intentId: 'not-hex',
    authMessage: null
  })

  t.is(resolvePublisherCustodyRoute('GET', '/api/v1/custody/intent'), null)
  t.is(resolvePublisherCustodyRoute('POST', '/api/custody/intent'), null)
  t.is(resolvePublisherCustodyRoute('POST', '/api/v1/custody/proof'), null)
})

test('api custody management: route action helpers keep operator and publisher signer boundaries separate', async (t) => {
  const node = makeNode()

  const operator = await runOperatorCustodyRouteAction({
    route: { action: 'commit', intentId: INTENT_ID },
    body: { receiptRoot: 'b'.repeat(64) },
    node
  })
  const publisher = await runPublisherCustodyRouteAction({
    route: { action: 'source-retired', intentId: INTENT_ID },
    body: { signature: 'sig' },
    node
  })
  const unknownOperator = await runOperatorCustodyRouteAction({ route: null, node })
  const unknownPublisher = await runPublisherCustodyRouteAction({ route: null, node })

  t.alike(operator.payload, { ok: true, type: 'custody-commit', intentId: INTENT_ID })
  t.alike(publisher.payload, { ok: true, type: 'source-retired', intentId: INTENT_ID })
  t.is(node.calls[0][0], 'commit')
  t.is(node.calls[0][2], node.swarm.keyPair, 'operator route action signs with relay keypair')
  t.is(node.calls[1][0], 'source-retired')
  t.is(node.calls[1][2], null, 'publisher route action refuses relay-side signing fallback')
  t.is(unknownOperator.status, 404)
  t.alike(unknownOperator.payload, { error: 'unknown operator custody route' })
  t.is(unknownPublisher.status, 404)
  t.alike(unknownPublisher.payload, { error: 'unknown publisher custody route' })
})

test('api custody management: operator routes preserve readiness and validation order', async (t) => {
  let out = await runOperatorCustodyAction({
    action: 'intent',
    body: {},
    node: { seedingRegistry: null }
  })
  t.is(out.status, 503)
  t.alike(out.payload, { error: 'Registry not running' })

  out = await runOperatorCustodyAction({
    action: 'intent',
    body: {},
    node: { seedingRegistry: {}, swarm: null }
  })
  t.is(out.status, 503)
  t.alike(out.payload, { error: 'Relay keypair unavailable' })

  out = await runOperatorCustodyAction({
    action: 'commit',
    body: {},
    intentId: 'bad-id',
    node: { seedingRegistry: null }
  })
  t.is(out.status, 503, 'commit checks registry readiness before route id shape')
  t.alike(out.payload, { error: 'Registry not running' })

  out = await runOperatorCustodyAction({
    action: 'witness',
    body: {},
    intentId: 'bad-id',
    node: { seedingRegistry: null }
  })
  t.is(out.status, 400, 'witness rejects malformed route ids before registry access')
  t.alike(out.payload, { error: 'intentId must be 64 hex characters' })

  out = await runOperatorCustodyAction({
    action: 'witness',
    body: {},
    intentId: INTENT_ID,
    node: { seedingRegistry: null }
  })
  t.is(out.status, 503)
  t.alike(out.payload, { error: 'registry not running' })
})

test('api custody management: disabled profile rejects operator and publisher actions before runtime access', async (t) => {
  const node = {
    swarm: {
      get keyPair () {
        t.fail('disabled custody action must not read relay signer')
      }
    },
    seedingRegistry: {
      async publishCustodyIntent () {
        t.fail('disabled custody action must not mutate registry')
      },
      async publishCustodyCommit () {
        t.fail('disabled custody action must not publish custody commit')
      }
    },
    async createCustodyNonServingProof () {
      t.fail('disabled custody action must not create non-serving proof')
    }
  }

  const operator = await runOperatorCustodyAction({
    action: 'intent',
    body: {},
    node,
    disabled: true
  })
  t.is(operator.ok, false)
  t.is(operator.kind, 'disabled-profile')
  t.is(operator.status, 409)
  t.ok(operator.payload.error.startsWith('not-enabled: '), 'operator action returns formatted disabled-profile error')

  const publisher = await runPublisherCustodyAction({
    action: 'commit',
    body: {},
    intentId: 'not-hex',
    node,
    disabled: true
  })
  t.is(publisher.ok, false)
  t.is(publisher.kind, 'disabled-profile')
  t.is(publisher.status, 409)
  t.ok(publisher.payload.error.startsWith('not-enabled: '), 'publisher action returns formatted disabled-profile error')
})

test('api custody management: operator actions call registry with relay keypair', async (t) => {
  const node = makeNode()

  let out = await runOperatorCustodyAction({
    action: 'intent',
    body: { intentId: INTENT_ID },
    node
  })
  t.alike(out.payload, { ok: true, type: 'custody-intent', intentId: INTENT_ID })

  out = await runOperatorCustodyAction({
    action: 'commit',
    body: { receiptRoot: 'b'.repeat(64) },
    intentId: INTENT_ID,
    node
  })
  t.alike(out.payload, { ok: true, type: 'custody-commit', intentId: INTENT_ID })

  out = await runOperatorCustodyAction({
    action: 'source-retired',
    body: { retiredAtVersion: 7 },
    intentId: INTENT_ID,
    node
  })
  t.alike(out.payload, { ok: true, type: 'source-retired', intentId: INTENT_ID })

  out = await runOperatorCustodyAction({
    action: 'proof',
    body: { proof: 'ok' },
    node
  })
  t.alike(out.payload, { ok: true, type: 'custody-proof', proof: 'ok' })

  out = await runOperatorCustodyAction({
    action: 'witness',
    body: { observedCatalog: false },
    intentId: INTENT_ID,
    node
  })
  t.alike(out.payload, { type: 'custody-expiry-witness', intentId: INTENT_ID })

  out = await runOperatorCustodyAction({
    action: 'non-serving-proof',
    body: { reason: 'expired' },
    intentId: INTENT_ID,
    node
  })
  t.alike(out.payload, { ok: true, type: 'custody-non-serving-proof', intentId: INTENT_ID })

  t.is(node.calls.length, 6)
  for (const call of node.calls.slice(0, 5)) {
    t.is(call[2], node.swarm.keyPair, call[0] + ' uses relay keypair')
  }
  t.is(node.calls[1][1].receiptRoot, 'b'.repeat(64))
  t.is(node.calls[2][1].retiredAtVersion, 7)
  t.is(node.calls[4][1].observedCatalog, false)
})

test('api custody management: non-serving proof keeps conflict status mapping', async (t) => {
  const node = {
    async createCustodyNonServingProof () {
      throw new Error('STILL_SERVING: catalog entry is still present')
    }
  }

  const out = await runOperatorCustodyAction({
    action: 'non-serving-proof',
    body: {},
    intentId: INTENT_ID,
    node
  })

  t.is(out.status, 409)
  t.alike(out.payload, { error: 'STILL_SERVING: catalog entry is still present' })
})

test('api custody management: publisher routes require signatures and pass null signer', async (t) => {
  const node = makeNode()

  let out = await runPublisherCustodyAction({
    action: 'intent',
    body: {},
    node
  })
  t.is(out.status, 400)
  t.alike(out.payload, { error: 'signature required (entry must be publisher-signed; see custody-signing.createCustodyIntent)' })

  out = await runPublisherCustodyAction({
    action: 'commit',
    body: { signature: 'sig' },
    intentId: 'bad-id',
    node
  })
  t.is(out.status, 400)
  t.alike(out.payload, { error: 'intentId must be 64 hex characters' })

  out = await runPublisherCustodyAction({
    action: 'commit',
    body: {},
    intentId: INTENT_ID,
    node
  })
  t.is(out.status, 400)
  t.alike(out.payload, { error: 'signature required (entry must be publisher-signed)' })

  out = await runPublisherCustodyAction({
    action: 'intent',
    body: { intentId: INTENT_ID, signature: 'sig' },
    node
  })
  t.alike(out.payload, { ok: true, type: 'custody-intent', intentId: INTENT_ID })

  out = await runPublisherCustodyAction({
    action: 'source-retired',
    body: { signature: 'sig' },
    intentId: INTENT_ID,
    node
  })
  t.alike(out.payload, { ok: true, type: 'source-retired', intentId: INTENT_ID })

  t.is(node.calls[0][2], null, 'publisher intent refuses relay-side signing fallback')
  t.is(node.calls[1][2], null, 'publisher source-retired refuses relay-side signing fallback')
})

test('api custody management: publisher registry errors stay delegated to custody error response', async (t) => {
  const persistError = new Error('The corestore is closed')
  const node = {
    seedingRegistry: {
      async publishCustodyIntent () {
        throw persistError
      }
    }
  }

  const out = await runPublisherCustodyAction({
    action: 'intent',
    body: { signature: 'sig' },
    node
  })

  t.is(out.ok, false)
  t.is(out.kind, 'custody-error')
  t.is(out.error, persistError)
})