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(); 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; } }); });