OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / publish-channel.test.js

296 lines · javascript ref:
/**
 * PublishProtocol — verifies the publisher-signed submit channel that lets
 * external publishers submit custody-pipeline entries (intent / commit /
 * source-retired) over Protomux instead of HTTPS.
 *
 * Same fake-pair pattern as custody-channel.test.js — no real Hyperswarm
 * dialled. End-to-end integration over Protomux is covered separately by
 * a future testnet test once the wiring is reviewed.
 */

import test from 'brittle'
import { PublishProtocol, PublishProtocolClient, SUBMIT_KINDS } from 'p2p-hiverelay/core/protocol/publish-channel.js'

const SERVER_PUBKEY = 'aa'.repeat(32)
const CLIENT_PUBKEY = 'bb'.repeat(32)

/**
 * Wire a PublishProtocolClient (publisher) to a PublishProtocol (relay)
 * via fake in-memory message handlers, so we exercise the real onMessage
 * dispatch + RESULT routing without needing Hyperswarm. Each side gets a
 * `msgHandler.send` that calls the other's `_onMessage`.
 */
function fakePair (client, server) {
  client.channels.set(SERVER_PUBKEY, {
    channel: { close: () => {} },
    msgHandler: { send: (msg) => server._onMessage(CLIENT_PUBKEY, server.channels.get(CLIENT_PUBKEY).msgHandler, msg) },
    pending: new Map()
  })
  server.channels.set(CLIENT_PUBKEY, {
    channel: { close: () => {} },
    msgHandler: { send: (msg) => client._onMessage(SERVER_PUBKEY, msg) }
  })
  // Re-bind the client's entry so its pending map is the same the channel
  // dispatch reads.
  return { client, server }
}

// ─── Happy path per kind ─────────────────────────────────────────────────

