OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / server-linker.test.js

206 lines · javascript ref:
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import { CandidateRequest, MemberRequest, createInvite } from 'blind-pairing-core'
import { ServerIdentity } from '../../lib/p2p/identity/server-identity.js'
import { ServerLinker } from '../../lib/p2p/identity/server-linker.js'

const tmpDir = path.join(os.tmpdir(), 'pear-git-test-server-linker-' + Date.now())

// Create a server identity on disk (bypasses StorageLocator check in _generateIdentity)
function createIdentityStorage (name) {
  const dir = path.join(tmpDir, name)
  fs.mkdirSync(dir, { recursive: true })
  const keypair = crypto.keyPair()
  fs.writeFileSync(
    path.join(dir, 'server-identity.json'),
    JSON.stringify({
      publicKey: keypair.publicKey.toString('hex'),
      secretKey: keypair.secretKey.toString('hex'),
      createdAt: Date.now()
    }, null, 2),
    { mode: 0o600 }
  )
  return { dir, keypair }
}

test('setup: create temp dir', async (t) => {
  await fs.promises.mkdir(tmpDir, { recursive: true })
  t.pass('temp dir created')
})

test('server-linker: blind-pairing payload transfer (core primitives)', async (t) => {
  // Test the payload transfer flow using blind-pairing-core directly
  // Verifies the cryptographic flow without needing network

  const pairingKey = crypto.keyPair()
  const { invite, publicKey: invitePublicKey, seed: inviteSeed } = createInvite(pairingKey.publicKey)

  const inviteKeyPair = crypto.keyPair(inviteSeed)

  // Build the server identity payload (what ServerLinker sends)
  const payload = {
    mnemonic: 'abandon ability able about above absent absorb abstract absurd abuse access accident alcohol alien alley almost alpha alter always amateur amazing among amount amused',
    serverKey: 'deadbeef'.repeat(8),
    repoList: ['repo-a', 'repo-b'],
    deployKeys: { 'repo-a': 'pear://fakekey1', 'repo-b': 'pear://fakekey2' },
    createdAt: Date.now()
  }
  const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')

  // Simulate target server sending its storage path as userData
  const targetStoragePath = Buffer.from('/tmp/target-server', 'utf-8')
  const candidateReq = new CandidateRequest(invite, targetStoragePath)

  // Simulate source server receiving request
  const memberReq = MemberRequest.from(candidateReq.encode())
  const userData = memberReq.open(invitePublicKey)

  t.alike(userData, targetStoragePath, 'source receives target storage path')

  // Source confirms with signed payload
  memberReq.confirm({
    key: pairingKey.publicKey,
    encryptionKey: null,
    additional: {
      data: payloadBuf,
      signature: crypto.sign(payloadBuf, inviteKeyPair.secretKey)
    }
  })

  // Target processes response
  const auth = candidateReq.handleResponse(memberReq.response)

  t.ok(auth, 'target received auth')
  t.alike(auth.key, pairingKey.publicKey, 'auth key matches')
  t.alike(auth.data, payloadBuf, 'payload transferred correctly')

  // Parse and verify payload contents
  const received = JSON.parse(auth.data.toString('utf-8'))
  t.is(received.mnemonic.split(' ').length, 24, '24-word mnemonic in payload')
  t.is(received.serverKey, 'deadbeef'.repeat(8), 'server key in payload')
  t.alike(received.repoList, ['repo-a', 'repo-b'], 'repo list in payload')
  t.is(received.deployKeys['repo-a'], 'pear://fakekey1', 'deploy key a')
  t.is(received.deployKeys['repo-b'], 'pear://fakekey2', 'deploy key b')
})

