cameras/src/lib/first-run.test.ts
Michael Mainguy 65345d0984 Add the first-run experience and the onvif-dashboard command
- src/lib/first-run.ts settles where data lives (asked once, remembered
  in a pointer under the user's config dir), creates the owner-only key
  that protects stored camera logins, and reports whether MediaMTX is
  installed. Without a terminal nothing prompts: defaults are taken and
  logged, so a service still starts. Running it again changes nothing.
- cli/onvif-dashboard.mjs is the published command: setup, install-video
  and help. Node runs the TypeScript in src/lib directly, so the CLI
  needs no build of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 19:00:16 -05:00

191 lines
7.4 KiB
TypeScript

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,
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<typeof firstRun>[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("/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(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(work, {}, home);
await ensureSecrets(work);
const env: Record<string, string | undefined> = {};
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(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<string, string | undefined> = {};
await applyEnvironment(env, home);
expect(env.CAMERA_CREDENTIALS_KEY).toBeUndefined();
});
});