Files
obsidian-foundry-sync/tests/e7-2-auth.test.ts
Kaysser Kayyali fe925d4aec feat(E7.2): localhost-default bind + refuse-to-start + first-run auth prompt
Safe-by-default dashboard exposure: localhost-only unless you explicitly opt in
to a public bind with a token.

- src/cli.ts: host default 0.0.0.0 → 127.0.0.1 (localhost-only; --host 0.0.0.0 to
  expose, which then requires a token when the auth flag is on).
- src/server.ts startServer guards (before listen): SELF-LOCKOUT (flag on + no
  token → throw — you'd otherwise brick the dashboard with no recovery short of
  editing .env) + PUBLIC-EXPOSURE gate (flag on + 0.0.0.0 + no token → throw).
  Both use __authState (mutable seam) so they're testable. __authState.bound set
  from cfg.host. When the flag is off, the guards are skipped (back-compat
  escape hatch — the bind still defaults to 127.0.0.1).
- src/server.ts authenticate: E7.2 bind-gating — enforce only on a PUBLIC bind
  (0.0.0.0); 127.0.0.1 is localhost-trusted (requireAuth routes stay open even
  with the flag on). The refuse-to-start guard ensures a 0.0.0.0 bind has a token.
- src/server.ts routes: GET /api/auth/status (open; {authRequired, bound,
  relayConfigured, foundryConfigured} — booleans only, no secret values),
  POST /api/auth/login (open; validates token constant-time, sets an HttpOnly
  SameSite=Strict cookie; 401 invalid credentials on mismatch, no leak; empty
  token = unset; no token configured → 401), POST /api/auth/logout (clears
  cookie, Max-Age=0).
- src/dashboard.html: first-run login card (token input, shown when authRequired
  && no stored token), a shared apiFetch wrapper (attaches the stored token as a
  Bearer header, on 401 → show login), checkAuth gating init; bare fetch('/api/')
  calls migrated to apiFetch (auth endpoints stay plain fetch).
- .env.example: documents DASHBOARD_AUTH_TOKEN + ENABLE_AUTH_MIDDLEWARE.
- tests: e7-1-auth/dispatch updated for bind-gating (enforcement tests set
  bound=0.0.0.0; + a 127.0.0.1 no-enforcement test). e7-2-auth.test.ts (13
  tests): auth-status (off/localhost/public/no-secret-leak), login
  (valid-cookie/invalid-401/empty/unset), logout (clears), refuse-to-start
  (self-lockout / 0.0.0.0-no-token / token-set-passes / flag-off-no-guard).

tsc clean; 194 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 01:08:46 +00:00

152 lines
7.6 KiB
TypeScript

