OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-catalog-read.test.js

401 lines · javascript ref:
import test from 'brittle'
import {
  CATALOG_TYPE_ERROR,
  RELAY_CATALOG_PAGE_SIZE_MAX,
  buildCatalogReadRoutePayload,
  buildGatewayCatalogPayload,
  buildRelayCatalogPayload,
  catalogEntriesByType,
  resolveCatalogReadRoute
} from 'p2p-hiverelay/core/relay-node/api-catalog-read.js'

function nodeWithCatalog (entries, config = {}) {
  return {
    config,
    swarm: { keyPair: { publicKey: Buffer.alloc(32, 7) } },
    appRegistry: {
      catalog (opts) {
        nodeWithCatalog.lastCatalogOpts = opts
        return entries
      }
    },
    federation: {
      snapshot () {
        return { followed: [{ url: 'https://relay.example' }], mirrored: [] }
      }
    },
    _resolveAcceptMode () {
      return 'review'
    }
  }
}

test('api catalog read: route helper maps exact public catalog reads', (t) => {
  t.alike(resolveCatalogReadRoute('GET', '/catalog.json'), {
    kind: 'catalog'
  })
  t.alike(resolveCatalogReadRoute('GET', '/api/apps'), {
    kind: 'legacy-type',
    type: 'app'
  })
  t.alike(resolveCatalogReadRoute('GET', '/api/drives'), {
    kind: 'legacy-type',
    type: 'drive'
  })

  t.is(resolveCatalogReadRoute('POST', '/catalog.json'), null)
  t.is(resolveCatalogReadRoute('GET', '/catalog.json/extra'), null)
  t.is(resolveCatalogReadRoute('GET', '/api/apps/extra'), null)
  t.is(resolveCatalogReadRoute('GET', '/api/resources'), null)
})

test('api catalog read: route payload helper dispatches catalog and legacy typed routes', (t) => {
  const entries = [
    { appKey: '1'.repeat(64), type: 'app', name: 'App One' },
    { appKey: '2'.repeat(64), type: 'drive', name: 'Drive One' },
    { appKey: '3'.repeat(64), type: 'drive', name: 'Drive Two' }
  ]
  const node = nodeWithCatalog(entries)

  const catalog = buildCatalogReadRoutePayload({
    route: { kind: 'catalog' },
    node,
    url: new URL('http://relay.local/catalog.json?type=drive&pageSize=1')
  })
  const legacy = buildCatalogReadRoutePayload({
    route: { kind: 'legacy-type', type: 'drive' },
    node,
    url: new URL('http://relay.local/api/drives?page=2&pageSize=1')
  })
  const unknown = buildCatalogReadRoutePayload({
    route: { kind: 'unknown' },
    node,
    url: new URL('http://relay.local/catalog.json')
  })

  t.is(catalog.ok, true)
  t.is(catalog.payload.filters.type, 'drive')
  t.is(catalog.payload.pagination.pageSize, 1)
  t.is(catalog.payload.drives[0].appKey, '2'.repeat(64))
  t.is(legacy.status, 200)
  t.alike(legacy.payload, [{ appKey: '3'.repeat(64), type: 'drive', name: 'Drive Two' }])
  t.is(unknown.status, 404)
  t.is(unknown.payload.error, 'unknown catalog read route')
})

test('api catalog read: relay catalog filters, counts, and paginates in one bounded helper', (t) => {
  const parentKey = 'f'.repeat(64)
  const entries = [
    { appKey: '1'.repeat(64), type: 'app', name: 'Chat', categories: ['social'] },
    { appKey: '2'.repeat(64), type: 'drive', name: 'Root Drive', categories: ['files'] },
    { appKey: '3'.repeat(64), type: 'drive', name: 'Mounted Drive', parentKey, categories: ['files', 'ghost-drive'] },
    { appKey: '4'.repeat(64), type: 'dataset', name: 'Data', categories: ['research'] },
    { appKey: '5'.repeat(64), type: 'media', name: 'Clip', categories: ['video'] }
  ]
  const node = nodeWithCatalog(entries, {
    regions: ['EU'],
    operator: 'operator-a',
    custody: { redactedCatalog: true }
  })

  const result = buildRelayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json?category=FILES&page=1&pageSize=1')
  })

  t.is(result.ok, true)
  t.is(result.payload.region, 'EU')
  t.is(result.payload.operator, 'operator-a')
  t.is(result.payload.filters.category, 'FILES')
  t.is(result.payload.pagination.pageSize, 1)
  t.is(result.payload.pagination.total, 2)
  t.is(result.payload.pagination.hasNext, true)
  t.alike(result.payload.count, {
    total: 2,
    apps: 0,
    drives: 1,
    resources: 1,
    datasets: 0,
    media: 0
  })
  t.is(result.payload.drives.length, 1, 'first page contains root drive bucket')
  t.is(result.payload.resources.length, 0, 'resource bucket is not leaked from off-page entries')
  t.is(result.payload.federation.followed[0].url, 'https://relay.example')
  t.is(result.payload.federation.followed[0].pubkey, null)
  t.is(result.payload.federation.followed[0].addedAt, null)
  t.is(result.payload.acceptMode, 'review')
  t.alike(nodeWithCatalog.lastCatalogOpts, { redactPrivate: true })

  const secondPage = buildRelayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json?category=FILES&page=2&pageSize=1')
  })
  t.is(secondPage.payload.resources.length, 1, 'second page contains mounted drive bucket')

  const parentFilter = buildRelayCatalogPayload({
    node,
    url: new URL(`http://relay.local/catalog.json?parent=${parentKey}`)
  })
  t.is(parentFilter.payload.pagination.total, 1)
  t.is(parentFilter.payload.resources[0].appKey, '3'.repeat(64))
})

