IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

update-service.test.ts

Buka raw
import type * as NodeFsPromises from "node:fs/promises";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";

const { execFileMock, statMock } = vi.hoisted(() => ({
  execFileMock: vi.fn(),
  statMock: vi.fn(),
}));

vi.mock("node:child_process", () => ({
  execFile: execFileMock,
}));

vi.mock("node:fs/promises", async (importOriginal) => {
  const original = await importOriginal<typeof NodeFsPromises>();
  return { ...original, stat: statMock };
});

import {
  buildLatestReleaseApiUrl,
  buildRemotePackageJsonApiUrl,
  buildRemotePackageJsonUrl,
  compareVersions,
  getSystemUpdateStatus,
  resetUpdateStatusCacheForTests,
  scheduleSystemUpdate,
} from "@/lib/server/modules/system/update-service";
import packageJson from "@/package.json";

function resolveExecFileCallback(args: unknown[]) {
  const maybeCallback = args.at(-1);
  if (typeof maybeCallback !== "function") {
    throw new Error("Expected execFile callback");
  }

  return maybeCallback as (error: Error | null, stdout?: string, stderr?: string) => void;
}

function bumpPatchVersion(version: string) {
  const parts = version.split(".");
  const last = Number.parseInt(parts.at(-1) ?? "0", 10);
  parts[parts.length - 1] = String(Number.isFinite(last) ? last + 1 : 1);
  return parts.join(".");
}

function releaseResponse(tag: string) {
  return { ok: true, status: 200, json: async () => ({ tag_name: tag }) } as Response;
}

function branchResponse(version: string) {
  return {
    ok: true,
    status: 200,
    json: async () => ({
      content: Buffer.from(JSON.stringify({ version }), "utf8").toString("base64"),
      encoding: "base64",
    }),
  } as Response;
}

function succeedAllExecFileCalls() {
  execFileMock.mockImplementation((_command: string, _args: string[], ...rest: unknown[]) => {
    resolveExecFileCallback(rest)(null, "", "");
  });
}