// E7.2 — localhost-default bind + refuse-to-start + first-run auth prompt.
//
// Covers: /api/auth/status (authRequired flag), /api/auth/login (valid/invalid →
// cookie / 401), /api/auth/logout (clears cookie), and the startServer refuse-to-
// start guards (self-lockout + 0.0.0.0-without-token).
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "node:http";
import { startServer, __authState } from "../src/server.js";
let dir: string;
let server: Server;
let baseURL: string;
const savedEnabled = __authState.enabled;
const savedToken = __authState.token;
const savedBound = __authState.bound;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "e7-2-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "cc"), { recursive: true });
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open(); await jdb.close();
const { server: srv } = await startServer({
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir: join(dir, "out"), mode: "dev", port: 0, host: "127.0.0.1",
});
server = srv;
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
afterAll(async () => {
__authState.enabled = savedEnabled; __authState.token = savedToken; __authState.bound = savedBound;
await new Promise<void>((r) => server.close(() => r()));
await rm(dir, { recursive: true, force: true });
});
beforeEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
afterEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
async function req(path: string, init: RequestInit = {}): Promise<{ code: number; body: unknown; headers: Headers }> {
const r = await fetch(`${baseURL}${path}`, init);
return { code: r.status, body: r.headers.get("content-type")?.includes("json") ? await r.json() : await r.text(), headers: r.headers };
}
describe("E7.2 /api/auth/status", () => {
it("flag off → authRequired:false, bound:127.0.0.1", async () => {
const r = await req("/api/auth/status");
expect(r.code).toBe(200);
expect((r.body as { authRequired: boolean }).authRequired).toBe(false);
expect((r.body as { bound: string }).bound).toBe("127.0.0.1");
});
it("flag on + 127.0.0.1 (localhost trusted) → authRequired:false", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "127.0.0.1";
const r = await req("/api/auth/status");
expect((r.body as { authRequired: boolean }).authRequired).toBe(false);
});
it("flag on + 0.0.0.0 + token → authRequired:true", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0";
const r = await req("/api/auth/status");
expect((r.body as { authRequired: boolean }).authRequired).toBe(true);
});
it("never echoes the token or secret config (booleans only)", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0";
const r = await req("/api/auth/status");
const body = JSON.stringify(r.body);
expect(body).not.toContain("s3cret");
});
});
describe("E7.2 /api/auth/login + logout", () => {
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0"; });
it("valid token → 200 + sets an HttpOnly SameSite=Strict auth_token cookie", async () => {
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "s3cret" }) });
expect(r.code).toBe(200);
const cookie = r.headers.get("set-cookie") ?? "";
expect(cookie).toContain("auth_token=s3cret");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Strict");
});
it("wrong token → 401 invalid credentials (no leak)", async () => {
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "wrong" }) });
expect(r.code).toBe(401);
expect((r.body as { error: string }).error).toBe("invalid credentials");
expect(r.headers.get("set-cookie")).toBe(null); // no cookie set on failure
});
it("empty/whitespace token → 401 (treated as unset)", async () => {
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: " " }) });
expect(r.code).toBe(401);
});
it("no token configured (DASHBOARD_AUTH_TOKEN unset) → 401 (can't log in)", async () => {
__authState.token = "";
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "anything" }) });
expect(r.code).toBe(401);
});
it("logout → 200 + clears the cookie (Max-Age=0)", async () => {
const r = await req("/api/auth/logout", { method: "POST" });
expect(r.code).toBe(200);
const cookie = r.headers.get("set-cookie") ?? "";
expect(cookie).toContain("auth_token=");
expect(cookie).toContain("Max-Age=0");
});
});
describe("E7.2 startServer refuse-to-start guards", () => {
// These throw BEFORE opening the journal, so no LevelDB is needed.
const dummyCfg = (host: string) => ({
journal: join(dir, "no-such-journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir: join(dir, "out"), mode: "dev" as const, port: 0, host,
});
it("flag on + no token → refuses to start (self-lockout guard)", async () => {
__authState.enabled = true; __authState.token = ""; __authState.bound = "127.0.0.1";
await expect(startServer(dummyCfg("127.0.0.1"))).rejects.toThrow(/ENABLE_AUTH_MIDDLEWARE=on requires DASHBOARD_AUTH_TOKEN/);
});
it("flag on + 0.0.0.0 + no token → refuses to start (public-exposure gate)", async () => {
__authState.enabled = true; __authState.token = ""; __authState.bound = "127.0.0.1";
// The self-lockout guard fires first (no token at all), so this throws the
// self-lockout message — both guards refuse; assert it refuses (either message).
await expect(startServer(dummyCfg("0.0.0.0"))).rejects.toThrow();
});
it("flag on + token set → does NOT throw on the guards (would proceed to open the journal)", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "127.0.0.1";
// With a token, the guards pass; startServer then tries to open the journal
// (dummyCfg points at a non-existent path) → throws a DIFFERENT error (LevelDB
// open), NOT the guard message. Assert it does NOT throw the guard message.
try {
await startServer(dummyCfg("0.0.0.0"));
expect.unreachable("should have thrown on the missing journal");
} catch (e) {
expect((e as Error).message).not.toMatch(/DASHBOARD_AUTH_TOKEN/);
expect((e as Error).message).not.toMatch(/refusing to bind/);
}
});
it("flag off → no guard (back-compat: 0.0.0.0 + no token would proceed)", async () => {
__authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1";
try {
await startServer(dummyCfg("0.0.0.0"));
expect.unreachable("should have thrown on the missing journal");
} catch (e) {
// No guard message — it got past the guards to the journal open.
expect((e as Error).message).not.toMatch(/DASHBOARD_AUTH_TOKEN/);
expect((e as Error).message).not.toMatch(/refusing to bind/);
}
});
});