test('api catalog read: relay catalog sanitizes top-level public metadata', (t) => {
  const node = nodeWithCatalog([], {
    regions: ['EU\u0000bad'],
    operator: 'operator\nsecret'
  })
  node._resolveAcceptMode = () => 'not-a-mode'

  const malformed = buildRelayCatalogPayload({
    node,
    relayKey: 'not-a-key',
    url: new URL('http://relay.local/catalog.json')
  })

  t.is(malformed.payload.relayKey, null)
  t.is(malformed.payload.region, null)
  t.is(malformed.payload.operator, null)
  t.is(malformed.payload.acceptMode, null)

  node.config = {
    regions: ['  NA  '],
    operator: '  operator-one  '
  }
  node._resolveAcceptMode = () => 'allowlist'
  const valid = buildRelayCatalogPayload({
    node,
    relayKey: 'A'.repeat(64),
    url: new URL('http://relay.local/catalog.json')
  })

  t.is(valid.payload.relayKey, 'a'.repeat(64))
  t.is(valid.payload.region, 'NA')
  t.is(valid.payload.operator, 'operator-one')
  t.is(valid.payload.acceptMode, 'allowlist')

  node.config.operator = 'x'.repeat(256)
  const truncated = buildRelayCatalogPayload({
    node,
    relayKey: 'b'.repeat(64),
    url: new URL('http://relay.local/catalog.json')
  })
  t.is(Buffer.byteLength(truncated.payload.operator, 'utf8'), 128)
})

test('api catalog read: invalid types are rejected instead of widening public catalog reads', (t) => {
  const node = nodeWithCatalog([{ type: 'app' }])
  const relay = buildRelayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json?type=unknown')
  })
  const gateway = buildGatewayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json?type=unknown')
  })

  t.is(relay.ok, false)
  t.is(relay.status, 400)
  t.is(relay.payload.error, CATALOG_TYPE_ERROR)
  t.is(gateway.ok, false)
  t.is(gateway.status, 400)
  t.is(gateway.payload.error, CATALOG_TYPE_ERROR)
})

test('api catalog read: relay catalog sanitizes federation snapshot before public response', (t) => {
  const node = nodeWithCatalog([])
  node.federation = {
    snapshot () {
      return {
        followed: [
          { url: 'https://user:pass@secret.example', pubkey: 'a'.repeat(64), addedAt: 1, apiKey: 'leak' },
          { url: 'https://relay.example', pubkey: 'B'.repeat(64), addedAt: 2, apiKey: 'leak' }
        ],
        mirrored: [
          { url: 'https://mirror.example', pubkey: 'not-a-key', addedAt: -1, hidden: 'leak' }
        ],
        republished: [
          {
            appKey: 'c'.repeat(64),
            sourceUrl: 'https://source.example',
            sourcePubkey: 'D'.repeat(64),
            channel: 'stable',
            note: 'operator curated',
            privateKey: 'leak'
          }
        ],
        peerCatalogs: [
          {
            url: 'https://peer.example',
            pubkey: 'e'.repeat(64),
            region: 'EU\u0000bad',
            operator: 'operator-one',
            fetchedAt: 3,
            apps: [
              {
                appKey: 'f'.repeat(64),
                publisherPubkey: 'a'.repeat(64),
                type: 'drive',
                privacyTier: 'public',
                hidden: 'leak'
              }
            ],
            rawCatalog: 'leak'
          }
        ],
        secret: 'leak',
        running: true,
        followIntervalMs: 300000
      }
    }
  }

  const result = buildRelayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json')
  })

  t.is(result.ok, true)
  t.alike(result.payload.federation.followed, [{
    url: 'https://relay.example',
    pubkey: 'b'.repeat(64),
    addedAt: 2
  }])
  t.alike(result.payload.federation.mirrored, [{
    url: 'https://mirror.example',
    pubkey: null,
    addedAt: null
  }])
  t.alike(result.payload.federation.republished, [{
    appKey: 'c'.repeat(64),
    sourceUrl: 'https://source.example',
    sourcePubkey: 'd'.repeat(64),
    channel: 'stable',
    note: 'operator curated',
    addedAt: null
  }])
  t.is(result.payload.federation.peerCatalogs[0].region, null)
  t.is(result.payload.federation.peerCatalogs[0].operator, 'operator-one')
  t.alike(result.payload.federation.peerCatalogs[0].apps[0], {
    appKey: 'f'.repeat(64),
    publisherPubkey: 'a'.repeat(64),
    type: 'drive',
    privacyTier: 'public',
    storageClass: null,
    availabilityClass: null,
    blind: false
  })
  const text = JSON.stringify(result.payload.federation)
  t.absent(text.includes('user:pass'))
  t.absent(text.includes('apiKey'))
  t.absent(text.includes('privateKey'))
  t.absent(text.includes('rawCatalog'))
  t.absent(text.includes('hidden'))
  t.absent(text.includes('secret'))
})

