import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyEnvironment, rememberBinDir, serverEnvironment, configFile, ensureSecrets, firstRun, parseEnvFile, readConfig, writeConfig, } from "./first-run"; let home: string; let work: string; let cwd: string; beforeEach(async () => { home = await mkdtemp(path.join(os.tmpdir(), "first-run-home-")); work = await mkdtemp(path.join(os.tmpdir(), "first-run-data-")); // Tests must never write into the repo, and the default folder is relative to cwd. cwd = await mkdtemp(path.join(os.tmpdir(), "first-run-cwd-")); vi.spyOn(process, "cwd").mockReturnValue(cwd); }); afterEach(async () => { for (const dir of [home, work, cwd]) await rm(dir, { recursive: true, force: true }); }); const logs: string[] = []; const log = (line: string) => logs.push(line); beforeEach(() => (logs.length = 0)); /** No terminal by default, and MediaMTX already installed, unless a test says otherwise. */ const run = (options: Parameters[0] = {}) => firstRun({ env: {}, home, log, exists: async () => true, ...options }); describe("configFile", () => { it("sits under the user's config directory, honouring XDG_CONFIG_HOME", () => { expect(configFile({}, "/home/mm")).toBe("/home/mm/.config/onvif-dashboard/config.json"); expect(configFile({ XDG_CONFIG_HOME: "/cfg" }, "/home/mm")).toBe("/cfg/onvif-dashboard/config.json"); }); }); describe("readConfig / writeConfig", () => { it("remembers the folder and reads it back", async () => { await writeConfig({ dataDir: "/srv/clips" }, {}, home); expect(await readConfig({}, home)).toEqual({ dataDir: "/srv/clips" }); }); it.each([["", "missing file"], ["{oops", "not JSON"], ["{}", "no folder"], ['{"dataDir":3}', "wrong type"]])( "treats %j (%s) as nothing remembered", async (text) => { if (text) { await mkdir(path.dirname(configFile({}, home)), { recursive: true }); await writeFile(configFile({}, home), text); } expect(await readConfig({}, home)).toEqual({}); }, ); }); describe("parseEnvFile", () => { it("reads KEY=value lines and ignores comments and noise", () => { expect(parseEnvFile("# comment\nA=1\n B = two \n\nnot a line\nlowercase=x\nC=")).toEqual({ A: "1", B: "two", C: "", }); }); }); describe("ensureSecrets", () => { it("creates an owner-only key file once, then leaves it alone", async () => { const first = await ensureSecrets(work); expect(first.created).toBe(true); expect(first.values.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/); const file = path.join(work, "secrets.env"); expect((await stat(file)).mode & 0o777).toBe(0o600); expect(await readFile(file, "utf8")).toContain("Keep this file private"); const again = await ensureSecrets(work); expect(again.created).toBe(false); expect(again.values.CAMERA_CREDENTIALS_KEY).toBe(first.values.CAMERA_CREDENTIALS_KEY); }); it("keeps other values already in the file", async () => { await writeFile(path.join(work, "secrets.env"), "OTHER=keep\n"); const { values } = await ensureSecrets(work); expect(values).toMatchObject({ OTHER: "keep" }); expect(values.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/); }); }); describe("firstRun", () => { it("asks where data should live, offering the working directory, and remembers the answer", async () => { const ask = vi.fn(async () => work); const result = await run({ ask }); expect(ask).toHaveBeenCalledWith("Where should this keep its data?", path.join(process.cwd(), ".data")); expect(result).toMatchObject({ dataDir: work, source: "answered", secretCreated: true, videoReady: true }); expect(await readConfig({}, home)).toEqual({ dataDir: work }); expect(logs.join(" ")).toContain("Made a key for stored camera logins"); }); it("takes the offered default when the answer is empty", async () => { expect((await run({ ask: async (_q, fallback) => fallback })).dataDir).toBe(path.join(cwd, ".data")); }); it("resolves a relative answer against the directory it was started in", async () => { const original = process.cwd; process.cwd = () => cwd; // path.resolve reads this directly, so the spy isn't enough try { expect((await run({ ask: async () => "clips" })).dataDir).toBe(path.join(cwd, "clips")); } finally { process.cwd = original; } }); it("checks for MediaMTX on disk when not told otherwise", async () => { const env = { CAMERAS_DATA_DIR: work, MEDIAMTX_BIN: path.join(work, "mediamtx") }; expect((await firstRun({ env, home, log })).videoReady).toBe(false); await writeFile(env.MEDIAMTX_BIN, "#!/bin/sh\n"); expect((await firstRun({ env, home, log })).videoReady).toBe(true); }); it("never blocks without a terminal: takes the default and says so", async () => { const result = await run(); expect(result).toMatchObject({ source: "default", dataDir: path.join(process.cwd(), ".data") }); expect(logs.join(" ")).toContain("No terminal to ask"); expect(logs.join(" ")).toContain("CAMERAS_DATA_DIR"); }); it("uses the environment first, and doesn't remember what it was told", async () => { const ask = vi.fn(); const result = await run({ env: { CAMERAS_DATA_DIR: work }, ask }); expect(result).toMatchObject({ dataDir: work, source: "environment" }); expect(ask).not.toHaveBeenCalled(); expect(await readConfig({}, home)).toEqual({}); }); it("uses the remembered folder on later runs, without asking again", async () => { await writeConfig({ dataDir: work }, {}, home); const ask = vi.fn(); const result = await run({ ask }); expect(result).toMatchObject({ dataDir: work, source: "remembered", secretCreated: true }); expect(ask).not.toHaveBeenCalled(); const again = await run({ ask }); expect(again.secretCreated).toBe(false); // nothing regenerated }); it("says when live video needs installing", async () => { const result = await run({ env: { CAMERAS_DATA_DIR: work }, exists: async () => false }); expect(result.videoReady).toBe(false); expect(logs.join(" ")).toContain("install-video"); }); it("creates the data folder owner-only", async () => { const dir = path.join(work, "nested", "data"); await run({ env: { CAMERAS_DATA_DIR: dir } }); expect((await stat(dir)).mode & 0o777).toBe(0o700); }); }); describe("applyEnvironment", () => { it("fills in the remembered folder and the stored key for this run", async () => { await writeConfig({ dataDir: work }, {}, home); await ensureSecrets(work); const env: Record = {}; await applyEnvironment(env, home); expect(env.CAMERAS_DATA_DIR).toBe(work); expect(env.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/); }); it("never overrides what the environment already says", async () => { await writeConfig({ dataDir: work }, {}, home); await ensureSecrets(work); const env = { CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" }; await applyEnvironment(env, home); expect(env).toEqual({ CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" }); }); it("does nothing when there is neither a pointer nor a key file", async () => { const env: Record = {}; await applyEnvironment(env, home); expect(env.CAMERA_CREDENTIALS_KEY).toBeUndefined(); }); }); describe("serverEnvironment", () => { it("pins the data and binary folders before the server starts elsewhere", async () => { await writeConfig({ dataDir: work }, {}, home); await ensureSecrets(work); const env: Record = {}; await serverEnvironment(env, home); expect(env.CAMERAS_DATA_DIR).toBe(work); // Resolved from where the command was typed, not from wherever the server will run. expect(env.CAMERAS_BIN_DIR).toBe(path.join(cwd, "bin")); expect(env.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/); }); it("leaves anything the environment already set alone", async () => { const env = { CAMERAS_DATA_DIR: "/srv/data", CAMERAS_BIN_DIR: "/opt/bin" }; await serverEnvironment(env, home); expect(env).toMatchObject({ CAMERAS_DATA_DIR: "/srv/data", CAMERAS_BIN_DIR: "/opt/bin" }); }); }); describe("rememberBinDir", () => { it("remembers where helper binaries went, so another directory still finds them", async () => { const env: Record = {}; expect(await rememberBinDir(env, home)).toBe(path.join(cwd, "bin")); expect(await readConfig({}, home)).toMatchObject({ binDir: path.join(cwd, "bin") }); // A later command, run from somewhere else entirely, is told the same folder. const later: Record = {}; await applyEnvironment(later, home); expect(later.CAMERAS_BIN_DIR).toBe(path.join(cwd, "bin")); }); it("keeps the data folder it was told earlier", async () => { await writeConfig({ dataDir: work }, {}, home); await rememberBinDir({ CAMERAS_BIN_DIR: "/opt/bin" }, home); expect(await readConfig({}, home)).toEqual({ dataDir: work, binDir: "/opt/bin" }); }); it("prefers the environment, then what was remembered", async () => { await writeConfig({ binDir: "/remembered" }, {}, home); expect(await rememberBinDir({ CAMERAS_BIN_DIR: "/from-env" }, home)).toBe("/from-env"); expect(await rememberBinDir({}, home)).toBe("/from-env"); }); });