homeio
PublicForked from doctor-io/homeio
tailscale-status.test.ts
import { describe, expect, it, vi, beforeEach } from "vitest";
const { mockExecFile, mockAccess, mockWriteFile, mockUnlink } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
mockAccess: vi.fn(),
mockWriteFile: vi.fn(),
mockUnlink: vi.fn(),
}));
vi.mock("node:child_process", () => ({ execFile: mockExecFile }));
vi.mock("node:fs/promises", () => ({
access: mockAccess,
writeFile: mockWriteFile,
unlink: mockUnlink,
}));
vi.mock("node:util", () => ({
promisify: (fn: unknown) => (fn === mockExecFile ? mockExecFile : fn),
}));
import { getLocalTailscaleStatus, installTailscale } from "@/lib/server/modules/integrations/tailscale-status";
const runningJson = JSON.stringify({
BackendState: "Running",
Self: {
HostName: "homeserver",
DNSName: "homeserver.example.ts.net",
TailscaleIPs: ["100.64.0.1"],
Online: true,
},
});
beforeEach(() => {
vi.clearAllMocks();
mockUnlink.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
});
describe("getLocalTailscaleStatus", () => {
it("returns not-installed when CLI is absent", async () => {
mockAccess.mockResolvedValue(undefined);
const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
mockExecFile.mockRejectedValueOnce(err);
const status = await getLocalTailscaleStatus();
expect(status.installed).toBe(false);
expect(status.connected).toBe(false);
expect(status.error).toContain("not installed");
});
it("returns connected status when daemon is Running", async () => {
mockAccess.mockResolvedValue(undefined);
mockExecFile.mockResolvedValueOnce({ stdout: runningJson, stderr: "" });
const status = await getLocalTailscaleStatus();
expect(status.installed).toBe(true);
expect(status.connected).toBe(true);
expect(status.hostname).toBe("homeserver");
expect(status.tailscaleIps).toEqual(["100.64.0.1"]);
expect(status.issue).toBeNull();
});
it("reports missing_tun when /dev/net/tun is absent", async () => {
mockAccess.mockRejectedValue(new Error("ENOENT"));
mockExecFile.mockRejectedValueOnce(new Error("failed to open /dev/net/tun: no such file"));
const status = await getLocalTailscaleStatus();
expect(status.tunAvailable).toBe(false);
expect(status.issue).toBe("missing_tun");
});
it("reports service_unavailable when daemon is down but TUN exists", async () => {
mockAccess.mockResolvedValue(undefined);
mockExecFile.mockRejectedValueOnce(new Error("dial tcp: connect refused"));
const status = await getLocalTailscaleStatus();
expect(status.installed).toBe(true);
expect(status.issue).toBe("service_unavailable");
});
});
describe("installTailscale", () => {
it("returns installed status without activating when no auth key", async () => {
mockAccess.mockResolvedValue(undefined);
mockExecFile.mockResolvedValueOnce({ stdout: runningJson, stderr: "" });
mockExecFile.mockResolvedValueOnce({ stdout: runningJson, stderr: "" });
const result = await installTailscale(undefined);
expect(result.installed).toBe(true);
expect(result.activated).toBe(true);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it("writes key to temp file and uses file: prefix instead of inline arg", async () => {
mockAccess.mockResolvedValue(undefined);
mockExecFile
.mockResolvedValueOnce({ stdout: runningJson, stderr: "" })
.mockResolvedValueOnce({ stdout: runningJson, stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await installTailscale("tskey-auth-test");
const tailscaleUpCall = mockExecFile.mock.calls.find(
(call: unknown[]) =>
Array.isArray(call[1]) &&
(call[1] as string[]).some((a) => (a as string).startsWith("--auth-key")),
);
expect(tailscaleUpCall).toBeDefined();
const authKeyArg = (tailscaleUpCall?.[1] as string[]).find((a) =>
(a as string).startsWith("--auth-key"),
);
expect(authKeyArg).toMatch(/^--auth-key=file:/);
expect(authKeyArg).not.toContain("tskey-auth-test");
expect(mockWriteFile).toHaveBeenCalledWith(
expect.stringContaining("ts-key-"),
"tskey-auth-test",
{ mode: 0o600 },
);
expect(mockUnlink).toHaveBeenCalled();
});
it("cleans up temp file even when tailscale up fails", async () => {
mockAccess.mockResolvedValue(undefined);
const ok = { stdout: runningJson, stderr: "" };
mockExecFile
.mockResolvedValueOnce(ok)
.mockResolvedValueOnce(ok)
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockRejectedValueOnce(new Error("auth failed"));
await expect(installTailscale("tskey-bad")).rejects.toThrow("auth failed");
expect(mockUnlink).toHaveBeenCalled();
});
it("rejects concurrent install calls", async () => {
mockAccess.mockResolvedValue(undefined);
let resolveFirst!: () => void;
const slowPromise = new Promise<{ stdout: string; stderr: string }>((res) => {
resolveFirst = () => res({ stdout: runningJson, stderr: "" });
});
mockExecFile.mockReturnValueOnce(slowPromise).mockReturnValue(slowPromise);
const first = installTailscale(undefined);
await expect(installTailscale(undefined)).rejects.toThrow("already in progress");
resolveFirst();
await first.catch(() => {});
});
});