|
| 1 | +import test from "node:test"; |
| 2 | +import assert from "node:assert/strict"; |
| 3 | +import { spawnSync, spawn } from "node:child_process"; |
| 4 | +import net from "node:net"; |
| 5 | +import { generateKeyPairSync, createHash } from "node:crypto"; |
| 6 | + |
| 7 | +function freePort() { |
| 8 | + return new Promise((resolve, reject) => { |
| 9 | + const s = net.createServer(); |
| 10 | + s.on("error", reject); |
| 11 | + s.listen(0, "127.0.0.1", () => { |
| 12 | + const addr = s.address(); |
| 13 | + s.close(() => resolve(addr.port)); |
| 14 | + }); |
| 15 | + }); |
| 16 | +} |
| 17 | + |
| 18 | +function makeKeys() { |
| 19 | + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); |
| 20 | + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); |
| 21 | + const privatePemB64 = Buffer.from(String(privatePem), "utf8").toString("base64"); |
| 22 | + const spki = publicKey.export({ type: "spki", format: "der" }); |
| 23 | + const raw32 = Buffer.from(spki).subarray(spki.length - 32); |
| 24 | + return { |
| 25 | + privatePemB64, |
| 26 | + publicRaw32B64: raw32.toString("base64"), |
| 27 | + kid: createHash("sha256").update(raw32).digest("base64url").slice(0, 16), |
| 28 | + }; |
| 29 | +} |
| 30 | + |
| 31 | +async function startServer(extraEnv) { |
| 32 | + const port = await freePort(); |
| 33 | + const proc = spawn(process.execPath, ["server.mjs"], { |
| 34 | + cwd: process.cwd(), |
| 35 | + env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), ...extraEnv }, |
| 36 | + stdio: ["ignore", "pipe", "pipe"], |
| 37 | + }); |
| 38 | + let stderr = ""; |
| 39 | + proc.stderr.on("data", (d) => (stderr += String(d))); |
| 40 | + |
| 41 | + const base = `http://127.0.0.1:${port}`; |
| 42 | + for (let i = 0; i < 80; i++) { |
| 43 | + try { |
| 44 | + const r = await fetch(`${base}/health`); |
| 45 | + if (r.ok) return { proc, base, stderr: () => stderr }; |
| 46 | + } catch {} |
| 47 | + await new Promise((r) => setTimeout(r, 100)); |
| 48 | + } |
| 49 | + throw new Error(`server did not boot: ${stderr}`); |
| 50 | +} |
| 51 | + |
| 52 | +async function stop(proc) { |
| 53 | + if (proc.exitCode !== null) return; |
| 54 | + proc.kill("SIGTERM"); |
| 55 | + await new Promise((r) => setTimeout(r, 200)); |
| 56 | + if (proc.exitCode === null) proc.kill("SIGKILL"); |
| 57 | +} |
| 58 | + |
| 59 | +test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => { |
| 60 | + const res = spawnSync(process.execPath, ["server.mjs"], { |
| 61 | + cwd: process.cwd(), |
| 62 | + env: { ...process.env, HOST: "127.0.0.1", PORT: "0", RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", DEV_AUTO_KEYS: "0" }, |
| 63 | + encoding: "utf8", |
| 64 | + timeout: 4000, |
| 65 | + }); |
| 66 | + assert.notEqual(res.status, 0); |
| 67 | + assert.match(`${res.stderr}${res.stdout}`, /fatal signer misconfiguration|Missing required env var/); |
| 68 | +}); |
| 69 | + |
| 70 | +test("private PEM_B64 + public raw32 b64 path signs and /verify roundtrip works", async () => { |
| 71 | + const keys = makeKeys(); |
| 72 | + const srv = await startServer({ |
| 73 | + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, |
| 74 | + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, |
| 75 | + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", |
| 76 | + }); |
| 77 | + |
| 78 | + try { |
| 79 | + const h = await (await fetch(`${srv.base}/health`)).json(); |
| 80 | + assert.equal(h.signer_ok, true); |
| 81 | + assert.equal(h.kid, keys.kid); |
| 82 | + |
| 83 | + const body = { |
| 84 | + x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" }, |
| 85 | + input: { subject: "t", detail_level: "short" }, |
| 86 | + trace: { provider: "test" }, |
| 87 | + }; |
| 88 | + const receiptResp = await fetch(`${srv.base}/describe/v1.0.0`, { |
| 89 | + method: "POST", |
| 90 | + headers: { "content-type": "application/json" }, |
| 91 | + body: JSON.stringify(body), |
| 92 | + }); |
| 93 | + assert.equal(receiptResp.status, 200); |
| 94 | + const receipt = await receiptResp.json(); |
| 95 | + assert.ok(receipt.metadata?.proof?.signature_b64); |
| 96 | + assert.ok(receipt.metadata?.proof?.hash_sha256); |
| 97 | + assert.equal(receipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth"); |
| 98 | + assert.equal(receipt.metadata?.proof?.canonical, "json.sorted_keys.v1"); |
| 99 | + |
| 100 | + const verifyResp = await fetch(`${srv.base}/verify`, { |
| 101 | + method: "POST", |
| 102 | + headers: { "content-type": "application/json" }, |
| 103 | + body: JSON.stringify(receipt), |
| 104 | + }); |
| 105 | + const verifyJson = await verifyResp.json(); |
| 106 | + assert.equal(verifyResp.status, 200); |
| 107 | + assert.equal(verifyJson.ok, true); |
| 108 | + assert.equal(verifyJson.verified_with, "env"); |
| 109 | + } finally { |
| 110 | + await stop(srv.proc); |
| 111 | + } |
| 112 | +}); |
| 113 | + |
| 114 | +test("/verify?ens=1 passes with mocked ENS TXT response", async () => { |
| 115 | + const keys = makeKeys(); |
| 116 | + const ensMock = JSON.stringify({ |
| 117 | + "cl.sig.pub": `ed25519:${keys.publicRaw32B64}`, |
| 118 | + "cl.sig.canonical": "json.sorted_keys.v1", |
| 119 | + "cl.sig.kid": "v1", |
| 120 | + }); |
| 121 | + const srv = await startServer({ |
| 122 | + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, |
| 123 | + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, |
| 124 | + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", |
| 125 | + ENS_MOCK_TXT_JSON: ensMock, |
| 126 | + }); |
| 127 | + |
| 128 | + try { |
| 129 | + const body = { |
| 130 | + x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" }, |
| 131 | + input: { subject: "t", detail_level: "short" }, |
| 132 | + trace: { provider: "test" }, |
| 133 | + }; |
| 134 | + const receipt = await ( |
| 135 | + await fetch(`${srv.base}/describe/v1.0.0`, { |
| 136 | + method: "POST", |
| 137 | + headers: { "content-type": "application/json" }, |
| 138 | + body: JSON.stringify(body), |
| 139 | + }) |
| 140 | + ).json(); |
| 141 | + |
| 142 | + const verifyResp = await fetch(`${srv.base}/verify?ens=1`, { |
| 143 | + method: "POST", |
| 144 | + headers: { "content-type": "application/json" }, |
| 145 | + body: JSON.stringify(receipt), |
| 146 | + }); |
| 147 | + const verifyJson = await verifyResp.json(); |
| 148 | + assert.equal(verifyResp.status, 200); |
| 149 | + assert.equal(verifyJson.ok, true); |
| 150 | + assert.equal(verifyJson.verified_with, "ens"); |
| 151 | + } finally { |
| 152 | + await stop(srv.proc); |
| 153 | + } |
| 154 | +}); |
0 commit comments