From 4b608117a2c01ec6706d263c8ed251303083e4e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 03:55:32 +0000 Subject: [PATCH] fix(discovery): lazy-load bonjour; add tests --- src/cli/gateway.sigterm.test.ts | 4 +- src/cli/program.test.ts | 34 ++++++++ src/gateway/server.test.ts | 143 ++++++++++++++++++++++++++++++++ src/infra/bonjour.test.ts | 87 +++++++++++++++++++ src/infra/bonjour.ts | 10 ++- 5 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 src/infra/bonjour.test.ts diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 9998c2506..57deddcbd 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -41,7 +41,7 @@ describe("gateway SIGTERM", () => { child = null; }); - it("exits 0 on SIGTERM", { timeout: 15_000 }, async () => { + it("exits 0 on SIGTERM", { timeout: 30_000 }, async () => { const port = await getFreePort(); const out: string[] = []; const err: string[] = []; @@ -70,7 +70,7 @@ describe("gateway SIGTERM", () => { await waitForText( out, new RegExp(`gateway listening on ws://127\\.0\\.0\\.1:${port}\\b`), - 10_000, + 20_000, ); proc.kill("SIGTERM"); diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index c63f0f5b1..54e6669c5 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -4,6 +4,7 @@ const sendCommand = vi.fn(); const statusCommand = vi.fn(); const loginWeb = vi.fn(); const startWebChatServer = vi.fn(async () => ({ port: 18788 })); +const callGateway = vi.fn(); const runtime = { log: vi.fn(), @@ -19,6 +20,9 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("../provider-web.js", () => ({ loginWeb, })); +vi.mock("../gateway/call.js", () => ({ + callGateway, +})); vi.mock("../webchat/server.js", () => ({ startWebChatServer, getWebChatServer: () => null, @@ -57,4 +61,34 @@ describe("cli program", () => { JSON.stringify({ port: 18788, basePath: "/", host: "127.0.0.1" }), ); }); + + it("runs nodes list and calls node.pair.list", async () => { + callGateway.mockResolvedValue({ pending: [], paired: [] }); + const program = buildProgram(); + runtime.log.mockClear(); + await program.parseAsync(["nodes", "list"], { from: "user" }); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "node.pair.list", + }), + ); + expect(runtime.log).toHaveBeenCalledWith("Pending: 0 ยท Paired: 0"); + }); + + it("runs nodes approve and calls node.pair.approve", async () => { + callGateway.mockResolvedValue({ + requestId: "r1", + node: { nodeId: "n1", token: "t1" }, + }); + const program = buildProgram(); + runtime.log.mockClear(); + await program.parseAsync(["nodes", "approve", "r1"], { from: "user" }); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "node.pair.approve", + params: { requestId: "r1" }, + }), + ); + expect(runtime.log).toHaveBeenCalled(); + }); }); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 23ff8e042..7eec229a9 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -181,6 +181,149 @@ async function connectOk( } describe("gateway server", () => { + test("supports gateway-owned node pairing methods and events", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); + const prevHome = process.env.HOME; + process.env.HOME = homeDir; + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const requestedP = onceMessage<{ + type: "event"; + event: string; + payload?: unknown; + }>(ws, (o) => o.type === "event" && o.event === "node.pair.requested"); + + ws.send( + JSON.stringify({ + type: "req", + id: "pair-req-1", + method: "node.pair.request", + params: { nodeId: "n1", displayName: "Iris" }, + }), + ); + const res1 = await onceMessage<{ + type: "res"; + ok: boolean; + payload?: unknown; + }>(ws, (o) => o.type === "res" && o.id === "pair-req-1"); + expect(res1.ok).toBe(true); + const req1 = (res1.payload as { request?: { requestId?: unknown } } | null) + ?.request; + const requestId = String(req1?.requestId ?? ""); + expect(requestId.length).toBeGreaterThan(0); + + const evt1 = await requestedP; + expect(evt1.event).toBe("node.pair.requested"); + expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe( + requestId, + ); + + // Second request for same node should return the existing pending request + // without emitting a second requested event. + ws.send( + JSON.stringify({ + type: "req", + id: "pair-req-2", + method: "node.pair.request", + params: { nodeId: "n1", displayName: "Iris" }, + }), + ); + const res2 = await onceMessage<{ + type: "res"; + ok: boolean; + payload?: unknown; + }>(ws, (o) => o.type === "res" && o.id === "pair-req-2"); + expect(res2.ok).toBe(true); + await expect( + onceMessage( + ws, + (o) => o.type === "event" && o.event === "node.pair.requested", + 200, + ), + ).rejects.toThrow(); + + const resolvedP = onceMessage<{ + type: "event"; + event: string; + payload?: unknown; + }>(ws, (o) => o.type === "event" && o.event === "node.pair.resolved"); + + ws.send( + JSON.stringify({ + type: "req", + id: "pair-approve-1", + method: "node.pair.approve", + params: { requestId }, + }), + ); + const approveRes = await onceMessage<{ + type: "res"; + ok: boolean; + payload?: unknown; + }>(ws, (o) => o.type === "res" && o.id === "pair-approve-1"); + expect(approveRes.ok).toBe(true); + const token = String( + (approveRes.payload as { node?: { token?: unknown } } | null)?.node + ?.token ?? "", + ); + expect(token.length).toBeGreaterThan(0); + + const evt2 = await resolvedP; + expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe( + requestId, + ); + expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe( + "approved", + ); + + ws.send( + JSON.stringify({ + type: "req", + id: "pair-verify-1", + method: "node.pair.verify", + params: { nodeId: "n1", token }, + }), + ); + const verifyRes = await onceMessage<{ + type: "res"; + ok: boolean; + payload?: unknown; + }>(ws, (o) => o.type === "res" && o.id === "pair-verify-1"); + expect(verifyRes.ok).toBe(true); + expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true); + + ws.send( + JSON.stringify({ + type: "req", + id: "pair-list-1", + method: "node.pair.list", + params: {}, + }), + ); + const listRes = await onceMessage<{ + type: "res"; + ok: boolean; + payload?: unknown; + }>(ws, (o) => o.type === "res" && o.id === "pair-list-1"); + expect(listRes.ok).toBe(true); + const paired = (listRes.payload as { paired?: unknown } | null)?.paired; + expect(Array.isArray(paired)).toBe(true); + expect( + (paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1"), + ).toBe(true); + + ws.close(); + await server.close(); + await fs.rm(homeDir, { recursive: true, force: true }); + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + }); + test("supports cron.add and cron.list", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-")); testCronStorePath = path.join(dir, "cron.json"); diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts new file mode 100644 index 000000000..8ae6ae0d9 --- /dev/null +++ b/src/infra/bonjour.test.ts @@ -0,0 +1,87 @@ +import os from "node:os"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const createService = vi.fn(); +const shutdown = vi.fn(); + +vi.mock("@homebridge/ciao", () => { + return { + Protocol: { TCP: "tcp" }, + getResponder: () => ({ + createService, + shutdown, + }), + }; +}); + +const { startGatewayBonjourAdvertiser } = await import("./bonjour.js"); + +describe("gateway bonjour advertiser", () => { + const prevEnv = { ...process.env }; + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in prevEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(prevEnv)) { + process.env[key] = value; + } + createService.mockReset(); + shutdown.mockReset(); + vi.restoreAllMocks(); + }); + + it("does not block on advertise and publishes expected txt keys", async () => { + // Allow advertiser to run in unit tests. + delete process.env.VITEST; + process.env.NODE_ENV = "development"; + + vi.spyOn(os, "hostname").mockReturnValue("test-host"); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockImplementation( + async () => + await new Promise((resolve) => { + setTimeout(resolve, 250); + }), + ); + createService.mockReturnValue({ advertise, destroy }); + + const started = await startGatewayBonjourAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + bridgePort: 18790, + tailnetDns: "host.tailnet.ts.net", + }); + + expect(createService).toHaveBeenCalledTimes(2); + const [masterCall, bridgeCall] = createService.mock.calls as Array< + [Record] + >; + expect(masterCall?.[0]?.type).toBe("clawdis-master"); + expect(masterCall?.[0]?.port).toBe(2222); + expect((masterCall?.[0]?.txt as Record)?.lanHost).toBe( + "test-host.local", + ); + expect((masterCall?.[0]?.txt as Record)?.sshPort).toBe( + "2222", + ); + + expect(bridgeCall?.[0]?.type).toBe("clawdis-bridge"); + expect(bridgeCall?.[0]?.port).toBe(18790); + expect((bridgeCall?.[0]?.txt as Record)?.bridgePort).toBe( + "18790", + ); + expect((bridgeCall?.[0]?.txt as Record)?.transport).toBe( + "bridge", + ); + + // We don't await `advertise()`, but it should still be called for each service. + expect(advertise).toHaveBeenCalledTimes(2); + + await started.stop(); + expect(destroy).toHaveBeenCalledTimes(2); + expect(shutdown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 3f1b7ae1d..332aabea0 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -1,7 +1,5 @@ import os from "node:os"; -import { type CiaoService, getResponder, Protocol } from "@homebridge/ciao"; - export type GatewayBonjourAdvertiser = { stop: () => Promise; }; @@ -26,6 +24,11 @@ function safeServiceName(name: string) { return trimmed.length > 0 ? trimmed : "Clawdis"; } +type BonjourService = { + advertise: () => Promise; + destroy: () => Promise; +}; + export async function startGatewayBonjourAdvertiser( opts: GatewayBonjourAdvertiseOpts, ): Promise { @@ -33,6 +36,7 @@ export async function startGatewayBonjourAdvertiser( return { stop: async () => {} }; } + const { getResponder, Protocol } = await import("@homebridge/ciao"); const responder = getResponder(); const hostname = os.hostname().replace(/\.local$/i, ""); @@ -53,7 +57,7 @@ export async function startGatewayBonjourAdvertiser( txtBase.tailnetDns = opts.tailnetDns.trim(); } - const services: CiaoService[] = []; + const services: BonjourService[] = []; // Master beacon: used for discovery (auto-fill SSH/direct targets). // We advertise a TCP service so clients can resolve the host; the port itself is informational.