admin.test.ts

8.1 KB · raw

1import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2import type { Hono } from "hono";3import { createApp } from "../src/server/app.ts";4import { Browser, type Instance, instance, PASSWORD } from "./browser.ts";5import { makeRepos, writeRelease } from "./fixture.ts";67// Every admin route with sample parameters, from the app's own route table.8function adminRoutes(app: Hono): { method: string; path: string }[] {9	return app.routes10		.filter((route) => route.path.startsWith("/admin") && route.method !== "ALL")11		.map((route) => ({12			method: route.method,13			path: route.path14				.replace(":repo", "app")15				.replace(/:n\{[^}]+\}/, "1")16				.replace(/:version(\{[^}]+\})?/, "1.0.0"),17		}));18}1920describe("admin sign-in, sessions and the guard", () => {21	let inst: Instance;22	let clock = Date.parse("2026-09-26T12:00:00Z");23	const tick = (seconds: number) => {24		clock += seconds * 1000;25		setSystemTime(new Date(clock));26	};2728	beforeAll(async () => {29		setSystemTime(new Date(clock));30		const repos = await makeRepos(["app"]);31		await writeRelease(repos.bucketDir, "app", {32			version: "1.0.0",33			save: repos.saves.app as string,34			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],35		});36		inst = await instance(repos.bucketDir);37	});38	afterAll(() => setSystemTime());3940	test("the sign-in page sets a strict double-submit cookie and is never cached", async () => {41		const response = await new Browser(inst.app).request("/sign-in");42		expect(response.status).toBe(200);43		expect(response.headers.get("cache-control")).toBe("no-store");44		expect(response.headers.get("referrer-policy")).toBe("same-origin");45		const cookie = response.headers.getSetCookie()[0] ?? "";46		expect(cookie).toMatch(47			/^kepr_sign_in=[0-9a-f]{64}; Path=\/; HttpOnly; Secure; SameSite=Strict$/,48		);49	});5051	test("the right password sets a 30-day strict session and opens the console", async () => {52		const browser = new Browser(inst.app);53		await browser.get("/sign-in");54		const response = await browser.post("/sign-in", { password: PASSWORD });55		expect(response.status).toBe(303);56		expect(response.headers.get("location")).toBe("/admin");57		const cookie = response.headers.getSetCookie().find((c) => c.startsWith("kepr_session="));58		expect(cookie).toMatch(59			/^kepr_session=[0-9a-f]{64}; Max-Age=2592000; Path=\/; HttpOnly; Secure; SameSite=Strict$/,60		);61		// Only the token's hash is stored.62		const token = browser.cookies.get("kepr_session") ?? "";63		const stored = inst.kepr64			.query<{ token_hash: string }, []>("SELECT token_hash FROM sessions")65			.all();66		expect(stored.some((row) => row.token_hash === token)).toBe(false);67		const admin = await browser.get("/admin");68		expect(admin.status).toBe(200);69		expect(admin.headers.get("cache-control")).toBe("no-store");70		const text = await admin.text();71		// As the sync recorded it: the console never waits on the bucket.72		expect(text).toMatch(/Bucket read by the sync (just now|\d+ \w+ ago): 1 repo\./);73		expect(text).toContain("format ok");74		// A repo gone from the bucket keeps its row, but the last listing didn't find it.75		inst.kepr.query("INSERT INTO repos (name) VALUES ('gone')").run();76		expect(await (await browser.get("/admin")).text()).toMatch(/: 1 repo\./);77		inst.kepr.query("DELETE FROM repos WHERE name = 'gone'").run();78		inst.kepr.query("INSERT INTO settings (key, value) VALUES ('bucket_error', 'timed out')").run();79		expect(await (await browser.get("/admin")).text()).toContain(80			"Bucket unreachable at the last sync: timed out",81		);82		inst.kepr.query("DELETE FROM settings WHERE key = 'bucket_error'").run();83		expect(text).toMatch(/Running kepr 3\.\d+\.\d+: [\d,]+ lines of TypeScript/);84		expect(text).toContain('href="/admin?section=backups"');85		const backupPage = await (await browser.get("/admin?section=backups")).text();86		expect(backupPage).toContain("<h1>backups</h1>");87		expect(backupPage).toContain("Sessions and bucket credentials are not included");88		expect(backupPage).not.toContain("Bucket read by the sync");89	});9091	test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => {92		const browser = new Browser(inst.app, "198.51.100.1");93		expect((await browser.signIn("wrong")).status).toBe(401);94		expect(browser.cookies.has("kepr_session")).toBe(false);95		await browser.get("/sign-in");96		expect(97			(await browser.post("/sign-in", { password: PASSWORD }, "https://evil.example")).status,98		).toBe(403);99		expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403);100		browser.cookies.delete("kepr_sign_in");101		expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403);102	});103104	test("plain http is the site's origin only when the domain is localhost", async () => {105		const plain = new Browser(inst.app, "198.51.100.4");106		await plain.get("/sign-in");107		const http = "http://kepr.example";108		expect((await plain.post("/sign-in", { password: PASSWORD }, http)).status).toBe(403);109		const local = createApp({ ...inst.config, domain: "localhost:3000" }, inst.kepr);110		const browser = new Browser(local, "198.51.100.4");111		const origin = "http://localhost:3000";112		await browser.get("/sign-in");113		expect((await browser.post("/sign-in", { password: PASSWORD }, origin)).status).toBe(303);114		await browser.get("/admin");115		expect((await browser.post("/admin/sync", {}, origin)).status).toBe(303);116		expect((await browser.post("/admin/sync", {}, "http://localhost:4000")).status).toBe(403);117	});118119	test("five failures lock one address out for 15 minutes, even with the right password", async () => {120		const browser = new Browser(inst.app, "198.51.100.2");121		for (let i = 0; i < 5; i++) expect((await browser.signIn("nope")).status).toBe(401);122		const locked = await browser.signIn();123		expect(locked.status).toBe(429);124		expect(locked.headers.get("retry-after")).toBe("900");125		expect((await new Browser(inst.app, "198.51.100.3").signIn()).status).toBe(303);126		tick(901);127		expect((await browser.signIn()).status).toBe(303);128	});129130	test("failures are limited per address, with no global cap", async () => {131		for (let i = 0; i < 30; i++) {132			expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401);133		}134		expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303);135	});136137	test("every admin route: signed out is 401; a post needs the origin and the form token", async () => {138		const routes = adminRoutes(inst.app);139		expect(routes.length).toBeGreaterThan(1);140		const stranger = new Browser(inst.app);141		for (const { method, path } of routes) {142			const response =143				method === "GET" ? await stranger.get(path) : await stranger.post(path, { form: "x" });144			expect(`${method} ${path} ${response.status}`).toBe(`${method} ${path} 401`);145		}146		const browser = new Browser(inst.app);147		await browser.signIn();148		for (const { path } of routes.filter((route) => route.method === "POST")) {149			const foreign = await browser.post(path, {}, "https://evil.example");150			expect(`${path} ${foreign.status}`).toBe(`${path} 403`);151			const bare = await browser.post(path, {}, "");152			expect(`${path} ${bare.status}`).toBe(`${path} 403`);153			const tokenless = await browser.post(path, { form: "" });154			expect(`${path} ${tokenless.status}`).toBe(`${path} 403`);155			const other = new Browser(inst.app);156			await other.signIn();157			const foreignToken = await browser.post(path, { form: other.form });158			expect(`${path} ${foreignToken.status}`).toBe(`${path} 403`);159		}160	});161162	test("a session expires after 30 days", async () => {163		const browser = new Browser(inst.app);164		await browser.signIn();165		tick(30 * 86400 - 10);166		expect((await browser.get("/admin")).status).toBe(200);167		tick(11);168		expect((await browser.get("/admin")).status).toBe(401);169	});170171	test("signing out revokes the session on the server, not just the cookie", async () => {172		const browser = new Browser(inst.app);173		await browser.signIn();174		const token = browser.cookies.get("kepr_session") ?? "";175		const out = await browser.post("/admin/sign-out", {});176		expect(out.status).toBe(303);177		expect(browser.cookies.has("kepr_session")).toBe(false);178		browser.cookies.set("kepr_session", token);179		expect((await browser.get("/admin")).status).toBe(401);180	});181});