OverviewFilesCommitsIssuesDocsReleasesPeers

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

189 lines · javascript ref:
import test from 'brittle'
import crypto from 'hypercore-crypto'
import { CandidateRequest, MemberRequest, createInvite } from 'blind-pairing-core'
import { RepoLinker } from '../../lib/repo-link/repo-linker.js'

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

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

  // Owner's payload: bootstrapKey + repoName + role
  const payload = {
    bootstrapKey: 'a'.repeat(64),
    repoName: 'test-repo',
    role: 'writer'
  }
  const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')

  // Joiner sends identity info
  const joinerInfo = {
    userId: 'joiner-user-1',
    serverKey: 'b'.repeat(64)
  }
  const joinerBuf = Buffer.from(JSON.stringify(joinerInfo), 'utf-8')

  // Simulate candidate (joiner) sending request
  const candidateReq = new CandidateRequest(invite, joinerBuf)

  // Simulate member (owner) receiving and opening request
  const memberReq = MemberRequest.from(candidateReq.encode())
  const receivedJoinerData = memberReq.open(invitePublicKey)

  const parsedJoiner = JSON.parse(receivedJoinerData.toString('utf-8'))
  t.is(parsedJoiner.userId, 'joiner-user-1', 'owner receives joiner userId')
  t.is(parsedJoiner.serverKey, 'b'.repeat(64), 'owner receives joiner serverKey')

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

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

  t.ok(auth, 'joiner received auth')
  t.alike(auth.key, pairingKey.publicKey, 'auth key matches')

  const receivedPayload = JSON.parse(auth.data.toString('utf-8'))
  t.is(receivedPayload.bootstrapKey, 'a'.repeat(64), 'joiner receives bootstrapKey')
  t.is(receivedPayload.repoName, 'test-repo', 'joiner receives repoName')
  t.is(receivedPayload.role, 'writer', 'joiner receives role')
})

test('repo-linker: invite creation returns hex', async (t) => {
  t.timeout(30000)

  const linker = new RepoLinker({})

  try {
    const result = await linker.createInvite({
      bootstrapKey: 'c'.repeat(64),
      repoName: 'invite-test',
      role: 'writer'
    })

    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('repo-linker: joiner sends full identity info', async (t) => {
  // Verify the joiner info structure roundtrips through blind-pairing-core

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

  const joinerInfo = {
    userId: 'user-456',
    serverKey: 'd'.repeat(64),
    identityKey: 'e'.repeat(64),
    proof: 'some-proof-data'
  }

  const joinerBuf = Buffer.from(JSON.stringify(joinerInfo), 'utf-8')
  const candidateReq = new CandidateRequest(invite, joinerBuf)
  const memberReq = MemberRequest.from(candidateReq.encode())
  const received = memberReq.open(invitePublicKey)

  const parsed = JSON.parse(received.toString('utf-8'))
  t.is(parsed.userId, 'user-456')
  t.is(parsed.serverKey, 'd'.repeat(64))
  t.is(parsed.identityKey, 'e'.repeat(64))
  t.is(parsed.proof, 'some-proof-data')
})

test('repo-linker: mirror role transfer', async (t) => {
  // Mirrors get read-only access (no Autobase writer)

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

  const payload = {
    bootstrapKey: 'f'.repeat(64),
    repoName: 'mirror-repo',
    role: 'mirror'
  }
  const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')

  const joinerBuf = Buffer.from(JSON.stringify({ userId: 'mirror-user', serverKey: '1'.repeat(64) }), 'utf-8')
  const candidateReq = new CandidateRequest(invite, joinerBuf)
  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'))
  t.is(received.role, 'mirror', 'mirror role transferred')
})

test('repo-linker: cleanup closes swarm', async (t) => {
  const linker = new RepoLinker({})

  // Close without any operations
  await linker.close()
  t.is(linker._swarm, null, 'swarm is null after close')
  t.is(linker._pairing, null, 'pairing is null after close')
})

test('repo-linker: creates ephemeral swarm when none provided', async (t) => {
  // This is the correct usage — each RepoLinker gets its own swarm
  // to avoid protocol conflicts with the git transport swarm.
  t.timeout(30000)

  const linker = new RepoLinker({})

  const result = await linker.createInvite({
    bootstrapKey: 'a'.repeat(64),
    repoName: 'ephemeral-test',
    role: 'writer'
  })

  // Verify it created its own swarm
  t.ok(linker._swarm, 'swarm was created')
  t.ok(linker._pairing, 'pairing was created')
  t.ok(result.inviteHex, 'invite was generated')

  await linker.close()
  t.is(linker._swarm, null, 'ephemeral swarm destroyed on close')
})

test('repo-linker: external swarm is not destroyed on close', async (t) => {
  // When an external swarm is passed, close() should NOT destroy it.
  // (Note: passing external swarms causes protocol conflicts with git
  // transport and should be avoided — but the behavior is still correct.)
  const Hyperswarm = (await import('hyperswarm')).default
  const externalSwarm = new Hyperswarm()

  const linker = new RepoLinker({ swarm: externalSwarm })
  await linker.close()

  t.is(linker._swarm, null, 'internal ref cleared')
  // External swarm should still be usable (not destroyed)
  t.ok(externalSwarm.connections !== undefined, 'external swarm still alive')

  await externalSwarm.destroy()
})