OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / test-identity.js

295 lines · javascript ref:
#!/usr/bin/env node

/**
 * Identity System Integration Test Suite
 *
 * Tests the full identity lifecycle across the live network:
 *   1. Identity stats — system status and developer counts
 *   2. LNURL-auth — challenge creation, polling
 *   3. Attestation — submit, resolve, list
 *   4. Developer profiles — lookup, manual set
 *   5. Session management — validate, logout
 *   6. Identity service dispatch — whoami, peers, resolve, developer
 *   7. Cross-relay identity sync — verify sync propagation
 *   8. Identity middleware — context enrichment via dispatch
 *
 * Usage: node scripts/test-identity.js
 */

import http from 'http'
import crypto from 'crypto'

const API_KEY = process.env.HIVERELAY_API_KEY || 'hiverelay-secret'
const RELAYS = [
  { name: 'Utah', host: process.env.UTAH_IP || '144.172.101.215', port: 9100 },
  { name: 'Utah-US', host: process.env.UTAH_US_IP || '144.172.91.26', port: 9100 },
  { name: 'Singapore', host: process.env.SINGAPORE_IP || '104.194.153.179', port: 9100 }
]

function request (relay, method, path, body = null, opts = {}) {
  return new Promise((resolve, reject) => {
    const headers = { 'Content-Type': 'application/json' }
    if (opts.auth !== false) headers.Authorization = `Bearer ${API_KEY}`
    if (opts.headers) Object.assign(headers, opts.headers)
    const payload = body ? JSON.stringify(body) : null
    if (payload) headers['Content-Length'] = Buffer.byteLength(payload)
    const start = process.hrtime.bigint()
    const req = http.request({
      method, hostname: relay.host, port: relay.port, path, headers, timeout: opts.timeout || 15000
    }, (res) => {
      let data = ''
      res.setEncoding('utf8')
      res.on('data', chunk => { data += chunk })
      res.on('end', () => {
        const ms = Math.round(Number(process.hrtime.bigint() - start) / 1e6)
        try { resolve({ status: res.statusCode, data: JSON.parse(data), ms }) } catch { resolve({ status: res.statusCode, data, ms }) }
      })
    })
    req.on('error', reject)
    req.on('timeout', () => { req.destroy(); reject(new Error('TIMEOUT')) })
    if (payload) req.write(payload)
    req.end()
  })
}

function dispatch (relay, route, params = {}) {
  return request(relay, 'POST', '/api/v1/dispatch', { route, params })
}

let passed = 0; let failed = 0
const results = []
function log (icon, msg) { console.log(`  ${icon} ${msg}`) }
async function test (name, fn) {
  const start = Date.now()
  try { await fn(); log('✅', `${name} (${Date.now() - start}ms)`); passed++; results.push({ name, status: 'pass' }) } catch (err) { log('❌', `${name} — ${err.message}`); failed++; results.push({ name, status: 'fail', error: err.message }) }
}
function assert (cond, msg) { if (!cond) throw new Error(msg) }

