cameras/scripts/create-admin.test.ts
Michael Mainguy 462141aa35 Add admin login foundations: scrypt admin file, CLI, sessions
Groundwork for securing the web front end (vrek gol-wqf95dq). The app
does not enforce login yet.

- src/lib/admin-file.ts: the admin file at ADMIN_AUTH_FILE (default
  .data/admin.json). scrypt hashing (N=2^16, random salt, bounded
  parameters, constant-time compare), zod-validated reads where a
  malformed file is an error, and atomic 0600 writes that won't
  replace an existing admin without overwrite. Plain Node, so the
  CLI can share it (iss-mffqscg).
- src/lib/admin-auth.ts: server-only app layer; failed logins always
  cost one hash.
- scripts/create-admin.mts + `npm run admin:create`: create or reset
  the admin outside the app, interactive (hidden, confirmed) or piped
  (iss-7xmka20). The README documents it, a no-npm Node one-liner,
  the file format, and password reset.
- src/lib/session-token.ts and session.ts: stateless HMAC-signed
  session cookie, keyed from the password hash so a password change
  ends every session, with a 12 h sliding window (iss-e27nb70,
  dec-f0xar8r).

281 tests, 99.8% line coverage. Refreshes the vrek export.

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

95 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { verifyPassword } from "../src/lib/admin-file";
/** Runs the CLI as a real process with piped (non-interactive) stdin. */
const run = promisify(execFile);
const SCRIPT = path.join(import.meta.dirname, "create-admin.mts");
const PASSWORD = "correct horse battery";
let dir: string;
let file: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "create-admin-"));
file = path.join(dir, "admin.json");
});
afterEach(() => rm(dir, { recursive: true, force: true }));
async function cli(args: string[], stdin: string) {
const child = run(
process.execPath,
["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", SCRIPT, ...args],
{ env: { ...process.env, ADMIN_AUTH_FILE: file } },
);
child.child.stdin!.end(stdin);
return child.then(
({ stdout, stderr }) => ({ code: 0, stdout, stderr }),
(err) => ({ code: err.code as number, stdout: err.stdout as string, stderr: err.stderr as string }),
);
}
const readAdmin = async () => JSON.parse(await readFile(file, "utf8"));
describe("npm run admin:create", () => {
it("creates an owner-only admin file with a hash of the piped password", async () => {
const res = await cli(["--username", "admin"], `${PASSWORD}\n`);
expect(res).toMatchObject({ code: 0, stderr: "" });
expect(res.stdout).toContain(`Admin "admin" saved to ${file} (owner-only).`);
const admin = await readAdmin();
expect(admin.username).toBe("admin");
expect(await verifyPassword(PASSWORD, admin.passwordHash)).toBe(true);
expect(JSON.stringify(admin)).not.toContain(PASSWORD);
expect((await stat(file)).mode & 0o777).toBe(0o600);
});
it("reads the username from stdin too", async () => {
expect((await cli([], `ops\n${PASSWORD}\n`)).code).toBe(0);
expect((await readAdmin()).username).toBe("ops");
});
it("won't replace an existing admin without --force", async () => {
await cli(["--username", "admin"], `${PASSWORD}\n`);
const res = await cli(["--username", "intruder"], `${PASSWORD}\n`);
expect(res.code).toBe(1);
expect(res.stderr).toContain('an admin ("admin") already exists');
expect((await readAdmin()).username).toBe("admin");
});
it("replaces the admin with --force and says sessions are signed out", async () => {
await cli(["--username", "admin"], `${PASSWORD}\n`);
const res = await cli(["--username", "admin", "--force"], "a brand new password\n");
expect(res.code).toBe(0);
expect(res.stdout).toContain("Existing sessions are signed out");
expect(await verifyPassword("a brand new password", (await readAdmin()).passwordHash)).toBe(true);
});
it.each([
["a short password", ["--username", "admin"], "short\n", "password: At least 12 characters"],
["an invalid username", ["--username", "bad name"], `${PASSWORD}\n`, "username 1–64"],
["no input", [], "", "username 1–64"],
])("rejects %s and writes nothing", async (_label, args, stdin, message) => {
const res = await cli(args, stdin);
expect(res.code).toBe(1);
expect(res.stderr).toContain(message);
await expect(stat(file)).rejects.toThrow(/ENOENT/);
});
it("refuses to run over a corrupt admin file", async () => {
await writeFile(file, "{oops");
const res = await cli(["--username", "admin", "--force"], `${PASSWORD}\n`);
expect(res.code).toBe(1);
expect(res.stderr).toContain("is not valid JSON");
});
it("prints usage with --help", async () => {
const res = await cli(["--help"], "");
expect(res).toMatchObject({ code: 0 });
expect(res.stdout).toContain("Usage: npm run admin:create");
});
});