test('PublishProtocol: SUBMIT_INTENT routes through onSubmitIntent handler', async (t) => {
  const seen = []
  const server = new PublishProtocol({
    onSubmitIntent: async (body) => {
      seen.push({ kind: 'intent', body })
      return { ok: true, result: { type: 'custody-intent', intentId: body.intentId } }
    }
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const res = await client.submit(SERVER_PUBKEY, 'intent', {
    type: 'custody-intent',
    intentId: 'ee'.repeat(32),
    signature: '11'.repeat(64)
  })

  t.is(res.ok, true)
  t.absent(res.error)
  t.is(res.result.type, 'custody-intent')
  t.is(seen.length, 1)
  t.is(seen[0].kind, 'intent')
})

test('PublishProtocol: SUBMIT_COMMIT routes through onSubmitCommit handler', async (t) => {
  const seen = []
  const server = new PublishProtocol({
    onSubmitCommit: async (body) => {
      seen.push({ kind: 'commit', body })
      return { ok: true, result: { type: 'custody-commit' } }
    }
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const res = await client.submit(SERVER_PUBKEY, 'commit', {
    type: 'custody-commit',
    intentId: 'aa'.repeat(32),
    signature: '22'.repeat(64)
  })

  t.is(res.ok, true)
  t.is(res.result.type, 'custody-commit')
  t.is(seen.length, 1)
})

test('PublishProtocol: SUBMIT_SOURCE_RETIRED routes through onSubmitSourceRetired handler', async (t) => {
  const seen = []
  const server = new PublishProtocol({
    onSubmitSourceRetired: async (body) => {
      seen.push({ kind: 'source-retired', body })
      return { ok: true, result: { type: 'source-retired' } }
    }
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const res = await client.submit(SERVER_PUBKEY, 'source-retired', {
    type: 'source-retired',
    intentId: 'bb'.repeat(32),
    signature: '33'.repeat(64)
  })

  t.is(res.ok, true)
  t.is(res.result.type, 'source-retired')
  t.is(seen.length, 1)
})

// ─── Error / edge cases ──────────────────────────────────────────────────

test('PublishProtocol: handler throw becomes ok:false with the error message', async (t) => {
  const server = new PublishProtocol({
    onSubmitIntent: async () => { throw new Error('INVALID_CUSTODY_TRANSITION: receipt before intent') }
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const res = await client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent', signature: 'x' })
  t.is(res.ok, false)
  t.ok(res.error.includes('INVALID_CUSTODY_TRANSITION'))
  t.absent(res.retryable, 'thrown errors default retryable to undefined (treated as non-retryable)')
})

test('PublishProtocol: handler returning retryable:true propagates through to client', async (t) => {
  const server = new PublishProtocol({
    onSubmitIntent: async () => ({ ok: false, error: 'The corestore is closed', retryable: true })
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const res = await client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent', signature: 'x' })
  t.is(res.ok, false)
  t.is(res.retryable, true)
  t.ok(res.error.includes('corestore is closed'))
})

test('PublishProtocol: unknown kind returns ok:false', async (t) => {
  const server = new PublishProtocol()
  const client = new PublishProtocolClient()
  fakePair(client, server)

  // Send a raw SUBMIT with a kind the client wrapper would have rejected
  // up front; we want to verify the server's defensive check.
  const entry = client.channels.get(SERVER_PUBKEY)
  const resPromise = new Promise(resolve => {
    entry.pending.set(99, { resolve, reject: () => {} })
  })
  entry.msgHandler.send({ type: 1, id: 99, kind: 'made-up-kind', body: {} })
  const res = await resPromise
  t.is(res.ok, false)
  t.ok(/unknown submit kind/.test(res.error))
})

test('PublishProtocol: missing body returns ok:false', async (t) => {
  const server = new PublishProtocol({
    onSubmitIntent: async () => { throw new Error('handler should not be called') }
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const entry = client.channels.get(SERVER_PUBKEY)
  const resPromise = new Promise(resolve => {
    entry.pending.set(7, { resolve, reject: () => {} })
  })
  entry.msgHandler.send({ type: 1, id: 7, kind: 'intent', body: null })
  const res = await resPromise
  t.is(res.ok, false)
  t.ok(/body must be an object/.test(res.error))
})

test('PublishProtocol: default handlers return ok:false when kind not wired', async (t) => {
  // Constructing without any handlers should yield a relay that rejects
  // every submit with a typed "not configured" error rather than throwing
  // or timing out.
  const server = new PublishProtocol()
  const client = new PublishProtocolClient()
  fakePair(client, server)

  for (const kind of SUBMIT_KINDS) {
    const res = await client.submit(SERVER_PUBKEY, kind, { type: kind, signature: 'x' })
    t.is(res.ok, false, `kind=${kind} must reject`)
    t.ok(/not configured on this relay/.test(res.error), `kind=${kind} error mentions not configured`)
  }
})

test('PublishProtocol: submitting unknown kind on the client rejects immediately', async (t) => {
  const client = new PublishProtocolClient()
  fakePair(client, new PublishProtocol())
  try {
    await client.submit(SERVER_PUBKEY, 'totally-fake', {})
    t.fail('should have rejected')
  } catch (err) {
    t.ok(/unknown submit kind/.test(err.message))
  }
})

test('PublishProtocol: submit on a non-attached remote rejects immediately', async (t) => {
  const client = new PublishProtocolClient()
  try {
    await client.submit('cc'.repeat(32), 'intent', {})
    t.fail('should have rejected')
  } catch (err) {
    t.ok(/no publish channel attached/.test(err.message))
  }
})

test('PublishProtocol: SUBMIT_KINDS includes seed (forward-compat with seed support)', (t) => {
  // 'seed' is included in the SUBMIT_KINDS contract so the client wrapper
  // doesn't reject it pre-flight. Server-side support is currently
  // omitted (returns "not configured on this relay") and lands in a
  // follow-up that extracts api.js's /seed validation into a shared helper.
  t.ok(SUBMIT_KINDS.has('seed'))
  t.ok(SUBMIT_KINDS.has('intent'))
  t.ok(SUBMIT_KINDS.has('commit'))
  t.ok(SUBMIT_KINDS.has('source-retired'))
})

// ─── Correlation / lifecycle ─────────────────────────────────────────────

test('PublishProtocol: concurrent submits correlate to the right resolves by id', async (t) => {
  const server = new PublishProtocol({
    onSubmitIntent: async (body) => {
      // Wait for an external signal so we can let the two requests race.
      await new Promise(resolve => setTimeout(resolve, body.delay))
      return { ok: true, result: { tag: body.tag } }
    }
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const [a, b, c] = await Promise.all([
    client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent', tag: 'A', delay: 30 }),
    client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent', tag: 'B', delay: 5 }),
    client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent', tag: 'C', delay: 15 })
  ])

  t.is(a.result.tag, 'A', 'first submit gets A back even though it resolves last')
  t.is(b.result.tag, 'B')
  t.is(c.result.tag, 'C')
})

test('PublishProtocol: channel close rejects in-flight submits', async (t) => {
  const server = new PublishProtocol({
    onSubmitIntent: () => new Promise(() => {}) // never resolves
  })
  const client = new PublishProtocolClient()
  fakePair(client, server)

  const submitPromise = client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent' })

  // Simulate channel close on the client side
  setImmediate(() => client._onClose(SERVER_PUBKEY))

  try {
    await submitPromise
    t.fail('should have rejected')
  } catch (err) {
    t.ok(/closed before result arrived/.test(err.message))
  }
})

test('PublishProtocol: submitTimeoutMs rejects after deadline', async (t) => {
  const server = new PublishProtocol({
    onSubmitIntent: () => new Promise(() => {}) // never resolves
  })
  const client = new PublishProtocolClient({ submitTimeoutMs: 50 })
  fakePair(client, server)

  try {
    await client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent' })
    t.fail('should have timed out')
  } catch (err) {
    t.ok(/submit timeout/.test(err.message))
  }
})

test('PublishProtocol: emits submit-handled with outcome metadata', async (t) => {
  const handled = []
  const server = new PublishProtocol({
    onSubmitIntent: async () => ({ ok: true, result: {} }),
    onSubmitCommit: async () => { throw new Error('boom') }
  })
  server.on('submit-handled', (e) => handled.push(e))
  const client = new PublishProtocolClient()
  fakePair(client, server)

  await client.submit(SERVER_PUBKEY, 'intent', { type: 'custody-intent' })
  await client.submit(SERVER_PUBKEY, 'commit', { type: 'custody-commit' })

  t.is(handled.length, 2)
  t.is(handled[0].kind, 'intent')
  t.is(handled[0].ok, true)
  t.is(handled[1].kind, 'commit')
  t.is(handled[1].ok, false)
  t.ok(/boom/.test(handled[1].error))
})