From 7a62359b6a3daab56e4862b733b5e0ea8db77f0d Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 30 May 2026 16:24:11 -0400 Subject: [PATCH] chore: add a resilience test --- .../headscale/headscale-resilience.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/unit/headscale/headscale-resilience.test.ts diff --git a/tests/unit/headscale/headscale-resilience.test.ts b/tests/unit/headscale/headscale-resilience.test.ts new file mode 100644 index 0000000..73c0dec --- /dev/null +++ b/tests/unit/headscale/headscale-resilience.test.ts @@ -0,0 +1,119 @@ +// MARK: createHeadscale resilience +// +// These tests exercise the boot-time behaviour where Headscale may be +// unreachable. They use a Node http server to stand in for Headscale so +// we control whether `/version` resolves at boot, after a retry, or not +// at all. + +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createHeadscale } from "~/server/headscale/api"; + +let servers: Server[] = []; + +afterEach(async () => { + await Promise.all( + servers.map( + (s) => + new Promise((resolve) => { + s.close(() => resolve()); + }), + ), + ); + servers = []; + vi.useRealTimers(); +}); + +function startVersionServer(handler: (req: Request) => Response): Promise<{ + url: string; + callCount: () => number; + setResponse: (handler: (req: Request) => Response) => void; +}> { + let current = handler; + let count = 0; + const server = createServer((req, res) => { + count++; + const fullUrl = `http://${req.headers.host}${req.url}`; + const request = new Request(fullUrl, { method: req.method }); + const response = current(request); + res.writeHead(response.status, Object.fromEntries(response.headers)); + response.text().then((body) => res.end(body)); + }); + servers.push(server); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + resolve({ + url: `http://127.0.0.1:${port}`, + callCount: () => count, + setResponse: (h) => { + current = h; + }, + }); + }); + }); +} + +describe("createHeadscale boot-time resilience", () => { + test("detects version when Headscale is reachable at boot", async () => { + const { url } = await startVersionServer(() => + Response.json({ version: "v0.28.0", commit: "abc", buildTime: "", go: "", dirty: false }), + ); + + const headscale = await createHeadscale({ url }); + expect(headscale.version.raw).toBe("v0.28.0"); + expect(headscale.version.unknown).toBe(false); + expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true); + await headscale.dispose(); + }); + + test("boots with permissive defaults when /version fails at boot", async () => { + const { url } = await startVersionServer(() => new Response("nope", { status: 503 })); + + const headscale = await createHeadscale({ url, retryIntervalMs: 1_000_000 }); + // Should not throw, and should default to "unknown" with permissive caps. + expect(headscale.version.unknown).toBe(true); + expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true); + expect(headscale.capabilities.policyErrorsUseModernFormat).toBe(true); + await headscale.dispose(); + }); + + test("background retry promotes to detected version once Headscale comes online", async () => { + const probe = await startVersionServer(() => new Response("nope", { status: 503 })); + + const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 20 }); + expect(headscale.version.unknown).toBe(true); + + probe.setResponse(() => + Response.json({ version: "v0.27.1", commit: "x", buildTime: "", go: "", dirty: false }), + ); + + // Poll for up to 2s for the background retry to pick up the new response. + const deadline = Date.now() + 2000; + while (Date.now() < deadline && headscale.version.unknown) { + await new Promise((r) => setTimeout(r, 25)); + } + + expect(headscale.version.unknown).toBe(false); + expect(headscale.version.raw).toBe("v0.27.1"); + expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(false); + expect(headscale.capabilities.policyErrorsUseModernFormat).toBe(true); + await headscale.dispose(); + }); + + test("dispose cancels the background retry", async () => { + const probe = await startVersionServer(() => new Response("nope", { status: 503 })); + + const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 25 }); + await headscale.dispose(); + + const before = probe.callCount(); + await new Promise((r) => setTimeout(r, 150)); + // At most a tiny number of additional calls if a retry was already + // queued at dispose time, but the cadence should have stopped. + expect(probe.callCount() - before).toBeLessThanOrEqual(1); + }); +});