async function runTests () {
  console.log('\n╔═══════════════════════════════════════════════════╗')
  console.log('║       HiveRelay Identity Integration Tests        ║')
  console.log('╚═══════════════════════════════════════════════════╝\n')

  const relay = RELAYS[1]

  // ──────────────────────────────────────────
  // 1. Identity System Stats
  // ──────────────────────────────────────────
  console.log('── Test Group 1: Identity System Stats ──')

  for (const r of RELAYS) {
    await test(`Identity stats on ${r.name}`, async () => {
      const res = await request(r, 'GET', '/api/v1/identity/stats')
      assert(res.status === 200, `HTTP ${res.status}`)
      assert(res.data, 'Empty stats')
      log('ℹ️', `${r.name}: ${JSON.stringify(res.data).slice(0, 120)}`)
    })
  }

  // ──────────────────────────────────────────
  // 2. Developer Listing
  // ──────────────────────────────────────────
  console.log('\n── Test Group 2: Developer Registry ──')

  let developerCount = 0
  let knownDevKey = null

  await test('List developers', async () => {
    const res = await request(relay, 'GET', '/api/v1/identity/developers')
    assert(res.status === 200, `HTTP ${res.status}`)
    developerCount = res.data.count || 0
    if (res.data.developers && res.data.developers.length > 0) {
      knownDevKey = res.data.developers[0].pubkey
      log('ℹ️', `${developerCount} developers. First: ${knownDevKey?.slice(0, 16)}...`)
    } else {
      log('ℹ️', 'No developers registered yet')
    }
  })

  if (knownDevKey) {
    await test('Get developer detail', async () => {
      const res = await request(relay, 'GET', `/api/v1/identity/developer/${knownDevKey}`)
      assert(res.status === 200, `HTTP ${res.status}`)
      assert(res.data.pubkey === knownDevKey, 'Key mismatch')
      log('ℹ️', `Developer: ${res.data.appKeys?.length || 0} app keys, profile: ${res.data.profile?.displayName || 'none'}`)
    })
  }

  // ──────────────────────────────────────────
  // 3. LNURL-Auth Challenge
  // ──────────────────────────────────────────
  console.log('\n── Test Group 3: LNURL-Auth ──')

  let challengeK1 = null

  await test('Create LNURL-auth challenge', async () => {
    const res = await request(relay, 'GET', '/api/v1/identity/lnurl-auth', null, { auth: false })
    assert(res.status === 200, `HTTP ${res.status}`)
    assert(res.data.k1, 'Missing k1 challenge')
    assert(res.data.lnurl, 'Missing lnurl')
    challengeK1 = res.data.k1
    log('ℹ️', `Challenge k1: ${challengeK1.slice(0, 16)}..., expires: ${res.data.expires || 'unknown'}`)
  })

  await test('Poll challenge status (pending)', async () => {
    if (!challengeK1) throw new Error('No challenge to poll')
    const res = await request(relay, 'GET', `/api/v1/identity/lnurl-auth/poll/${challengeK1}`, null, { auth: false })
    assert(res.status === 200, `HTTP ${res.status}`)
    assert(res.data.status === 'pending', `Expected pending, got ${res.data.status}`)
  })

  await test('Callback with invalid signature returns error', async () => {
    const fakeSig = crypto.randomBytes(64).toString('hex')
    const fakeKey = '02' + crypto.randomBytes(32).toString('hex') // Fake compressed pubkey
    const res = await request(relay, 'GET',
      `/api/v1/identity/lnurl-auth/callback?k1=${challengeK1}&sig=${fakeSig}&key=${fakeKey}`,
      null, { auth: false })
    assert(res.status === 200, `HTTP ${res.status}`) // LNURL spec returns 200 with status
    assert(res.data.status === 'ERROR', `Expected ERROR status, got ${res.data.status}`)
    log('ℹ️', `Invalid callback rejected: ${res.data.reason || res.data.error}`)
  })

  // ──────────────────────────────────────────
  // 4. Attestation
  // ──────────────────────────────────────────
  console.log('\n── Test Group 4: Attestation ──')

  await test('Submit attestation with bad format returns 400', async () => {
    const res = await request(relay, 'POST', '/api/v1/identity/attest', {
      appKey: crypto.randomBytes(32).toString('hex'),
      developerKey: crypto.randomBytes(32).toString('hex'),
      signature: crypto.randomBytes(64).toString('hex')
    })
    // Should get 400 (bad format) or 500 (validation error), NOT hang
    assert(res.status >= 200 && res.status < 600, `Unexpected status ${res.status}`)
    log('ℹ️', `Bad attestation: ${res.status} — ${JSON.stringify(res.data).slice(0, 80)}`)
  })

  await test('Resolve unknown app key returns null/404', async () => {
    const unknownKey = crypto.randomBytes(32).toString('hex')
    const res = await request(relay, 'GET', `/api/v1/identity/resolve/${unknownKey}`)
    assert(res.status === 200 || res.status === 404, `Expected 200/404, got ${res.status}`)
    log('ℹ️', `Unknown key resolve: ${res.status} — ${JSON.stringify(res.data).slice(0, 60)}`)
  })

  // If there are known attestations, test resolve
  if (knownDevKey) {
    await test('Resolve known developer app key', async () => {
      const devsRes = await request(relay, 'GET', '/api/v1/identity/developers')
      const dev = devsRes.data.developers?.find(d => d.appKeys?.length > 0)
      if (!dev) { log('ℹ️', 'No developer with app keys to test'); return }
      const appKey = dev.appKeys[0]
      const res = await request(relay, 'GET', `/api/v1/identity/resolve/${appKey}`)
      assert(res.status === 200, `HTTP ${res.status}`)
      log('ℹ️', `Resolved ${appKey.slice(0, 12)}... → developer ${dev.pubkey.slice(0, 12)}...`)
    })
  }

  // ──────────────────────────────────────────
  // 5. Session Management
  // ──────────────────────────────────────────
  console.log('\n── Test Group 5: Session Management ──')

  await test('Validate session with no token returns 401', async () => {
    const res = await request(relay, 'GET', '/api/v1/identity/session', null, { auth: false })
    assert(res.status === 401, `Expected 401, got ${res.status}`)
  })

  await test('Validate session with bad token returns 401', async () => {
    const res = await request(relay, 'GET', '/api/v1/identity/session', null, {
      headers: { Authorization: 'Bearer ' + crypto.randomBytes(32).toString('hex') }
    })
    assert(res.status === 401, `Expected 401, got ${res.status}`)
  })

  await test('Logout with no token succeeds (no-op)', async () => {
    const res = await request(relay, 'POST', '/api/v1/identity/logout', {}, { auth: false })
    assert(res.status === 200, `Expected 200, got ${res.status}`)
    assert(res.data.success === true, 'Expected success')
  })

  // ──────────────────────────────────────────
  // 6. Identity Service via Router Dispatch
  // ──────────────────────────────────────────
  console.log('\n── Test Group 6: Identity Service Dispatch ──')

  await test('identity.whoami via dispatch', async () => {
    const res = await dispatch(relay, 'identity.whoami')
    assert(res.status === 200, `HTTP ${res.status}`)
    assert(res.data.result?.pubkey, 'Missing pubkey in whoami')
    log('ℹ️', `Relay pubkey: ${res.data.result.pubkey.slice(0, 16)}...`)
  })

  await test('identity.peers via dispatch', async () => {
    const res = await dispatch(relay, 'identity.peers')
    assert(res.status === 200, `HTTP ${res.status}`)
    log('ℹ️', `Peers result: ${JSON.stringify(res.data.result).slice(0, 100)}`)
  })

  await test('identity.resolve via dispatch (unknown key)', async () => {
    const res = await dispatch(relay, 'identity.resolve', { pubkey: crypto.randomBytes(32).toString('hex') })
    assert(res.status === 200 || res.status === 500, `HTTP ${res.status}`)
    // Unknown key should return null or throw
  })

  // ──────────────────────────────────────────
  // 7. Cross-Relay Identity Sync
  // ──────────────────────────────────────────
  console.log('\n── Test Group 7: Cross-Relay Identity Sync ──')

  await test('All relays have same developer count (sync working)', async () => {
    const counts = []
    for (const r of RELAYS) {
      const res = await request(r, 'GET', '/api/v1/identity/developers')
      counts.push({ name: r.name, count: res.data.count || 0 })
    }
    log('ℹ️', `Developer counts: ${counts.map(c => `${c.name}=${c.count}`).join(', ')}`)
    // They should be equal if sync is working
    const vals = counts.map(c => c.count)
    const allSame = vals.every(v => v === vals[0])
    if (!allSame) {
      log('⚠️', 'Developer counts differ — sync may be catching up')
    }
  })

  await test('Catalog sync includes identity data', async () => {
    const res = await request(relay, 'GET', '/api/v1/sync')
    assert(res.status === 200, `HTTP ${res.status}`)
    assert(res.data.running === true, 'Sync not running')
    log('ℹ️', `Sync stats: ${JSON.stringify(res.data.stats || {}).slice(0, 100)}`)
  })

  // ──────────────────────────────────────────
  // 8. Identity in Network View
  // ──────────────────────────────────────────
  console.log('\n── Test Group 8: Network Integration ──')

  await test('Relay identity in whoami across all relays', async () => {
    const pubkeys = new Set()
    for (const r of RELAYS) {
      const res = await dispatch(r, 'identity.whoami')
      if (res.data.result?.pubkey) {
        pubkeys.add(res.data.result.pubkey)
      }
    }
    assert(pubkeys.size === RELAYS.length,
      `Expected ${RELAYS.length} unique identities, got ${pubkeys.size}`)
    log('ℹ️', `${pubkeys.size} unique relay identities confirmed`)
  })

  // ──────────────────────────────────────────
  // Summary
  // ──────────────────────────────────────────
  console.log('\n╔═══════════════════════════════════════════════════╗')
  console.log(`║  Results: ${passed} passed, ${failed} failed`)
  console.log('╚═══════════════════════════════════════════════════╝')
  if (failed > 0) {
    console.log('\nFailed tests:')
    for (const r of results.filter(r => r.status === 'fail')) console.log(`  ❌ ${r.name}: ${r.error}`)
  }
  console.log()
  process.exit(failed > 0 ? 1 : 0)
}

runTests().catch(err => { console.error('Fatal:', err); process.exit(1) })