test('server-linker: full identity transfer via core primitives', async (t) => {
  // End-to-end: real ServerIdentity -> mnemonic -> reconstruct on target
  // Uses blind-pairing-core (no network)

  const { dir: srcDir } = createIdentityStorage('identity-src')
  const dstDir = path.join(tmpDir, 'identity-dst')
  await fs.promises.mkdir(dstDir, { recursive: true })

  // Load source identity (file already created by helper)
  const sourceIdentity = new ServerIdentity({ storagePath: srcDir })
  await sourceIdentity.init()

  const mnemonic = sourceIdentity.toMnemonic()
  t.is(mnemonic.split(' ').length, 24, 'mnemonic is 24 words')

  // Build payload (what ServerLinker.createInvite builds)
  const payload = {
    mnemonic,
    serverKey: sourceIdentity.getPublicKeyHex(),
    repoList: ['wirecore', 'hyper-wallet'],
    deployKeys: { wirecore: 'pear://abc123' },
    createdAt: Date.now()
  }
  const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')

  // Simulate blind-pairing transfer
  const pairingKey = crypto.keyPair()
  const { invite, publicKey: invitePublicKey, seed: inviteSeed } = createInvite(pairingKey.publicKey)
  const inviteKeyPair = crypto.keyPair(inviteSeed)

  const candidateReq = new CandidateRequest(invite, Buffer.from(dstDir, 'utf-8'))
  const memberReq = MemberRequest.from(candidateReq.encode())
  memberReq.open(invitePublicKey)
  memberReq.confirm({
    key: pairingKey.publicKey,
    encryptionKey: null,
    additional: {
      data: payloadBuf,
      signature: crypto.sign(payloadBuf, inviteKeyPair.secretKey)
    }
  })

  const auth = candidateReq.handleResponse(memberReq.response)
  const received = JSON.parse(auth.data.toString('utf-8'))

  // Reconstruct identity on target from received mnemonic
  const targetIdentity = new ServerIdentity({ storagePath: dstDir })
  await targetIdentity.initFromMnemonic(received.mnemonic)

  t.alike(targetIdentity.publicKey, sourceIdentity.publicKey, 'same public key after restore')
  t.is(targetIdentity.getPublicKeyHex(), sourceIdentity.getPublicKeyHex(), 'same hex key')

  // Verify topic derivation produces same results
  const srcTopic = sourceIdentity.deriveTopic('wirecore')
  const dstTopic = targetIdentity.deriveTopic('wirecore')
  t.alike(srcTopic, dstTopic, 'same topic hash for same repo')
})

test('server-linker: createInvite requires serverIdentity', async (t) => {
  const linker = new ServerLinker({ storagePath: tmpDir })

  try {
    await linker.createInvite()
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('Server identity required'), 'throws without identity')
  } finally {
    await linker.close()
  }
})

test('server-linker: createInvite returns invite hex and waitForPeer', async (t) => {
  t.timeout(10000)

  const { dir: srcDir } = createIdentityStorage('invite-api')

  const identity = new ServerIdentity({ storagePath: srcDir })
  await identity.init()

  const linker = new ServerLinker({ serverIdentity: identity })

  try {
    const result = await linker.createInvite({
      repoList: ['test-repo'],
      deployKeys: { 'test-repo': 'pear://testkey' }
    })

    t.ok(result.invite, 'invite buffer returned')
    t.ok(result.inviteHex, 'invite hex returned')
    t.ok(result.inviteHex.length > 0, 'invite hex is non-empty')
    t.ok(typeof result.waitForPeer === 'function', 'waitForPeer function returned')
  } finally {
    await linker.close()
  }
})

test('server-linker: close is safe when nothing initialized', async (t) => {
  const linker = new ServerLinker({ storagePath: tmpDir })

  await linker.close()
  t.is(linker._swarm, null, 'swarm is null after close')
  t.is(linker._pairing, null, 'pairing is null after close')
  t.is(linker._timeout, null, 'timeout is null after close')
})

test('server-linker: close is idempotent', async (t) => {
  const linker = new ServerLinker({ storagePath: tmpDir })

  await linker.close()
  await linker.close()
  t.pass('double close is safe')
})

test('cleanup: remove temp dir', async (t) => {
  await fs.promises.rm(tmpDir, { recursive: true, force: true })
  t.pass('cleaned up')
})