OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident-gateway / test / unit / gateway-identity.test.js

359 lines · javascript ref:
/**
 * Unit tests for GatewayIdentity
 */

import test from 'brittle'
import { GatewayIdentity } from '../../lib/gateway-identity.js'
import crypto from 'hypercore-crypto'
import { tmpdir } from 'os'
import { mkdtemp, writeFile, mkdir } from 'fs/promises'
import path from 'path'

test('GatewayIdentity - initialization', async (t) => {
  const identity = new GatewayIdentity({
    subnet: '10.22.0.0/16'
  })

  t.ok(identity, 'GatewayIdentity instance created')
  t.is(identity.networkPrefix, '10.22', 'Network prefix parsed correctly')
  t.is(identity.cidr, 16, 'CIDR parsed correctly')
  t.is(identity.ipAssignmentStrategy, 'hash', 'Default strategy is hash')
})

test('GatewayIdentity - topic derivation is deterministic', async (t) => {
  const identity = new GatewayIdentity()

  const topic1 = await identity.deriveServiceTopic('test-service')
  const topic2 = await identity.deriveServiceTopic('test-service')

  t.ok(Buffer.isBuffer(topic1), 'Topic is a Buffer')
  t.is(topic1.length, 32, 'Topic is 32 bytes')
  t.alike(topic1, topic2, 'Same profile name generates same topic')
})

test('GatewayIdentity - different profiles get different topics', async (t) => {
  const identity = new GatewayIdentity()

  const topic1 = await identity.deriveServiceTopic('service-a')
  const topic2 = await identity.deriveServiceTopic('service-b')

  t.unlike(topic1, topic2, 'Different profiles generate different topics')
})

test('GatewayIdentity - IP assignment from topic (/16 subnet)', async (t) => {
  const identity = new GatewayIdentity({
    subnet: '10.22.0.0/16'
  })

  const topic = crypto.hash(Buffer.from('test-topic'))
  const ip = identity.assignIP(topic)

  t.ok(ip, 'IP assigned')
  t.ok(ip.startsWith('10.22.'), 'IP in correct subnet')

  const parts = ip.split('.')
  t.is(parts.length, 4, 'Valid IPv4 format')
  t.is(parts[0], '10', 'First octet correct')
  t.is(parts[1], '22', 'Second octet correct')
})

test('GatewayIdentity - IP assignment is deterministic', async (t) => {
  const identity = new GatewayIdentity({
    subnet: '10.22.0.0/16'
  })

  const topic = crypto.hash(Buffer.from('deterministic-test'))
  const ip1 = identity.assignIP(topic)
  const ip2 = identity.assignIP(topic)

  t.is(ip1, ip2, 'Same topic generates same IP')
})

test('GatewayIdentity - /24 subnet support', async (t) => {
  const identity = new GatewayIdentity({
    subnet: '10.22.0.0/24'
  })

  const topic = crypto.hash(Buffer.from('test-topic'))
  const ip = identity.assignIP(topic)

  t.ok(ip.startsWith('10.22.0.'), '/24 subnet IP correct')

  const lastOctet = parseInt(ip.split('.')[3])
  t.ok(lastOctet >= 1 && lastOctet <= 254, 'Last octet in valid range')
})

test('GatewayIdentity - collision detection', async (t) => {
  const identity = new GatewayIdentity()

  // Manually add a service to create collision
  const topic = await identity.deriveServiceTopic('test-service')
  const ip = identity.assignIP(topic)

  identity.services.set(ip, {
    name: 'existing-service',
    ip,
    topic: topic.toString('hex')
  })

  t.ok(identity.hasCollision(ip), 'Detects existing IP')
  t.absent(identity.hasCollision('10.22.99.99'), 'Non-existing IP returns false')
})

test('GatewayIdentity - static IP mapping', async (t) => {
  const identity = new GatewayIdentity({
    subnet: '10.22.0.0/16',
    staticMappings: {
      'important-service': '10.22.0.100'
    }
  })

  t.ok(identity.staticMappings['important-service'], 'Static mapping configured')
  t.is(identity.staticMappings['important-service'], '10.22.0.100', 'Correct static IP')
})

test('GatewayIdentity - service lookup', async (t) => {
  const identity = new GatewayIdentity()

  const service = {
    name: 'test-service',
    ip: '10.22.0.50',
    topic: 'abc123',
    port: 9000
  }

  identity.services.set(service.ip, service)

  const found = identity.lookup('10.22.0.50')
  t.ok(found, 'Service found by IP')
  t.is(found.name, 'test-service', 'Correct service returned')

  const notFound = identity.lookup('10.22.0.99')
  t.absent(notFound, 'Non-existing service returns undefined')
})

test('GatewayIdentity - list services', async (t) => {
  const identity = new GatewayIdentity()

  identity.services.set('10.22.0.1', { name: 'service-1' })
  identity.services.set('10.22.0.2', { name: 'service-2' })
  identity.services.set('10.22.0.3', { name: 'service-3' })

  const services = identity.listServices()
  t.is(services.length, 3, 'Returns all services')
})

test('GatewayIdentity - get service by name', async (t) => {
  const identity = new GatewayIdentity()

  identity.services.set('10.22.0.10', { name: 'git-daemon', port: 9418 })
  identity.services.set('10.22.0.20', { name: 'fedicore', port: 3000 })

  const service = identity.getServiceByName('git-daemon')
  t.ok(service, 'Service found by name')
  t.is(service.port, 9418, 'Correct service returned')

  const notFound = identity.getServiceByName('non-existent')
  t.absent(notFound, 'Non-existing service returns null')
})

