import { chmod, mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), })); vi.mock("node:child_process", () => ({ execFile: execFileMock, })); import { getSystemPreferences, resetPreferencesCacheForTests, updateSystemPreferences, } from "@/lib/server/modules/system/preferences-service"; 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; } describe("preferences-service", () => { beforeEach(async () => { execFileMock.mockReset(); resetPreferencesCacheForTests(); const root = await mkdtemp(path.join(os.tmpdir(), "homeio-preferences-")); const timezonePath = path.join(root, "timezone"); const hostsPath = path.join(root, "hosts"); await mkdir(path.dirname(hostsPath), { recursive: true }); await writeFile(timezonePath, "Etc/UTC\n", "utf8"); await writeFile(hostsPath, "127.0.0.1 localhost\n127.0.1.1 homeio homeio.local\n", "utf8"); process.env.HOMEIO_TIMEZONE_FILE_PATH = timezonePath; process.env.HOMEIO_HOSTS_FILE_PATH = hostsPath; }); it("reads the host once for repeated Settings loads, and shows a saved change at once", async () => { let currentHostname = "homeio"; execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "hostnamectl" && args[0] === "set-hostname") currentHostname = args[1] ?? currentHostname; if (command === "hostnamectl" && args[0] === "--static") return callback(null, `${currentHostname}\n`, ""); if (command === "timedatectl" && args[0] === "show") return callback(null, "Etc/UTC\n", ""); callback(null, "", ""); }); const hostnameReads = () => execFileMock.mock.calls.filter(([command, args]) => command === "hostnamectl" && args[0] === "--static").length; await getSystemPreferences(); await getSystemPreferences(); expect(hostnameReads()).toBe(1); await updateSystemPreferences({ hostname: "nas", timezone: "Etc/UTC" }); expect(await getSystemPreferences()).toEqual({ hostname: "nas", timezone: "Etc/UTC" }); }); it("restarts avahi after hostname changes and rewrites the hosts file", async () => { let currentHostname = "homeio"; const calls: Array<{ command: string; args: string[] }> = []; execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { calls.push({ command, args }); const callback = resolveExecFileCallback(rest); if (command === "hostnamectl" && args[0] === "--static") { callback(null, `${currentHostname}\n`, ""); return; } if (command === "hostname") { callback(null, `${currentHostname}\n`, ""); return; } if (command === "timedatectl" && args[0] === "show") { callback(null, "Etc/UTC\n", ""); return; } if (command === "hostnamectl" && args[0] === "set-hostname") { currentHostname = args[1] ?? currentHostname; callback(null, "", ""); return; } if (command === "systemctl" && args[0] === "restart") { callback(null, "", ""); return; } callback(null, "", ""); }); const preferences = await updateSystemPreferences({ hostname: "homeai", timezone: "Etc/UTC", }); expect(preferences.hostname).toBe("homeai"); expect(calls).toEqual( expect.arrayContaining([ { command: "hostnamectl", args: ["set-hostname", "homeai"] }, { command: "systemctl", args: ["restart", "avahi-daemon"] }, ]), ); const hostsContent = await readFile(process.env.HOMEIO_HOSTS_FILE_PATH!, "utf8"); expect(hostsContent).toContain("127.0.1.1 homeai homeai.local"); }); it("falls back to the hostname command when hostnamectl is unavailable", async () => { let currentHostname = "homeio"; const calls: Array<{ command: string; args: string[] }> = []; execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { calls.push({ command, args }); const callback = resolveExecFileCallback(rest); if (command === "hostnamectl" && args[0] === "--static") { const error = Object.assign(new Error("spawn hostnamectl ENOENT"), { code: "ENOENT" }); callback(error as Error, "", ""); return; } if (command === "hostname" && args.length === 0) { callback(null, `${currentHostname}\n`, ""); return; } if (command === "hostname" && args.length === 1) { currentHostname = args[0] ?? currentHostname; callback(null, "", ""); return; } if (command === "timedatectl" && args[0] === "show") { callback(null, "Etc/UTC\n", ""); return; } if (command === "hostnamectl" && args[0] === "set-hostname") { const error = Object.assign(new Error("spawn hostnamectl ENOENT"), { code: "ENOENT" }); callback(error as Error, "", ""); return; } if (command === "systemctl" && args[0] === "restart") { callback(null, "", ""); return; } callback(null, "", ""); }); const preferences = await updateSystemPreferences({ hostname: "homeai", timezone: "Etc/UTC", }); expect(preferences.hostname).toBe("homeai"); expect(calls).toEqual( expect.arrayContaining([{ command: "hostname", args: ["homeai"] }]), ); }); it("skips system hostname change when container lacks SYS_ADMIN but still updates hosts file", async () => { execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "hostnamectl" && args[0] === "--static") { const error = Object.assign(new Error("spawn hostnamectl ENOENT"), { code: "ENOENT" }); callback(error as Error, "", ""); return; } if (command === "hostname" && args.length === 0) { callback(null, "homeio\n", ""); return; } if (command === "hostname" && args.length === 1) { const error = new Error("Command failed: hostname homeio\nhostname: sethostname: Operation not permitted"); callback(error, "", ""); return; } if (command === "timedatectl" && args[0] === "show") { callback(null, "Etc/UTC\n", ""); return; } if (command === "systemctl" && args[0] === "restart") { callback(null, "", ""); return; } callback(null, "", ""); }); await updateSystemPreferences({ hostname: "homeai", timezone: "Etc/UTC" }); const hostsContent = await readFile(process.env.HOMEIO_HOSTS_FILE_PATH!, "utf8"); expect(hostsContent).toContain("127.0.1.1 homeai homeai.local"); }); it("falls back to writing the timezone file when timedatectl is unavailable", async () => { execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "hostnamectl" && args[0] === "--static") { callback(null, "homeio\n", ""); return; } if (command === "timedatectl" && args[0] === "show") { callback(null, "Etc/UTC\n", ""); return; } if (command === "timedatectl" && args[0] === "set-timezone") { const error = Object.assign(new Error("spawn timedatectl ENOENT"), { code: "ENOENT" }); callback(error as Error, "", ""); return; } callback(null, "", ""); }); await updateSystemPreferences({ hostname: "homeio", timezone: "Europe/Paris", }); const timezoneContent = await readFile(process.env.HOMEIO_TIMEZONE_FILE_PATH!, "utf8"); expect(timezoneContent.trim()).toBe("Europe/Paris"); }); it("does not fail when the timezone file is root-owned and cannot be written", async () => { const readonlyRoot = await mkdtemp(path.join(os.tmpdir(), "homeio-readonly-")); process.env.HOMEIO_TIMEZONE_FILE_PATH = path.join(readonlyRoot, "timezone"); await chmod(readonlyRoot, 0o444); try { execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "hostnamectl" && args[0] === "--static") { callback(null, "homeio\n", ""); return; } if (command === "timedatectl" && args[0] === "show") { callback(null, "Etc/UTC\n", ""); return; } if (command === "timedatectl" && args[0] === "set-timezone") { const error = Object.assign(new Error("spawn timedatectl ENOENT"), { code: "ENOENT" }); callback(error as Error, "", ""); return; } callback(null, "", ""); }); await expect( updateSystemPreferences({ hostname: "homeio", timezone: "Europe/Paris" }), ).resolves.not.toThrow(); } finally { await chmod(readonlyRoot, 0o755); } }); it("does not fail the hostname update when avahi-daemon is unavailable", async () => { let currentHostname = "homeio"; execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "hostnamectl" && args[0] === "--static") { callback(null, `${currentHostname}\n`, ""); return; } if (command === "hostname") { callback(null, `${currentHostname}\n`, ""); return; } if (command === "timedatectl" && args[0] === "show") { callback(null, "Etc/UTC\n", ""); return; } if (command === "hostnamectl" && args[0] === "set-hostname") { currentHostname = args[1] ?? currentHostname; callback(null, "", ""); return; } if (command === "systemctl" && args[0] === "restart") { const error = new Error("Unit avahi-daemon.service could not be found.") as Error & { stdout?: string; stderr?: string; }; error.stderr = "Unit avahi-daemon.service could not be found."; callback(error, "", "Unit avahi-daemon.service could not be found."); return; } callback(null, "", ""); }); const preferences = await updateSystemPreferences({ hostname: "homeai", timezone: "Etc/UTC", }); expect(preferences.hostname).toBe("homeai"); const hostsContent = await readFile(process.env.HOMEIO_HOSTS_FILE_PATH!, "utf8"); expect(hostsContent).toContain("127.0.1.1 homeai homeai.local"); }); });