IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

tailscale-status.test.ts

Buka raw
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(() => {});
  });
});
byte
5.507
baris
158
Jenis
ts
Commit terakhir
16 Oktober 2026