diff --git a/tests/fixtures/fake-idunn-rudp.ts b/tests/fixtures/fake-idunn-rudp.ts new file mode 100644 index 0000000..e084666 --- /dev/null +++ b/tests/fixtures/fake-idunn-rudp.ts @@ -0,0 +1,64 @@ +/** + * A stand-in for Idunn's health endpoint: it accepts each RUDP connect, acks + * the frame that follows and keeps every delivered frame. Heimdall opens a + * fresh socket and session per publication, so sessions are kept per remote. + */ +import dgram from "node:dgram"; +import { + CultNetRudpSession, + decodeRudpPacket, + encodeRudpPacket, + type CultNetTransportFrame, +} from "cultnet-ts"; + +export interface FakeIdunnRudp { + port: number; + frames: CultNetTransportFrame[]; + /** Resolves with the nth delivered frame (0-based) once it has arrived. */ + frame(index: number): Promise; + close(): void; +} + +export async function startFakeIdunnRudp(): Promise { + const server = dgram.createSocket("udp4"); + const sessions = new Map(); + const frames: CultNetTransportFrame[] = []; + const waiting: Array<{ index: number; resolve: (frame: CultNetTransportFrame) => void }> = []; + + server.on("message", (wire, remote) => { + const packet = decodeRudpPacket(wire); + const key = `${remote.address}:${remote.port}`; + if (packet.packetType === "connect") { + const session = new CultNetRudpSession({ + connectionId: 0x1d0d0001, + initialSequence: 1, + resendDelayMs: 100, + }); + sessions.set(key, session); + server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address); + return; + } + const session = sessions.get(key); + if (!session) return; + const result = session.receive(packet, Date.now()); + if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address); + for (const frame of result.delivered) { + frames.push(frame); + for (const waiter of waiting.filter((entry) => entry.index === frames.length - 1)) waiter.resolve(frame); + } + if (result.delivered.length > 0) { + server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address); + } + }); + await new Promise((resolve) => server.bind(0, "127.0.0.1", resolve)); + + return { + port: server.address().port, + frames, + frame: (index) => + frames[index] + ? Promise.resolve(frames[index]!) + : new Promise((resolve) => waiting.push({ index, resolve })), + close: () => server.close(), + }; +} diff --git a/tests/fixtures/idunn-runtime/.gitattributes b/tests/fixtures/idunn-runtime/.gitattributes new file mode 100644 index 0000000..fa1385d --- /dev/null +++ b/tests/fixtures/idunn-runtime/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/tests/fixtures/idunn-runtime/README.md b/tests/fixtures/idunn-runtime/README.md new file mode 100644 index 0000000..0f26117 --- /dev/null +++ b/tests/fixtures/idunn-runtime/README.md @@ -0,0 +1,11 @@ +# Idunn runtime fixtures + +Written by the Rust owner, not by Heimdall's tests. Copied byte for byte from CultLib +`217a9480`, `packages/cultnet-ts/test/fixtures/idunn-runtime/web` (`expected.cc`, +`activation.cc`, `activation-key.seed`, `provider-identity.credential`) and that directory's +`machine-id`. CultLib regenerates them with `packages/cultnet-rs/examples/idunn_runtime_fixture.rs` +(its own README has the Yggdrasil command); the provider identity is bound to `machine-id`, so a +test that decodes it supplies that machine id instead of `/etc/machine-id`. + +Every key here is a test key. `provider-identity.credential` is the shape Idunn passes on the +presence-identity descriptor: key fields are 32-integer arrays, which only the Rust owner writes. diff --git a/tests/fixtures/idunn-runtime/activation-key.seed b/tests/fixtures/idunn-runtime/activation-key.seed new file mode 100644 index 0000000..7b6d37f --- /dev/null +++ b/tests/fixtures/idunn-runtime/activation-key.seed @@ -0,0 +1 @@ +¯ÂOd×f¯l*oµ‘ÀFªv‰n�hLûÑpšS´p \ No newline at end of file diff --git a/tests/fixtures/idunn-runtime/activation.cc b/tests/fixtures/idunn-runtime/activation.cc new file mode 100644 index 0000000..3931baf Binary files /dev/null and b/tests/fixtures/idunn-runtime/activation.cc differ diff --git a/tests/fixtures/idunn-runtime/expected.cc b/tests/fixtures/idunn-runtime/expected.cc new file mode 100644 index 0000000..dbf7153 Binary files /dev/null and b/tests/fixtures/idunn-runtime/expected.cc differ diff --git a/tests/fixtures/idunn-runtime/machine-id b/tests/fixtures/idunn-runtime/machine-id new file mode 100644 index 0000000..a71716f --- /dev/null +++ b/tests/fixtures/idunn-runtime/machine-id @@ -0,0 +1 @@ +c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1 \ No newline at end of file diff --git a/tests/fixtures/idunn-runtime/provider-identity.credential b/tests/fixtures/idunn-runtime/provider-identity.credential new file mode 100644 index 0000000..2e61713 Binary files /dev/null and b/tests/fixtures/idunn-runtime/provider-identity.credential differ diff --git a/tests/idunn-rudp-health.test.ts b/tests/idunn-rudp-health.test.ts index 66127d4..fa40393 100644 --- a/tests/idunn-rudp-health.test.ts +++ b/tests/idunn-rudp-health.test.ts @@ -1,18 +1,12 @@ -import dgram from "node:dgram"; import { mkdtemp } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { decode } from "@msgpack/msgpack"; -import { - CultNetRudpSession, - decodeRudpPacket, - encodeRudpPacket, - parseCultNetMessage, - type CultNetTransportFrame, -} from "cultnet-ts"; +import { parseCultNetMessage } from "cultnet-ts"; import { describe, expect, it, vi } from "vitest"; import { loadConfig } from "../src/config.js"; import { publishIdunnRudpHealth } from "../src/idunn-rudp-health.js"; +import { startFakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js"; vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); @@ -27,37 +21,12 @@ vi.mock("node:fs/promises", async (importOriginal) => { describe("Heimdall Idunn health publication", () => { it("completes the canonical RUDP handshake before publishing its signed frame", async () => { - const server = dgram.createSocket("udp4"); - const session = new CultNetRudpSession({ - connectionId: 0x1d0d0001, - initialSequence: 1, - resendDelayMs: 100, - }); - let resolveFrame!: (frame: CultNetTransportFrame) => void; - const frameReceived = new Promise((resolve) => { - resolveFrame = resolve; - }); - - server.on("message", (wire, remote) => { - const packet = decodeRudpPacket(wire); - if (packet.packetType === "connect") { - server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address); - return; - } - const result = session.receive(packet, Date.now()); - if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address); - for (const frame of result.delivered) resolveFrame(frame); - if (result.delivered.length > 0) { - server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address); - } - }); - await new Promise((resolve) => server.bind(0, "127.0.0.1", resolve)); + const server = await startFakeIdunnRudp(); try { const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-idunn-rudp-")); - const address = server.address(); const config = loadConfig({ - GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${address.port}`, + GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${server.port}`, GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH: path.join(tmp, "provider-health-identity.cc"), }); const observedAt = "2026-08-22T00:45:00.000Z"; @@ -78,7 +47,7 @@ describe("Heimdall Idunn health publication", () => { observedAt, presence, }); - const frame = await frameReceived; + const frame = await server.frame(0); const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0"); expect(frame.channelId).toBe("schema");