OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar / test / name-update-rpc.test.js

163 lines · javascript ref:
/**
 * claim-name / update-name over the real UDS surface (forge org #142 + #143).
 *
 * The sidecar handlers had no boot harness — the other sidecar test is
 * constructor-only — so both fixes were unproven against an actual running
 * service. This boots a fully scratch Sidecar (own root, own stores, own
 * socket, no swarm, no seeding, no subscriptions) and drives it as the CLI
 * does.
 *
 * The identity is a SYNTHETIC LINKED DEVICE: a generated keet root that has
 * attested a generated device, written out as a linked-device.json. That is
 * the exact shape #142 was about — `tryLoadLinkedDevice` populates
 * rootPubkeyHex and never masterPublicKey, so a handler reaching for
 * masterPublicKey alone anchored the claim to the DEVICE key. Using a real
 * chain (rather than a hand-made identity object) also gives update-name
 * something it can actually sign and verify.
 */

import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import process from '#node:process'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { Client } from 'hyper-svc'

import { Sidecar } from '../lib/index.js'

async function bootScratchSidecar (t) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-sidecar-rpc-'))

  // A real keet identity: root attests device. Written as the payload
  // `pgh device join` leaves behind.
  const mnemonic = IdentityKey.generateMnemonic()
  const identityKey = await IdentityKey.from({ mnemonic })
  const device = crypto.keyPair()
  const proof = await identityKey.bootstrap(device.publicKey)

  const rootHex = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')
  const deviceHex = b4a.toString(device.publicKey, 'hex')

  const linkedFile = path.join(dir, 'linked-device.json')
  fs.writeFileSync(linkedFile, JSON.stringify({
    version: 1,
    label: 'scratch',
    rootPubkeyHex: rootHex,
    devicePublicKeyHex: deviceHex,
    deviceSecretKeyHex: b4a.toString(device.secretKey, 'hex'),
    chainB64: b4a.toString(proof, 'base64'),
    createdAt: 1787600000000
  }))

  // Bare's process.env is a Proxy whose deleteProperty returns false, so
  // `delete` throws — clear by assignment. Readers treat '' as unset.
  const prev = {
    linked: process.env.PGH_LINKED_DEVICE_FILE || '',
    nameRegs: process.env.PGH_NAME_REGISTRIES || '',
    relRegs: process.env.PGH_RELEASE_REGISTRIES || '',
    subs: process.env.PGH_SUBSCRIPTIONS_PATH || ''
  }
  process.env.PGH_LINKED_DEVICE_FILE = linkedFile
  process.env.PGH_NAME_REGISTRIES = ''
  process.env.PGH_RELEASE_REGISTRIES = ''
  process.env.PGH_SUBSCRIPTIONS_PATH = path.join(dir, 'subscriptions.json')

  const sidecar = new Sidecar({
    root: dir,
    socketPath: path.join(dir, 'svc.sock'),
    identityPath: path.join(dir, 'identity'),
    storeRoot: path.join(dir, 'store'),
    registryStore: path.join(dir, 'release-registry'),
    nameRegistryStore: path.join(dir, 'name-registry'),
    releaseRegistryReplicasDir: path.join(dir, 'release-registries'),
    overlayStoreRoot: path.join(dir, 'overlay-store'),
    // No swarm, no DeployServer, and no replicas to warm.
    enableSeed: false,
    nameRegistrySubscriptions: [],
    releaseRegistrySubscriptions: []
  })

  await sidecar.start()
  const client = await Client.connectUds(path.join(dir, 'svc.sock'))

  t.teardown(async () => {
    try { await client.close() } catch {}
    // stop(), not close() — stop() is what clears the daily compact + gc
    // timers. Calling a method that does not exist inside a silent catch
    // left the sidecar fully live while the tmpdir was removed underneath
    // it, and the surviving compaction then threw ENOENT from a timer.
    // Left unguarded on purpose: a teardown that fails should be loud.
    await sidecar.stop()
    process.env.PGH_LINKED_DEVICE_FILE = prev.linked
    process.env.PGH_NAME_REGISTRIES = prev.nameRegs
    process.env.PGH_RELEASE_REGISTRIES = prev.relRegs
    process.env.PGH_SUBSCRIPTIONS_PATH = prev.subs
    fs.rmSync(dir, { recursive: true, force: true })
  })

  return { client, rootHex, deviceHex }
}

test('a claim from a linked device anchors to the ROOT (org #142)', async (t) => {
  const { client, rootHex, deviceHex } = await bootScratchSidecar(t)

  const claim = await client.call('claim-name', {
    name: 'scratch-app',
    description: 'boot harness'
  })

  t.is(claim.publisher.identityRoot, rootHex, 'anchored to the root')
  t.not(claim.publisher.identityRoot, deviceHex, 'NOT the device key')
  t.is(claim.publisher.devicePubkey, deviceHex, 'device recorded separately')
})

test('identity-info reports the root, not the device (org #142)', async (t) => {
  const { client, rootHex, deviceHex } = await bootScratchSidecar(t)

  const info = await client.call('identity-info', {})
  t.is(info.rootPubkey, rootHex, 'rootPubkey is the root')
  t.is(info.devicePubkey, deviceHex, 'devicePubkey is the device')
  t.not(info.rootPubkey, info.devicePubkey, 'the two are distinguished')
})

test('update-name re-points a name this identity owns (org #143)', async (t) => {
  const { client, rootHex } = await bootScratchSidecar(t)

  await client.call('claim-name', { name: 'scratch-app', description: 'before' })

  const rotated = 'e'.repeat(64)
  const updated = await client.call('update-name', {
    name: 'scratch-app',
    registryKey: rotated,
    description: 'after'
  })

  t.is(updated.publisher.registryKey, rotated, 'registryKey rotated')
  t.is(updated.description, 'after', 'description updated')
  t.is(updated.publisher.identityRoot, rootHex, 'anchor unchanged by the update')

  // And it survives a re-read rather than being a return-value artefact.
  const reread = await client.call('resolve-name', { name: 'scratch-app' })
  t.is(reread.publisher.registryKey, rotated, 'persisted to the view')
})

test('update-name refuses an unclaimed name and an empty update', async (t) => {
  const { client } = await bootScratchSidecar(t)

  await t.exception(
    () => client.call('update-name', { name: 'never-claimed', description: 'x' }),
    /not claimed/,
    'unclaimed name is refused by name'
  )

  await client.call('claim-name', { name: 'scratch-app' })
  await t.exception(
    () => client.call('update-name', { name: 'scratch-app' }),
    /at least one of/,
    'an update with no fields is refused'
  )
})