admin.test.ts

6.3 KB · raw

1import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2import type { Hono } from "hono";3import { Browser, type Instance, instance, PASSWORD } from "./browser.ts";4import { makeRepos, writeRelease } from "./fixture.ts";56// Every admin route with sample parameters, from the app's own route table.7function adminRoutes(app: Hono): { method: string; path: string }[] {8	return app.routes9		.filter((route) => route.path.startsWith("/admin") && route.method !== "ALL")10		.map((route) => ({11			method: route.method,12			path: route.path13				.replace(":repo", "app")14				.replace(/:n\{[^}]+\}/, "1")15				.replace(/:version(\{[^}]+\})?/, "1.0.0"),16		}));17}1819describe("admin sign-in, sessions and the guard", () => {20	let inst: Instance;21	let clock = Date.parse("2026-09-26T12:00:00Z");22	const tick = (seconds: number) => {23		clock += seconds * 1000;24		setSystemTime(new Date(clock));25	};2627	beforeAll(async () => {28		setSystemTime(new Date(clock));29		const repos = await makeRepos(["app"]);30		await writeRelease(repos.bucketDir, "app", {31			version: "1.0.0",32			save: repos.saves.app as string,33			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],34		});35		inst = await instance(repos.bucketDir);36	});37	afterAll(() => setSystemTime());3839	test("the sign-in page sets a strict double-submit cookie and is never cached", async () => {40		const response = await new Browser(inst.app).request("/sign-in");41		expect(response.status).toBe(200);42		expect(response.headers.get("cache-control")).toBe("no-store");43		expect(response.headers.get("referrer-policy")).toBe("same-origin");44		const cookie = response.headers.getSetCookie()[0] ?? "";45		expect(cookie).toMatch(46			/^kepr_sign_in=[0-9a-f]{64}; Path=\/; HttpOnly; Secure; SameSite=Strict$/,47		);48	});4950	test("the right password sets a 30-day strict session and opens the console", async () => {51		const browser = new Browser(inst.app);52		await browser.get("/sign-in");53		const response = await browser.post("/sign-in", { password: PASSWORD });54		expect(response.status).toBe(303);55		expect(response.headers.get("location")).toBe("/admin");56		const cookie = response.headers.getSetCookie().find((c) => c.startsWith("kepr_session="));57		expect(cookie).toMatch(58			/^kepr_session=[0-9a-f]{64}; Max-Age=2592000; Path=\/; HttpOnly; Secure; SameSite=Strict$/,59		);60		// Only the token's hash is stored.61		const token = browser.cookies.get("kepr_session") ?? "";62		const stored = inst.kepr63			.query<{ token_hash: string }, []>("SELECT token_hash FROM sessions")64			.all();65		expect(stored.some((row) => row.token_hash === token)).toBe(false);66		const admin = await browser.get("/admin");67		expect(admin.status).toBe(200);68		expect(admin.headers.get("cache-control")).toBe("no-store");69		const text = await admin.text();70		expect(text).toContain("Bucket reachable: 1 repo.");71		expect(text).toContain("format ok");72		expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/);73	});7475	test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => {76		const browser = new Browser(inst.app, "198.51.100.1");77		expect((await browser.signIn("wrong")).status).toBe(401);78		expect(browser.cookies.has("kepr_session")).toBe(false);79		await browser.get("/sign-in");80		expect(81			(await browser.post("/sign-in", { password: PASSWORD }, "https://evil.example")).status,82		).toBe(403);83		expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403);84		browser.cookies.delete("kepr_sign_in");85		expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403);86	});8788	test("five failures lock one address out for 15 minutes, even with the right password", async () => {89		const browser = new Browser(inst.app, "198.51.100.2");90		for (let i = 0; i < 5; i++) expect((await browser.signIn("nope")).status).toBe(401);91		const locked = await browser.signIn();92		expect(locked.status).toBe(429);93		expect(locked.headers.get("retry-after")).toBe("900");94		expect((await new Browser(inst.app, "198.51.100.3").signIn()).status).toBe(303);95		tick(901);96		expect((await browser.signIn()).status).toBe(303);97	});9899	test("failures are limited per address, with no global cap", async () => {100		for (let i = 0; i < 30; i++) {101			expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401);102		}103		expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303);104	});105106	test("every admin route: signed out is 401; a post needs the origin and the form token", async () => {107		const routes = adminRoutes(inst.app);108		expect(routes.length).toBeGreaterThan(1);109		const stranger = new Browser(inst.app);110		for (const { method, path } of routes) {111			const response =112				method === "GET" ? await stranger.get(path) : await stranger.post(path, { form: "x" });113			expect(`${method} ${path} ${response.status}`).toBe(`${method} ${path} 401`);114		}115		const browser = new Browser(inst.app);116		await browser.signIn();117		for (const { path } of routes.filter((route) => route.method === "POST")) {118			const foreign = await browser.post(path, {}, "https://evil.example");119			expect(`${path} ${foreign.status}`).toBe(`${path} 403`);120			const bare = await browser.post(path, {}, "");121			expect(`${path} ${bare.status}`).toBe(`${path} 403`);122			const tokenless = await browser.post(path, { form: "" });123			expect(`${path} ${tokenless.status}`).toBe(`${path} 403`);124			const other = new Browser(inst.app);125			await other.signIn();126			const foreignToken = await browser.post(path, { form: other.form });127			expect(`${path} ${foreignToken.status}`).toBe(`${path} 403`);128		}129	});130131	test("a session expires after 30 days", async () => {132		const browser = new Browser(inst.app);133		await browser.signIn();134		tick(30 * 86400 - 10);135		expect((await browser.get("/admin")).status).toBe(200);136		tick(11);137		expect((await browser.get("/admin")).status).toBe(401);138	});139140	test("signing out revokes the session on the server, not just the cookie", async () => {141		const browser = new Browser(inst.app);142		await browser.signIn();143		const token = browser.cookies.get("kepr_session") ?? "";144		const out = await browser.post("/admin/sign-out", {});145		expect(out.status).toBe(303);146		expect(browser.cookies.has("kepr_session")).toBe(false);147		browser.cookies.set("kepr_session", token);148		expect((await browser.get("/admin")).status).toBe(401);149	});150});