import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AdminFileError, adminFilePath, createAdminRecord, DEFAULT_ADMIN_FILE, hashPassword, isValidPasswordHash, passwordSchema, readAdminFile, usernameSchema, verifyPassword, writeAdminFile, type AdminFile, } from "./admin-file"; const PASSWORD = "correct horse battery"; // A well-formed hash string (valid shape, meaningless key) for the malformed-hash table, // which is built before beforeAll runs. const SHAPE = `scrypt$65536$8$1$${"A".repeat(24)}$${"A".repeat(88)}`; let hash: string; let admin: AdminFile; beforeAll(async () => { hash = await hashPassword(PASSWORD); admin = { version: 1, username: "admin", passwordHash: hash }; }); let dir: string; let file: string; beforeEach(async () => { dir = await mkdtemp(path.join(os.tmpdir(), "admin-file-")); file = path.join(dir, "nested", "admin.json"); }); afterEach(() => rm(dir, { recursive: true, force: true })); describe("adminFilePath", () => { it("uses ADMIN_AUTH_FILE when set", () => { expect(adminFilePath({ ADMIN_AUTH_FILE: "/etc/cams/admin.json" })).toBe("/etc/cams/admin.json"); }); it.each([[{}], [{ ADMIN_AUTH_FILE: "" }]])("defaults to ./.data/admin.json for %j", (env) => { expect(adminFilePath(env)).toBe(DEFAULT_ADMIN_FILE); expect(DEFAULT_ADMIN_FILE).toBe(path.join(process.cwd(), ".data", "admin.json")); }); }); describe("password hashing", () => { it("produces a salted scrypt string that never contains the password", () => { expect(hash).toMatch(/^scrypt\$65536\$8\$1\$[A-Za-z0-9+/=]{24}\$[A-Za-z0-9+/=]{88}$/); expect(hash).not.toContain(PASSWORD); expect(isValidPasswordHash(hash)).toBe(true); }); it("salts every hash differently", async () => { expect(await hashPassword(PASSWORD)).not.toBe(hash); }); it("verifies the right password and rejects others", async () => { expect(await verifyPassword(PASSWORD, hash)).toBe(true); expect(await verifyPassword("correct horse battery!", hash)).toBe(false); expect(await verifyPassword("", hash)).toBe(false); }); it("verifies hashes made with other scrypt parameters", async () => { // e.g. one produced by a future, costlier default, or by hand with the documented one-liner. const { scryptSync, randomBytes } = await import("node:crypto"); const salt = randomBytes(16); const key = scryptSync(PASSWORD, salt, 32, { N: 1024, r: 4, p: 2 }); const encoded = `scrypt$1024$4$2$${salt.toString("base64")}$${key.toString("base64")}`; expect(await verifyPassword(PASSWORD, encoded)).toBe(true); }); it("accepts the table's base shape", () => expect(isValidPasswordHash(SHAPE)).toBe(true)); it.each([ ["not a hash", "hunter2"], ["wrong algorithm", SHAPE.replace(/^scrypt/, "sha512")], ["N not a power of two", SHAPE.replace("$65536$", "$65535$")], ["N too large", SHAPE.replace("$65536$", "$2097152$")], ["r out of range", SHAPE.replace("$8$1$", "$0$1$")], ["p out of range", SHAPE.replace("$8$1$", "$8$99$")], ["salt too short", "scrypt$1024$8$1$AAAA$" + "A".repeat(88)], ["key too short", `scrypt$1024$8$1$${"A".repeat(24)}$AAAA`], ])("rejects %s without hashing", async (_label, encoded) => { expect(isValidPasswordHash(encoded)).toBe(false); expect(await verifyPassword(PASSWORD, encoded)).toBe(false); }); }); describe("validation", () => { it.each(["admin", "mike.m", "ops_team-1", "me@example.com", "a".repeat(64)])( "accepts username %s", (u) => expect(usernameSchema.safeParse(u).success).toBe(true), ); it.each(["", "has space", "semi;colon", "a".repeat(65), "ünïcode"])("rejects username %j", (u) => expect(usernameSchema.safeParse(u).success).toBe(false), ); it("requires 12–256 character passwords", () => { expect(passwordSchema.safeParse("x".repeat(11)).success).toBe(false); expect(passwordSchema.safeParse("x".repeat(12)).success).toBe(true); expect(passwordSchema.safeParse("x".repeat(256)).success).toBe(true); expect(passwordSchema.safeParse("x".repeat(257)).success).toBe(false); }); it("createAdminRecord validates, then hashes", async () => { await expect(createAdminRecord("bad name", PASSWORD)).rejects.toThrow(); await expect(createAdminRecord("admin", "short")).rejects.toThrow(); const record = await createAdminRecord("admin", PASSWORD); expect(record).toMatchObject({ version: 1, username: "admin" }); expect(await verifyPassword(PASSWORD, record.passwordHash)).toBe(true); }); }); describe("reading and writing the file", () => { it("returns null when the file doesn't exist", async () => { expect(await readAdminFile(file)).toBeNull(); }); it("writes owner-only in an owner-only folder, and reads it back", async () => { await writeAdminFile(file, admin); expect(await readAdminFile(file)).toEqual(admin); expect((await stat(file)).mode & 0o777).toBe(0o600); expect((await stat(path.dirname(file))).mode & 0o777).toBe(0o700); expect(await readFile(file, "utf8")).not.toContain(PASSWORD); }); it("refuses to replace an existing admin unless asked, leaving no temp files", async () => { await writeAdminFile(file, admin); const other = { ...admin, username: "intruder" }; await expect(writeAdminFile(file, other)).rejects.toMatchObject({ code: "EEXIST" }); expect((await readAdminFile(file))!.username).toBe("admin"); await writeAdminFile(file, other, { overwrite: true }); expect((await readAdminFile(file))!.username).toBe("intruder"); const { readdir } = await import("node:fs/promises"); expect(await readdir(path.dirname(file))).toEqual(["admin.json"]); }); it("refuses to write an invalid record", async () => { await expect(writeAdminFile(file, { ...admin, passwordHash: "plaintext" })).rejects.toThrow(); expect(await readAdminFile(file)).toBeNull(); }); describe("fails loudly instead of disabling auth", () => { it.each([ ["invalid JSON", "{not json", /not valid JSON/], ["a missing field", JSON.stringify({ version: 1, username: "admin" }), /passwordHash/], ["a plaintext password", JSON.stringify({ version: 1, username: "admin", passwordHash: "hunter2" }), /passwordHash/], ["an unknown version", JSON.stringify({ version: 2, username: "admin", passwordHash: "x" }), /version/], ["a non-object", "null", /file/], ])("on %s", async (_label, contents, message) => { await writeFile(file.replace("nested/", ""), contents); const err = await readAdminFile(file.replace("nested/", "")).catch((e) => e); expect(err).toBeInstanceOf(AdminFileError); expect(err.message).toMatch(message); }); it("on an unreadable path", async () => { // A directory where the file should be: exists, but can't be read as a file. await writeAdminFile(path.join(file, "inner.json"), admin); await expect(readAdminFile(file)).rejects.toBeInstanceOf(AdminFileError); }); }); });