test('api catalog read: relaykernel profile omits federation snapshot', (t) => {
  const node = nodeWithCatalog([], {
    productProfile: 'relaykernel',
    federation: { enabled: false }
  })

  const result = buildRelayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json')
  })

  t.is(result.ok, true)
  t.is(result.payload.federation, null)

  node.config = {}
  node.mode = 'relaykernel'
  const modeResult = buildRelayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json')
  })
  t.is(modeResult.payload.federation, null)
})

test('api catalog read: gateway catalog keeps its legacy shape and valid catalogBeeKey advertisement', (t) => {
  const entries = [
    { appKey: '1'.repeat(64), type: 'app', name: 'App One' },
    { appKey: '2'.repeat(64), type: 'drive', name: 'Drive Two' },
    { appKey: '3'.repeat(64), type: 'dataset', name: 'Dataset Three' }
  ]
  const key = 'a'.repeat(64)
  const node = nodeWithCatalog(entries, { catalogBeeKey: key })

  const result = buildGatewayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json?page=2abc&pageSize=1e2&type=drive')
  })

  t.is(result.ok, true)
  t.alike(result.payload.items, [{ appKey: '2'.repeat(64), type: 'drive', name: 'Drive Two' }])
  t.is(result.payload.page, 1, 'malformed page falls back to default')
  t.is(result.payload.pageSize, 50, 'malformed pageSize falls back to default')
  t.is(result.payload.total, 1)
  t.is(result.payload.hasMore, false)
  t.is(result.payload.catalogBeeKey, key)

  node.config.catalogBeeKey = 'not-a-key'
  const invalidKey = buildGatewayCatalogPayload({
    node,
    url: new URL('http://relay.local/catalog.json')
  })
  t.absent(invalidKey.payload.catalogBeeKey, 'invalid catalogBeeKey is omitted')
})

test('api catalog read: legacy type arrays tolerate missing or malformed registries', (t) => {
  t.alike(catalogEntriesByType({ node: {}, type: 'drive' }), [])
  t.alike(catalogEntriesByType({
    node: {
      appRegistry: {
        catalog () {
          return { not: 'an array' }
        }
      }
    },
    type: 'drive'
  }), [])

  t.alike(catalogEntriesByType({
    node: nodeWithCatalog([
      { appKey: 'a'.repeat(64), type: 'app' },
      { appKey: 'b'.repeat(64), type: 'drive' }
    ]),
    type: 'drive'
  }), [{ appKey: 'b'.repeat(64), type: 'drive' }])
})

test('api catalog read: legacy type arrays are bounded and paginated', (t) => {
  const entries = []
  for (let i = 0; i < RELAY_CATALOG_PAGE_SIZE_MAX + 5; i++) {
    entries.push({ appKey: String(i).padStart(64, '0'), type: 'drive', name: 'Drive ' + i })
  }
  entries.push({ appKey: 'f'.repeat(64), type: 'app', name: 'Filtered App' })
  const node = nodeWithCatalog(entries)

  const first = catalogEntriesByType({ node, type: 'drive' })
  t.is(first.length, RELAY_CATALOG_PAGE_SIZE_MAX, 'default legacy response is capped')
  t.is(first[0].name, 'Drive 0')
  t.is(first[first.length - 1].name, 'Drive ' + (RELAY_CATALOG_PAGE_SIZE_MAX - 1))

  const second = catalogEntriesByType({
    node,
    type: 'drive',
    url: new URL('http://relay.local/api/drives?page=2&pageSize=3')
  })
  t.alike(second.map(entry => entry.name), ['Drive 3', 'Drive 4', 'Drive 5'])

  const malformed = catalogEntriesByType({
    node,
    type: 'drive',
    url: new URL('http://relay.local/api/drives?page=bad&pageSize=999999')
  })
  t.is(malformed.length, RELAY_CATALOG_PAGE_SIZE_MAX, 'malformed/oversized legacy query falls back to bounded defaults')

  t.alike(catalogEntriesByType({ node, type: 'unknown' }), [])
})