IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

preferences-service.test.ts

Open raw
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");
  });
});
bytes
11,100
lines
332
Type
ts
Latest commit
16 October 2026