homeio
PublicForked from doctor-io/homeio
preferences-service.test.ts
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");
});
});