| … | … | @@ -0,0 +1,162 @@ |
| 1 | /** |
| 2 | * claim-name / update-name over the real UDS surface (forge org #142 + #143). |
| 3 | * |
| 4 | * The sidecar handlers had no boot harness — the other sidecar test is |
| 5 | * constructor-only — so both fixes were unproven against an actual running |
| 6 | * service. This boots a fully scratch Sidecar (own root, own stores, own |
| 7 | * socket, no swarm, no seeding, no subscriptions) and drives it as the CLI |
| 8 | * does. |
| 9 | * |
| 10 | * The identity is a SYNTHETIC LINKED DEVICE: a generated keet root that has |
| 11 | * attested a generated device, written out as a linked-device.json. That is |
| 12 | * the exact shape #142 was about — `tryLoadLinkedDevice` populates |
| 13 | * rootPubkeyHex and never masterPublicKey, so a handler reaching for |
| 14 | * masterPublicKey alone anchored the claim to the DEVICE key. Using a real |
| 15 | * chain (rather than a hand-made identity object) also gives update-name |
| 16 | * something it can actually sign and verify. |
| 17 | */ |
| 18 | |
| 19 | import test from 'brittle' |
| 20 | import fs from '#node:fs' |
| 21 | import path from '#node:path' |
| 22 | import os from '#node:os' |
| 23 | import process from '#node:process' |
| 24 | import IdentityKey from 'keet-identity-key' |
| 25 | import crypto from 'hypercore-crypto' |
| 26 | import b4a from 'b4a' |
| 27 | import { Client } from 'hyper-svc' |
| 28 | |
| 29 | import { Sidecar } from '../lib/index.js' |
| 30 | |
| 31 | async function bootScratchSidecar (t) { |
| 32 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-sidecar-rpc-')) |
| 33 | |
| 34 | // A real keet identity: root attests device. Written as the payload |
| 35 | // `pgh device join` leaves behind. |
| 36 | const mnemonic = IdentityKey.generateMnemonic() |
| 37 | const identityKey = await IdentityKey.from({ mnemonic }) |
| 38 | const device = crypto.keyPair() |
| 39 | const proof = await identityKey.bootstrap(device.publicKey) |
| 40 | |
| 41 | const rootHex = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex') |
| 42 | const deviceHex = b4a.toString(device.publicKey, 'hex') |
| 43 | |
| 44 | const linkedFile = path.join(dir, 'linked-device.json') |
| 45 | fs.writeFileSync(linkedFile, JSON.stringify({ |
| 46 | version: 1, |
| 47 | label: 'scratch', |
| 48 | rootPubkeyHex: rootHex, |
| 49 | devicePublicKeyHex: deviceHex, |
| 50 | deviceSecretKeyHex: b4a.toString(device.secretKey, 'hex'), |
| 51 | chainB64: b4a.toString(proof, 'base64'), |
| 52 | createdAt: 1787600000000 |
| 53 | })) |
| 54 | |
| 55 | // Bare's process.env is a Proxy whose deleteProperty returns false, so |
| 56 | // `delete` throws — clear by assignment. Readers treat '' as unset. |
| 57 | const prev = { |
| 58 | linked: process.env.PGH_LINKED_DEVICE_FILE || '', |
| 59 | nameRegs: process.env.PGH_NAME_REGISTRIES || '', |
| 60 | relRegs: process.env.PGH_RELEASE_REGISTRIES || '', |
| 61 | subs: process.env.PGH_SUBSCRIPTIONS_PATH || '' |
| 62 | } |
| 63 | process.env.PGH_LINKED_DEVICE_FILE = linkedFile |
| 64 | process.env.PGH_NAME_REGISTRIES = '' |
| 65 | process.env.PGH_RELEASE_REGISTRIES = '' |
| 66 | process.env.PGH_SUBSCRIPTIONS_PATH = path.join(dir, 'subscriptions.json') |
| 67 | |
| 68 | const sidecar = new Sidecar({ |
| 69 | root: dir, |
| 70 | socketPath: path.join(dir, 'svc.sock'), |
| 71 | identityPath: path.join(dir, 'identity'), |
| 72 | storeRoot: path.join(dir, 'store'), |
| 73 | registryStore: path.join(dir, 'release-registry'), |
| 74 | nameRegistryStore: path.join(dir, 'name-registry'), |
| 75 | releaseRegistryReplicasDir: path.join(dir, 'release-registries'), |
| 76 | overlayStoreRoot: path.join(dir, 'overlay-store'), |
| 77 | // No swarm, no DeployServer, and no replicas to warm. |
| 78 | enableSeed: false, |
| 79 | nameRegistrySubscriptions: [], |
| 80 | releaseRegistrySubscriptions: [] |
| 81 | }) |
| 82 | |
| 83 | await sidecar.start() |
| 84 | const client = await Client.connectUds(path.join(dir, 'svc.sock')) |
| 85 | |
| 86 | t.teardown(async () => { |
| 87 | try { await client.close() } catch {} |
| 88 | // stop(), not close() — stop() is what clears the daily compact + gc |
| 89 | // timers. Calling a method that does not exist inside a silent catch |
| 90 | // left the sidecar fully live while the tmpdir was removed underneath |
| 91 | // it, and the surviving compaction then threw ENOENT from a timer. |
| 92 | // Left unguarded on purpose: a teardown that fails should be loud. |
| 93 | await sidecar.stop() |
| 94 | process.env.PGH_LINKED_DEVICE_FILE = prev.linked |
| 95 | process.env.PGH_NAME_REGISTRIES = prev.nameRegs |
| 96 | process.env.PGH_RELEASE_REGISTRIES = prev.relRegs |
| 97 | process.env.PGH_SUBSCRIPTIONS_PATH = prev.subs |
| 98 | fs.rmSync(dir, { recursive: true, force: true }) |
| 99 | }) |
| 100 | |
| 101 | return { client, rootHex, deviceHex } |
| 102 | } |
| 103 | |
| 104 | test('a claim from a linked device anchors to the ROOT (org #142)', async (t) => { |
| 105 | const { client, rootHex, deviceHex } = await bootScratchSidecar(t) |
| 106 | |
| 107 | const claim = await client.call('claim-name', { |
| 108 | name: 'scratch-app', |
| 109 | description: 'boot harness' |
| 110 | }) |
| 111 | |
| 112 | t.is(claim.publisher.identityRoot, rootHex, 'anchored to the root') |
| 113 | t.not(claim.publisher.identityRoot, deviceHex, 'NOT the device key') |
| 114 | t.is(claim.publisher.devicePubkey, deviceHex, 'device recorded separately') |
| 115 | }) |
| 116 | |
| 117 | test('identity-info reports the root, not the device (org #142)', async (t) => { |
| 118 | const { client, rootHex, deviceHex } = await bootScratchSidecar(t) |
| 119 | |
| 120 | const info = await client.call('identity-info', {}) |
| 121 | t.is(info.rootPubkey, rootHex, 'rootPubkey is the root') |
| 122 | t.is(info.devicePubkey, deviceHex, 'devicePubkey is the device') |
| 123 | t.not(info.rootPubkey, info.devicePubkey, 'the two are distinguished') |
| 124 | }) |
| 125 | |
| 126 | test('update-name re-points a name this identity owns (org #143)', async (t) => { |
| 127 | const { client, rootHex } = await bootScratchSidecar(t) |
| 128 | |
| 129 | await client.call('claim-name', { name: 'scratch-app', description: 'before' }) |
| 130 | |
| 131 | const rotated = 'e'.repeat(64) |
| 132 | const updated = await client.call('update-name', { |
| 133 | name: 'scratch-app', |
| 134 | registryKey: rotated, |
| 135 | description: 'after' |
| 136 | }) |
| 137 | |
| 138 | t.is(updated.publisher.registryKey, rotated, 'registryKey rotated') |
| 139 | t.is(updated.description, 'after', 'description updated') |
| 140 | t.is(updated.publisher.identityRoot, rootHex, 'anchor unchanged by the update') |
| 141 | |
| 142 | // And it survives a re-read rather than being a return-value artefact. |
| 143 | const reread = await client.call('resolve-name', { name: 'scratch-app' }) |
| 144 | t.is(reread.publisher.registryKey, rotated, 'persisted to the view') |
| 145 | }) |
| 146 | |
| 147 | test('update-name refuses an unclaimed name and an empty update', async (t) => { |
| 148 | const { client } = await bootScratchSidecar(t) |
| 149 | |
| 150 | await t.exception( |
| 151 | () => client.call('update-name', { name: 'never-claimed', description: 'x' }), |
| 152 | /not claimed/, |
| 153 | 'unclaimed name is refused by name' |
| 154 | ) |
| 155 | |
| 156 | await client.call('claim-name', { name: 'scratch-app' }) |
| 157 | await t.exception( |
| 158 | () => client.call('update-name', { name: 'scratch-app' }), |
| 159 | /at least one of/, |
| 160 | 'an update with no fields is refused' |
| 161 | ) |
| 162 | }) |