feat(E7.1): auth middleware contract + ROUTES declaration table
The other half of Slice 0. A single auth contract + a route table so every
endpoint declares its auth/CSRF requirement once — neighbor epics (E2/E3/E4)
register routes via ROUTES["<METHOD> /api/<x>"] = {method, requireAuth,
requireCSRF, handler} with no new auth code. Ships dark (flag default off →
byte-identical); the flag flips at the launch gate.
- src/server.ts auth infra: ENABLE_AUTH_MIDDLEWARE flag (env, default false),
DASHBOARD_AUTH_TOKEN env, mutable __authState test seam. authenticate(req,res,
route): flag off → no-op pass-through; flag on → requireAuth routes check a
Bearer/cookie token (constant-time compare via timingSafeEqual, length-padded).
Malformed Authorization → 401 bad auth header; missing/wrong token → 401
unauthorized; token unset → 500. requireCSRF is declared (enforced in E7.4).
Error responses reuse send() (x-content-type-options + cache-control).
- src/server.ts ROUTES table: every existing endpoint migrated — GET read routes
(/, /api/index, /api/status, /api/file, /api/entries, /api/autosync,
/api/autosync/conflicts, /api/autosync/last-push) → requireAuth:false; POST
mutation routes (/api/action, /api/push, /api/push-all, /api/link,
/api/refresh, /api/autosync, /api/autosync/revert) → requireAuth:true,
requireCSRF:true (now gating the previously-unguarded E1b mutation endpoints
when the flag flips). The dispatch loop consults the table before calling the
handler. Top-of-file comment documents the ROUTES[...] contract.
- tests/e7-1-auth.test.ts: 8 unit tests (flag off no-op; flag on read-passes /
no-token-401 / valid-bearer / wrong-401 / malformed-401 / cookie / unset-500).
- tests/e7-1-dispatch.test.ts: 11 integration tests via real startServer (empty
temp LevelDB) — flag off byte-identical (read+mutation pass, favicon 204, 404);
flag on (read no-token 200; mutation no-token/wrong/malformed 401; valid Bearer
→ handler 200/400; apply-gate after auth).
tsc clean; 180 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
223
src/server.ts
223
src/server.ts
@@ -9,6 +9,7 @@
|
||||
// unless the server was started with --apply.
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir, copyFile, access, stat, readdir, unlink, rename } from "node:fs/promises";
|
||||
import { watch, readdirSync, statSync, mkdirSync, existsSync, createWriteStream, type WriteStream, type FSWatcher } from "node:fs";
|
||||
import { join, dirname, relative, basename, extname } from "node:path";
|
||||
@@ -91,6 +92,88 @@ function sleepJitter(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, Math.round(ms * (0.8 + 0.4 * Math.random()))));
|
||||
}
|
||||
|
||||
// E7.1: auth middleware contract. Feature-flagged behind ENABLE_AUTH_MIDDLEWARE
|
||||
// (env, default false). When OFF, `authenticate` short-circuits to true — every
|
||||
// route is unguarded, byte-identical to pre-E7.1 (this ships dark; the flag flips
|
||||
// at the launch gate). When ON, routes annotated requireAuth:true check a
|
||||
// bearer/cookie token (constant-time compare against DASHBOARD_AUTH_TOKEN);
|
||||
// requireCSRF:true is declared but enforced in E7.4. Neighbor epics register
|
||||
// routes via ROUTES["/api/<x>"] = { method, requireAuth, requireCSRF, handler }
|
||||
// — no new auth code outside this module.
|
||||
const ENABLE_AUTH_MIDDLEWARE = process.env.ENABLE_AUTH_MIDDLEWARE === "true";
|
||||
const DASHBOARD_AUTH_TOKEN = process.env.DASHBOARD_AUTH_TOKEN ?? "";
|
||||
|
||||
/** E7.1: mutable auth state (the boot env values by default; tests override
|
||||
* `enabled`/`token` to exercise the flag-on path without re-importing). */
|
||||
export const __authState = { enabled: ENABLE_AUTH_MIDDLEWARE, token: DASHBOARD_AUTH_TOKEN };
|
||||
|
||||
/** E7.1: the auth-relevant subset of a route (the full Route type adds the handler). */
|
||||
export interface AuthRoute {
|
||||
requireAuth: boolean;
|
||||
requireCSRF: boolean;
|
||||
}
|
||||
|
||||
/** E7.1: a route in the ROUTES table. Handler signature is uniform so the
|
||||
* dispatch loop can call it generically after auth. Neighbor epics register
|
||||
* routes via ROUTES["/api/<x>"] = { method, requireAuth, requireCSRF, handler }. */
|
||||
export type RouteHandler = (state: State, req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void> | void;
|
||||
export interface Route extends AuthRoute {
|
||||
method: string;
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
/** Constant-time string compare (pads to equal length so the length doesn't leak). */
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a, "utf8");
|
||||
const bb = Buffer.from(b, "utf8");
|
||||
const len = Math.max(ba.length, bb.length);
|
||||
const pa = Buffer.alloc(len, 0);
|
||||
const pb = Buffer.alloc(len, 0);
|
||||
ba.copy(pa);
|
||||
bb.copy(pb);
|
||||
return timingSafeEqual(pa, pb);
|
||||
}
|
||||
|
||||
/** E7.1: read the auth token from `Authorization: Bearer <token>` or an
|
||||
* `auth_token` cookie (E7.2's first-run prompt sets the cookie). Returns the
|
||||
* token string or null. Throws on a malformed Authorization header. */
|
||||
function readAuthToken(req: IncomingMessage): string | null {
|
||||
const auth = req.headers["authorization"];
|
||||
if (auth !== undefined) {
|
||||
if (typeof auth !== "string") throw new Error("bad auth header");
|
||||
const m = auth.match(/^Bearer\s+(.+)$/i);
|
||||
if (!m) throw new Error("bad auth header");
|
||||
return m[1].trim();
|
||||
}
|
||||
// Cookie fallback (E7.2 sets it on first-run login).
|
||||
const cookie = req.headers["cookie"];
|
||||
if (typeof cookie === "string") {
|
||||
const mt = cookie.match(/(?:^|;\s*)auth_token=([^;]+)/);
|
||||
if (mt) return mt[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* E7.1: the auth middleware. Returns true to proceed, false after sending a 401
|
||||
* (missing/invalid token). When ENABLE_AUTH_MIDDLEWARE is off (default), it's a
|
||||
* no-op pass-through so behavior is byte-identical to pre-E7.1. When on, a route
|
||||
* with requireAuth:true requires a token matching DASHBOARD_AUTH_TOKEN. CSRF
|
||||
* (requireCSRF) is declared here but enforced in E7.4. `send` is used for error
|
||||
* responses so x-content-type-options + cache-control still apply.
|
||||
*/
|
||||
export async function authenticate(req: IncomingMessage, res: ServerResponse, route: AuthRoute): Promise<boolean> {
|
||||
if (!__authState.enabled) return true; // flag off → no-op pass-through
|
||||
if (!route.requireAuth) return true; // unguarded route (read endpoints stay open on-box)
|
||||
if (!__authState.token) { send(res, 500, { error: "auth required but DASHBOARD_AUTH_TOKEN is unset" }); return false; }
|
||||
let token: string | null;
|
||||
try { token = readAuthToken(req); }
|
||||
catch { send(res, 401, { error: "bad auth header" }); return false; }
|
||||
if (token === null) { send(res, 401, { error: "unauthorized" }); return false; }
|
||||
if (!constantTimeEqual(token, __authState.token)) { send(res, 401, { error: "unauthorized" }); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
journal: string;
|
||||
refinedDir: string;
|
||||
@@ -1294,81 +1377,78 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
await state.autosync.migrateFlagsSchemaVersion();
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
// Silence the favicon 404 noise every browser generates.
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204, { "content-length": "0", "cache-control": "max-age=86400" });
|
||||
return res.end();
|
||||
}
|
||||
// HEAD == GET headers (some clients/proxies probe with HEAD).
|
||||
const isHead = req.method === "HEAD";
|
||||
const method = isHead ? "GET" : (req.method ?? "GET");
|
||||
if (method === "GET" && url.pathname === "/") {
|
||||
// E7.1: the route table. Every endpoint is declared here with its auth/CSRF
|
||||
// requirement + handler; the dispatch loop consults the table before calling
|
||||
// the handler. Read routes are requireAuth:false; mutation (POST) routes are
|
||||
// requireAuth:true, requireCSRF:true. When ENABLE_AUTH_MIDDLEWARE is off
|
||||
// (default), `authenticate` no-ops so this is byte-identical to pre-E7.1.
|
||||
// Neighbor epics (E2/E3/E4) register new routes via:
|
||||
// ROUTES["<METHOD> /api/<x>"] = { method, requireAuth, requireCSRF, handler };
|
||||
const ROUTES: Record<string, Route> = {
|
||||
"GET /": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, req, res) => {
|
||||
const html = await readFile(DASHBOARD_PATH, "utf8");
|
||||
if (isHead) {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-length": String(Buffer.byteLength(html, "utf8")) });
|
||||
return res.end();
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
return send(res, 200, html);
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/index") {
|
||||
// Rebuild on every request: in apply mode the source files change after an
|
||||
// action, so a cached index would show stale recommendations; in dev mode the
|
||||
// <out> mirror changes after an action. Indexing is hash-only (no per-row
|
||||
// conversion), so this stays cheap.
|
||||
},
|
||||
},
|
||||
"GET /api/index": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
// Rebuild on every request (apply-mode sources change after an action; dev
|
||||
// mirror too). Hash-only indexing, so this stays cheap.
|
||||
handler: async (_s, _req, res) => {
|
||||
const ov = overlayDirs(state);
|
||||
state.index = await indexAll(state.db, state.cfg.ccDir, state.cfg.refinedDir, ov.refinedOverlay, ov.ccOverlay);
|
||||
return send(res, 200, state.index);
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/status") {
|
||||
return send(res, 200, { mode: state.cfg.mode, refinedDir: state.cfg.refinedDir, ccDir: state.cfg.ccDir, outDir: state.cfg.outDir });
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/file") {
|
||||
},
|
||||
},
|
||||
"GET /api/status": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, _req, res) => send(res, 200, { mode: state.cfg.mode, refinedDir: state.cfg.refinedDir, ccDir: state.cfg.ccDir, outDir: state.cfg.outDir }),
|
||||
},
|
||||
"GET /api/file": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, _req, res, url) => {
|
||||
const name = url.searchParams.get("name");
|
||||
if (!name) return send(res, 400, { error: "missing name" });
|
||||
return send(res, 200, await fileDetail(state, name));
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/action") {
|
||||
return handlePost(state, req, res);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/push") {
|
||||
return handlePush(state, req, res);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/push-all") {
|
||||
return handlePushAll(state, req, res);
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/entries") {
|
||||
return handleEntries(state, res);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/link") {
|
||||
return handleLink(state, req, res);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/refresh") {
|
||||
return handleRefresh(state, req, res);
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/autosync") {
|
||||
return send(res, 200, state.autosync.status());
|
||||
}
|
||||
// E1b.3: TOCTOU conflict list. Registered unguarded (E7's auth middleware
|
||||
// is not landed yet); E7 will gate this route when it ships.
|
||||
if (req.method === "GET" && url.pathname === "/api/autosync/conflicts") {
|
||||
return send(res, 200, { conflicts: state.autosync.conflicts });
|
||||
}
|
||||
// E1b.4: last-push record per uuid (drives the "Revert last push" button).
|
||||
if (req.method === "GET" && url.pathname === "/api/autosync/last-push") {
|
||||
},
|
||||
},
|
||||
"GET /api/entries": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, _req, res) => handleEntries(state, res),
|
||||
},
|
||||
"GET /api/autosync": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, _req, res) => send(res, 200, state.autosync.status()),
|
||||
},
|
||||
"GET /api/autosync/conflicts": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, _req, res) => send(res, 200, { conflicts: state.autosync.conflicts }),
|
||||
},
|
||||
"GET /api/autosync/last-push": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, _req, res, url) => {
|
||||
const uuid = url.searchParams.get("uuid") ?? "";
|
||||
const rec = state.autosync.lastPushes.get(uuid);
|
||||
if (!rec) return send(res, 404, { error: `no last push for ${uuid}` });
|
||||
return send(res, 200, rec);
|
||||
}
|
||||
// E1b.4: revert the last push (restore Foundry to the pre-push backup +
|
||||
// re-baseline). Registered unguarded (E7 not landed yet).
|
||||
if (req.method === "POST" && url.pathname === "/api/autosync/revert") {
|
||||
return handleRevert(state, req, res);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/autosync") {
|
||||
},
|
||||
},
|
||||
"POST /api/action": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handlePost(state, req, res) },
|
||||
"POST /api/push": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handlePush(state, req, res) },
|
||||
"POST /api/push-all": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handlePushAll(state, req, res) },
|
||||
"POST /api/link": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handleLink(state, req, res) },
|
||||
"POST /api/refresh": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handleRefresh(state, req, res) },
|
||||
"POST /api/autosync/revert": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handleRevert(state, req, res) },
|
||||
"POST /api/autosync": {
|
||||
method: "POST", requireAuth: true, requireCSRF: true,
|
||||
handler: async (_s, req, res) => {
|
||||
const body = await readJsonBody(req);
|
||||
if (body === null) return send(res, 400, { error: "bad json" });
|
||||
// E1b.5: apply-mode gating — refuse to enable in dev mode (hard floor).
|
||||
@@ -1381,9 +1461,26 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
} catch (e) {
|
||||
send(res, 500, { error: (e as Error).message });
|
||||
}
|
||||
return;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
// Silence the favicon 404 noise every browser generates.
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204, { "content-length": "0", "cache-control": "max-age=86400" });
|
||||
return res.end();
|
||||
}
|
||||
send(res, 404, { error: "not found" });
|
||||
// HEAD == GET headers (some clients/proxies probe with HEAD).
|
||||
const method = (req.method === "HEAD" ? "GET" : (req.method ?? "GET"));
|
||||
const route = ROUTES[`${method} ${url.pathname}`];
|
||||
if (!route) { send(res, 404, { error: "not found" }); return; }
|
||||
// E7.1: auth (flag-gated). 401 if a requireAuth route lacks a valid token.
|
||||
// CSRF (route.requireCSRF) is declared in the table; E7.4 enforces it.
|
||||
if (!(await authenticate(req, res, route))) return;
|
||||
await route.handler(state, req, res, url);
|
||||
} catch (e) {
|
||||
send(res, 500, { error: (e as Error).message });
|
||||
}
|
||||
|
||||
90
tests/e7-1-auth.test.ts
Normal file
90
tests/e7-1-auth.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// E7.1a — authenticate middleware unit tests.
|
||||
//
|
||||
// Exercises the auth middleware directly with mock req/res + the mutable
|
||||
// __authState test seam (so the flag can be flipped per-test without re-importing).
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { authenticate, __authState, type AuthRoute } from "../src/server.js";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
const REQUIRE_AUTH: AuthRoute = { requireAuth: true, requireCSRF: true };
|
||||
const OPEN: AuthRoute = { requireAuth: false, requireCSRF: false };
|
||||
|
||||
function mockReq(headers: Record<string, string> = {}): IncomingMessage {
|
||||
return { headers } as unknown as IncomingMessage;
|
||||
}
|
||||
function mockRes(): { res: ServerResponse; code: number; body: string } {
|
||||
const state = { code: 0, body: "" };
|
||||
const res = {
|
||||
writeHead: (c: number) => { state.code = c; },
|
||||
end: (b?: unknown) => { state.body = typeof b === "string" ? b : String(b ?? ""); },
|
||||
} as unknown as ServerResponse;
|
||||
return { res, get code() { return state.code; }, get body() { return state.body; } } as { res: ServerResponse; code: number; body: string };
|
||||
}
|
||||
|
||||
const savedEnabled = __authState.enabled;
|
||||
const savedToken = __authState.token;
|
||||
beforeEach(() => { __authState.enabled = false; __authState.token = ""; });
|
||||
afterEach(() => { __authState.enabled = savedEnabled; __authState.token = savedToken; });
|
||||
|
||||
describe("E7.1a authenticate (flag off = no-op pass-through)", () => {
|
||||
it("flag off → always returns true regardless of route or headers", async () => {
|
||||
__authState.enabled = false;
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq(), r.res, REQUIRE_AUTH)).toBe(true);
|
||||
expect(await authenticate(mockReq({ authorization: "Bearer wrong" }), r.res, REQUIRE_AUTH)).toBe(true);
|
||||
expect(await authenticate(mockReq(), r.res, OPEN)).toBe(true);
|
||||
expect(r.code).toBe(0); // no response sent
|
||||
});
|
||||
});
|
||||
|
||||
describe("E7.1a authenticate (flag on)", () => {
|
||||
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; });
|
||||
|
||||
it("requireAuth:false (read route) → proceeds without a token", async () => {
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq(), r.res, OPEN)).toBe(true);
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
it("requireAuth:true + no Authorization header → 401 unauthorized", async () => {
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq(), r.res, REQUIRE_AUTH)).toBe(false);
|
||||
expect(r.code).toBe(401);
|
||||
expect(r.body).toContain("unauthorized");
|
||||
});
|
||||
|
||||
it("requireAuth:true + valid Bearer token → proceeds", async () => {
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq({ authorization: "Bearer s3cret-token" }), r.res, REQUIRE_AUTH)).toBe(true);
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
it("requireAuth:true + wrong token → 401 unauthorized", async () => {
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq({ authorization: "Bearer wrong" }), r.res, REQUIRE_AUTH)).toBe(false);
|
||||
expect(r.code).toBe(401);
|
||||
expect(r.body).toContain("unauthorized");
|
||||
});
|
||||
|
||||
it("requireAuth:true + malformed Authorization (not Bearer) → 401 bad auth header", async () => {
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq({ authorization: "Basic abc" }), r.res, REQUIRE_AUTH)).toBe(false);
|
||||
expect(r.code).toBe(401);
|
||||
expect(r.body).toContain("bad auth header");
|
||||
});
|
||||
|
||||
it("requireAuth:true + token via cookie → proceeds (E7.2 first-run cookie)", async () => {
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq({ cookie: "other=val; auth_token=s3cret-token" }), r.res, REQUIRE_AUTH)).toBe(true);
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
it("requireAuth:true + DASHBOARD_AUTH_TOKEN unset → 500", async () => {
|
||||
__authState.token = "";
|
||||
const r = mockRes();
|
||||
expect(await authenticate(mockReq({ authorization: "Bearer x" }), r.res, REQUIRE_AUTH)).toBe(false);
|
||||
expect(r.code).toBe(500);
|
||||
expect(r.body).toContain("DASHBOARD_AUTH_TOKEN is unset");
|
||||
});
|
||||
});
|
||||
120
tests/e7-1-dispatch.test.ts
Normal file
120
tests/e7-1-dispatch.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// E7.1c — dispatch auth-gating integration tests.
|
||||
//
|
||||
// Starts the REAL server (startServer) on an ephemeral port with an empty
|
||||
// temp journal LevelDB, then hits it via fetch to verify the ROUTES table is
|
||||
// consulted: read routes pass without a token; mutation routes 401 without a
|
||||
// token / 401 with a wrong token / pass with a valid Bearer. Flag off → no auth.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import { ClassicLevel } from "classic-level";
|
||||
import { mkdtemp, mkdir, rm, writeFile } 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 realFetch = globalThis.fetch;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "e7dispatch-"));
|
||||
await mkdir(join(dir, "refined"), { recursive: true });
|
||||
await mkdir(join(dir, "cc"), { recursive: true });
|
||||
// Create an empty journal LevelDB so JournalDb.open (readOnly) succeeds.
|
||||
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", // dev mode: POST /api/autosync {enabled:true} → 400 apply-gate (no relay needed)
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
server = srv;
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "object" && !("port" in addr)) throw new Error("no port");
|
||||
baseURL = `http://127.0.0.1:${(addr as { port: number }).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
__authState.enabled = savedEnabled;
|
||||
__authState.token = savedToken;
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => { __authState.enabled = false; __authState.token = ""; });
|
||||
afterEach(() => { __authState.enabled = false; __authState.token = ""; });
|
||||
|
||||
async function req(path: string, init: RequestInit = {}): Promise<{ code: number; body: unknown }> {
|
||||
const r = await fetch(`${baseURL}${path}`, init);
|
||||
const text = await r.text();
|
||||
let body: unknown = text;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { /* non-JSON */ }
|
||||
return { code: r.status, body };
|
||||
}
|
||||
|
||||
describe("E7.1c dispatch (flag off — no auth, byte-identical)", () => {
|
||||
it("GET /api/status passes without a token", async () => {
|
||||
__authState.enabled = false;
|
||||
const r = await req("/api/status");
|
||||
expect(r.code).toBe(200);
|
||||
});
|
||||
it("POST /api/autosync (mutation) passes without a token (dev-mode apply-gate 400, not 401)", async () => {
|
||||
__authState.enabled = false;
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: true }) });
|
||||
expect(r.code).toBe(400); // apply-mode gate (dev), NOT an auth 401
|
||||
expect((r.body as { error: string }).error).toMatch(/requires --apply mode/);
|
||||
});
|
||||
it("/favicon.ico → 204", async () => {
|
||||
const r = await req("/favicon.ico");
|
||||
expect(r.code).toBe(204);
|
||||
});
|
||||
it("unknown route → 404", async () => {
|
||||
const r = await req("/api/nope");
|
||||
expect(r.code).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("E7.1c dispatch (flag on + token set)", () => {
|
||||
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; });
|
||||
|
||||
it("GET /api/status (read) passes WITHOUT a token", async () => {
|
||||
const r = await req("/api/status");
|
||||
expect(r.code).toBe(200);
|
||||
});
|
||||
it("GET /api/autosync (read) passes without a token", async () => {
|
||||
const r = await req("/api/autosync");
|
||||
expect(r.code).toBe(200);
|
||||
});
|
||||
it("POST /api/autosync (mutation) WITHOUT a token → 401", async () => {
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: false }) });
|
||||
expect(r.code).toBe(401);
|
||||
});
|
||||
it("POST /api/autosync with a WRONG token → 401", async () => {
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer wrong" }, body: JSON.stringify({ enabled: false }) });
|
||||
expect(r.code).toBe(401);
|
||||
});
|
||||
it("POST /api/autosync with a malformed Authorization → 401 bad auth header", async () => {
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Basic abc" }, body: JSON.stringify({ enabled: false }) });
|
||||
expect(r.code).toBe(401);
|
||||
expect((r.body as { error: string }).error).toMatch(/bad auth header/);
|
||||
});
|
||||
it("POST /api/autosync with a VALID Bearer → reaches the handler (200, enabled:false)", async () => {
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer s3cret-token" }, body: JSON.stringify({ enabled: false }) });
|
||||
expect(r.code).toBe(200); // auth passed → setEnabled(false) → 200
|
||||
});
|
||||
it("POST /api/autosync {enabled:true} in dev mode with a valid token → 400 apply-gate (auth passed, mode check next)", async () => {
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer s3cret-token" }, body: JSON.stringify({ enabled: true }) });
|
||||
expect(r.code).toBe(400); // auth passed → apply-mode gate
|
||||
expect((r.body as { error: string }).error).toMatch(/requires --apply mode/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user