describe("update-service", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
    resetUpdateStatusCacheForTests();
    execFileMock.mockReset();
    // Release mode unless a test opts into following a branch.
    vi.stubEnv("HOMEIO_REPO_BRANCH", "");
    vi.stubEnv("HOMEIO_CONTAINER", "");
  });

  afterEach(() => {
    vi.unstubAllEnvs();
  });

  it("compares semantic versions correctly", () => {
    expect(compareVersions("0.1.75", "0.1.74")).toBe(1);
    expect(compareVersions("0.1.74", "0.1.74")).toBe(0);
    expect(compareVersions("0.1.73", "0.1.74")).toBe(-1);
  });

  it("builds a raw GitHub package.json url from the repo config", () => {
    expect(
      buildRemotePackageJsonUrl("https://github.com/doctor-io/homeio.git", "main"),
    ).toBe("https://raw.githubusercontent.com/doctor-io/homeio/main/package.json");
  });

  it("builds a GitHub contents API url from the repo config", () => {
    expect(
      buildRemotePackageJsonApiUrl("https://github.com/doctor-io/homeio.git", "main"),
    ).toBe("https://api.github.com/repos/doctor-io/homeio/contents/package.json?ref=main");
  });

  it("reports that the Docker image cannot update itself", async () => {
    vi.stubEnv("HOMEIO_CONTAINER", "true");
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(releaseResponse(`v${packageJson.version}`));

    const status = await getSystemUpdateStatus();
    expect(status.canSelfUpdate).toBe(false);
  });

  it("offers the latest published release, not whatever main holds", async () => {
    const nextVersion = bumpPatchVersion(packageJson.version);
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(releaseResponse(`v${nextVersion}`));

    const status = await getSystemUpdateStatus();

    expect(status.currentVersion).toBe(packageJson.version);
    expect(status.latestVersion).toBe(nextVersion);
    expect(status.updateAvailable).toBe(true);
    expect(status.checkedAt).toBeTruthy();
    expect(status.canSelfUpdate).toBe(true);
    const [requestUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
    expect(String(requestUrl)).toBe(buildLatestReleaseApiUrl());
    expect(String(requestUrl)).toBe("https://api.github.com/repos/doctor-io/homeio/releases/latest");
    expect(requestInit).toMatchObject({
      cache: "no-store",
      headers: {
        Accept: "application/vnd.github+json",
        "User-Agent": "homeio-update-check",
      },
    });
  });

  it("reports no update before the first release is published", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, status: 404 } as Response);

    const status = await getSystemUpdateStatus();

    expect(status.latestVersion).toBeNull();
    expect(status.updateAvailable).toBe(false);
  });

  it("rejects a latest release whose tag is not a version", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(releaseResponse("main; rm -rf /"));

    await expect(getSystemUpdateStatus()).rejects.toThrow("does not have a version tag");
  });

  it("follows HOMEIO_REPO_BRANCH when a server sets it", async () => {
    vi.stubEnv("HOMEIO_REPO_BRANCH", "v2.0");
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(branchResponse("2.0.13"));

    const status = await getSystemUpdateStatus();

    expect(status.latestVersion).toBe("2.0.13");
    expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
      "https://api.github.com/repos/doctor-io/homeio/contents/package.json?ref=v2.0",
    );
  });

  it("asks GitHub at most once an hour, however many desktops load", async () => {
    const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(releaseResponse("v9.9.9"));

    await Promise.all([getSystemUpdateStatus(), getSystemUpdateStatus(), getSystemUpdateStatus()]);
    now.mockReturnValue(1_000_000 + 59 * 60_000);
    await getSystemUpdateStatus();
    expect(fetchMock).toHaveBeenCalledTimes(1);

    now.mockReturnValue(1_000_000 + 60 * 60_000);
    await getSystemUpdateStatus();
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("serves the last known version when GitHub rate-limits the check", async () => {
    const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
    vi.spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(releaseResponse("v9.9.9"))
      .mockResolvedValueOnce({ ok: false, status: 403 } as Response);
    const first = await getSystemUpdateStatus();

    now.mockReturnValue(1_000_000 + 2 * 60 * 60_000);
    const second = await getSystemUpdateStatus();

    expect(second.latestVersion).toBe("9.9.9");
    // The answer is as old as the last successful check, and says so.
    expect(second.checkedAt).toBe(first.checkedAt);
  });

  it("asks GitHub and reports its failure when the user checks now", async () => {
    const fetchMock = vi
      .spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(releaseResponse("v9.9.9"))
      .mockResolvedValueOnce({ ok: false, status: 403 } as Response);
    await getSystemUpdateStatus();

    await expect(getSystemUpdateStatus({ refresh: true })).rejects.toThrow("(403)");
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("still fails when GitHub cannot be reached and nothing is known yet", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, status: 403 } as Response);

    await expect(getSystemUpdateStatus()).rejects.toThrow("(403)");
  });

  it("waits five minutes before asking a rate-limiting GitHub again", async () => {
    const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
    const fetchMock = vi
      .spyOn(globalThis, "fetch")
      .mockResolvedValueOnce({ ok: false, status: 429 } as Response)
      .mockResolvedValueOnce(releaseResponse("v9.9.9"));

    await expect(getSystemUpdateStatus()).rejects.toThrow("(429)");
    now.mockReturnValue(1_000_000 + 4 * 60_000);
    await expect(getSystemUpdateStatus()).rejects.toThrow("(429)");
    expect(fetchMock).toHaveBeenCalledTimes(1);

    now.mockReturnValue(1_000_000 + 5 * 60_000);
    expect((await getSystemUpdateStatus()).latestVersion).toBe("9.9.9");
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("schedules the updater through a transient systemd unit, pinned to the latest release", async () => {
    statMock.mockResolvedValueOnce({});
    succeedAllExecFileCalls();
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(releaseResponse("v9.9.9"));

    const result = await scheduleSystemUpdate();

    expect(result).toEqual({
      action: "update",
      accepted: true,
    });
    expect(execFileMock).toHaveBeenCalledWith(
      "systemd-run",
      expect.arrayContaining([
        "--quiet",
        "--no-block",
        "--collect",
        "--property=Type=exec",
        "--property=KillMode=control-group",
        "--property=TimeoutStopSec=30s",
        "--property=SendSIGKILL=yes",
        "--setenv=HOMEIO_REPO_BRANCH=v9.9.9",
        "--setenv=HOMEIO_REPO_URL=https://github.com/doctor-io/homeio.git",
        "bash",
        "-lc",
        expect.stringContaining("scripts/update.sh"),
      ]),
      expect.any(Function),
    );
  });

  it("installs the configured branch when the server follows one", async () => {
    vi.stubEnv("HOMEIO_REPO_BRANCH", "v2.0");
    statMock.mockResolvedValueOnce({});
    succeedAllExecFileCalls();
    const fetchMock = vi.spyOn(globalThis, "fetch");

    await scheduleSystemUpdate();

    expect(fetchMock).not.toHaveBeenCalled();
    expect(execFileMock).toHaveBeenCalledWith(
      "systemd-run",
      expect.arrayContaining(["--setenv=HOMEIO_REPO_BRANCH=v2.0"]),
      expect.any(Function),
    );
  });

  it("refuses to update when no release has been published", async () => {
    statMock.mockResolvedValueOnce({});
    succeedAllExecFileCalls();
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, status: 404 } as Response);

    await expect(scheduleSystemUpdate()).rejects.toThrow("no published Homeio release");
    expect(execFileMock).not.toHaveBeenCalledWith("systemd-run", expect.anything(), expect.anything());
  });

  it("rejects scheduling when the update script does not exist", async () => {
    statMock.mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));

    await expect(scheduleSystemUpdate()).rejects.toThrow(
      "Update script not found",
    );
    expect(execFileMock).not.toHaveBeenCalled();
  });

    it("rejects scheduling when running inside a container runtime", async () => {
    process.env.HOMEIO_CONTAINER = "true";
    try {
      await expect(scheduleSystemUpdate()).rejects.toThrow(
        "Homeio is running in a Docker container",
      );
    } finally {
      delete process.env.HOMEIO_CONTAINER;
    }
  });
});
byte
10.665
baris
295
Jenis
ts
Commit terakhir
16 Oktober 2026