test('GatewayIdentity - connection management', async (t) => {
  const identity = new GatewayIdentity()

  const service = {
    name: 'test-service',
    ip: '10.22.0.50',
    connections: []
  }

  identity.services.set(service.ip, service)

  const conn1 = { id: 'conn-1' }
  const conn2 = { id: 'conn-2' }

  identity.updateConnection(service.ip, conn1)
  identity.updateConnection(service.ip, conn2)

  const updated = identity.lookup(service.ip)
  t.is(updated.connections.length, 2, 'Connections added')

  identity.removeConnection(service.ip, conn1)
  t.is(updated.connections.length, 1, 'Connection removed')
  t.is(updated.connections[0].id, 'conn-2', 'Correct connection remains')
})

test('GatewayIdentity - export mappings', async (t) => {
  const identity = new GatewayIdentity()

  identity.services.set('10.22.0.10', {
    name: 'service-1',
    profile: 'profile-1',
    topic: 'abc123',
    port: 9000,
    connections: [{ id: 'conn-1' }]
  })

  const mappings = identity.exportMappings()

  t.ok(mappings['10.22.0.10'], 'Service mapping exported')
  t.is(mappings['10.22.0.10'].name, 'service-1', 'Correct name')
  t.is(mappings['10.22.0.10'].activeConnections, 1, 'Connection count correct')
})

// Integration test with mock hyperident profiles
test('GatewayIdentity - load profiles (mock)', async (t) => {
  // Create temporary hyperident directory
  const tempDir = await mkdtemp(path.join(tmpdir(), 'hyperident-test-'))

  // Create mock profiles.json
  const profilesData = {
    version: '1.0.0',
    profiles: {
      'test-service': {
        name: 'test-service',
        description: 'Test service profile',
        created: new Date().toISOString()
      },
      'another-service': {
        name: 'another-service',
        description: 'Another test service',
        created: new Date().toISOString()
      }
    }
  }

  await writeFile(
    path.join(tempDir, 'profiles.json'),
    JSON.stringify(profilesData, null, 2)
  )

  const identity = new GatewayIdentity({
    hyperidentPath: tempDir
  })

  const profiles = await identity.loadProfiles()

  t.ok(profiles, 'Profiles loaded')
  t.ok(profiles['test-service'], 'test-service profile exists')
  t.ok(profiles['another-service'], 'another-service profile exists')
  t.is(Object.keys(profiles).length, 2, 'Correct number of profiles')
})

test('GatewayIdentity - register service (mock hyperident)', async (t) => {
  // Create temporary hyperident directory
  const tempDir = await mkdtemp(path.join(tmpdir(), 'hyperident-test-'))

  const profilesData = {
    version: '1.0.0',
    profiles: {
      'git-daemon': {
        name: 'git-daemon',
        description: 'Git daemon service',
        created: new Date().toISOString()
      }
    }
  }

  await writeFile(
    path.join(tempDir, 'profiles.json'),
    JSON.stringify(profilesData, null, 2)
  )

  const identity = new GatewayIdentity({
    hyperidentPath: tempDir,
    subnet: '10.22.0.0/16'
  })

  const service = await identity.registerService({
    name: 'git-daemon',
    profile: 'git-daemon',
    port: 9418
  })

  t.ok(service, 'Service registered')
  t.is(service.name, 'git-daemon', 'Service name correct')
  t.is(service.profile, 'git-daemon', 'Profile name correct')
  t.is(service.port, 9418, 'Port correct')
  t.ok(service.ip, 'IP assigned')
  t.ok(service.ip.startsWith('10.22.'), 'IP in correct subnet')
  t.ok(service.topic, 'Topic generated')
  t.is(service.topic.length, 64, 'Topic is hex string (32 bytes)')
})

test('GatewayIdentity - register service with static mapping', async (t) => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'hyperident-test-'))

  const profilesData = {
    version: '1.0.0',
    profiles: {
      'important-service': {
        name: 'important-service',
        created: new Date().toISOString()
      }
    }
  }

  await writeFile(
    path.join(tempDir, 'profiles.json'),
    JSON.stringify(profilesData, null, 2)
  )

  const identity = new GatewayIdentity({
    hyperidentPath: tempDir,
    subnet: '10.22.0.0/16',
    staticMappings: {
      'important-service': '10.22.0.100'
    }
  })

  const service = await identity.registerService({
    name: 'important-service',
    profile: 'important-service',
    port: 8080
  })

  t.is(service.ip, '10.22.0.100', 'Static IP used instead of hash')
})

test('GatewayIdentity - prevent duplicate service registration', async (t) => {
  const tempDir = await mkdtemp(path.join(tmpdir(), 'hyperident-test-'))

  const profilesData = {
    version: '1.0.0',
    profiles: {
      'test-service': {
        name: 'test-service',
        created: new Date().toISOString()
      }
    }
  }

  await writeFile(
    path.join(tempDir, 'profiles.json'),
    JSON.stringify(profilesData, null, 2)
  )

  const identity = new GatewayIdentity({
    hyperidentPath: tempDir
  })

  // Register once
  await identity.registerService({
    name: 'test-service',
    profile: 'test-service',
    port: 9000
  })

  // Try to register again (same topic → same IP → collision)
  try {
    await identity.registerService({
      name: 'duplicate-service',
      profile: 'test-service', // Same profile = same topic = same IP
      port: 9001
    })
    t.fail('Should have thrown collision error')
  } catch (error) {
    t.ok(error.message.includes('IP collision'), 'Collision detected')
  }
})