admin.test.ts

9.3 KB · raw

1import type { Database } from "bun:sqlite";2import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";3import { mkdtemp } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import type { Hono } from "hono";7import { openBucket } from "../src/bucket/reader.ts";8import { syncAll } from "../src/bucket/sync.ts";9import { createApp } from "../src/server/app.ts";10import type { Config } from "../src/server/config.ts";11import { openKeprDb } from "../src/server/db.ts";12import { makeRepos, writeRelease } from "./fixture.ts";1314const ORIGIN = "https://kepr.example";15const PASSWORD = "correct horse battery staple";16// The env Bun.serve passes: its requestIP gives the socket peer.17const peer = (address: string) => ({ requestIP: () => ({ address }) });1819/** A browser against one app: a cookie jar, and the form token of the last admin page read. */20class Browser {21	cookies = new Map<string, string>();22	form = "";23	constructor(24		readonly app: Hono,25		readonly address = "203.0.113.5",26	) {}2728	async request(path: string, init: RequestInit = {}): Promise<Response> {29		const headers = new Headers(init.headers);30		headers.set("cookie", [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; "));31		const response = await this.app.request(path, { ...init, headers }, peer(this.address));32		for (const line of response.headers.getSetCookie()) {33			const [pair = "", ...attributes] = line.split(";");34			const [name = "", value = ""] = pair.split("=");35			if (attributes.some((a) => /max-age=0/i.test(a.trim())) || value === "") {36				this.cookies.delete(name);37			} else this.cookies.set(name, value);38		}39		return response;40	}4142	async get(path: string): Promise<Response> {43		const response = await this.request(path);44		const text = await response.clone().text();45		this.form = /name="form" value="([^"]+)"/.exec(text)?.[1] ?? this.form;46		return response;47	}4849	post(path: string, fields: Record<string, string> | FormData, origin = ORIGIN) {50		const body = fields instanceof FormData ? fields : new URLSearchParams(fields);51		if (!(fields instanceof FormData) && !("form" in fields)) body.set("form", this.form);52		else if (fields instanceof FormData && !fields.has("form")) body.set("form", this.form);53		return this.request(path, { method: "POST", headers: origin ? { origin } : {}, body });54	}5556	async signIn(password = PASSWORD): Promise<Response> {57		await this.get("/sign-in");58		const response = await this.post("/sign-in", { password });59		if (response.status === 303) await this.get("/admin");60		return response;61	}62}6364type Instance = { app: Hono; kepr: Database; dataDir: string; config: Config };6566async function instance(bucketDir: string, dataDir?: string): Promise<Instance> {67	const dir = dataDir ?? (await mkdtemp(join(tmpdir(), "kepr-admin-")));68	const kepr = openKeprDb(dir);69	const bucket = { kind: "folder" as const, path: bucketDir };70	await syncAll({71		bucket: openBucket(bucket),72		kepr,73		mirrorDir: join(dir, "mirror"),74		tarballDir: join(dir, "tarballs"),75	});76	const config: Config = {77		domain: "kepr.example",78		dataDir: dir,79		adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),80		bucket,81	};82	return { app: createApp(config, kepr), kepr, dataDir: dir, config };83}8485// Every admin route with sample parameters, from the app's own route table.86function adminRoutes(app: Hono): { method: string; path: string }[] {87	return app.routes88		.filter((route) => route.path.startsWith("/admin") && route.method !== "ALL")89		.map((route) => ({90			method: route.method,91			path: route.path92				.replace(":repo", "app")93				.replace(/:n\{[^}]+\}/, "1")94				.replace(/:version(\{[^}]+\})?/, "1.0.0"),95		}));96}9798describe("admin sign-in, sessions and the guard", () => {99	let inst: Instance;100	let clock = Date.parse("2026-09-26T12:00:00Z");101	const tick = (seconds: number) => {102		clock += seconds * 1000;103		setSystemTime(new Date(clock));104	};105106	beforeAll(async () => {107		setSystemTime(new Date(clock));108		const repos = await makeRepos(["app"]);109		await writeRelease(repos.bucketDir, "app", {110			version: "1.0.0",111			save: repos.saves.app as string,112			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],113		});114		inst = await instance(repos.bucketDir);115	});116	afterAll(() => setSystemTime());117118	test("the sign-in page sets a strict double-submit cookie and is never cached", async () => {119		const response = await new Browser(inst.app).request("/sign-in");120		expect(response.status).toBe(200);121		expect(response.headers.get("cache-control")).toBe("no-store");122		const cookie = response.headers.getSetCookie()[0] ?? "";123		expect(cookie).toMatch(124			/^kepr_sign_in=[0-9a-f]{64}; Path=\/; HttpOnly; Secure; SameSite=Strict$/,125		);126	});127128	test("the right password sets a 30-day strict session and opens the console", async () => {129		const browser = new Browser(inst.app);130		await browser.get("/sign-in");131		const response = await browser.post("/sign-in", { password: PASSWORD });132		expect(response.status).toBe(303);133		expect(response.headers.get("location")).toBe("/admin");134		const cookie = response.headers.getSetCookie().find((c) => c.startsWith("kepr_session="));135		expect(cookie).toMatch(136			/^kepr_session=[0-9a-f]{64}; Max-Age=2592000; Path=\/; HttpOnly; Secure; SameSite=Strict$/,137		);138		// Only the token's hash is stored.139		const token = browser.cookies.get("kepr_session") ?? "";140		const stored = inst.kepr141			.query<{ token_hash: string }, []>("SELECT token_hash FROM sessions")142			.all();143		expect(stored.some((row) => row.token_hash === token)).toBe(false);144		const admin = await browser.get("/admin");145		expect(admin.status).toBe(200);146		expect(admin.headers.get("cache-control")).toBe("no-store");147		const text = await admin.text();148		expect(text).toContain("Bucket reachable: 1 repos.");149		expect(text).toContain("format ok");150		expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/);151	});152153	test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => {154		const browser = new Browser(inst.app, "198.51.100.1");155		expect((await browser.signIn("wrong")).status).toBe(401);156		expect(browser.cookies.has("kepr_session")).toBe(false);157		await browser.get("/sign-in");158		expect(159			(await browser.post("/sign-in", { password: PASSWORD }, "https://evil.example")).status,160		).toBe(403);161		expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403);162		browser.cookies.delete("kepr_sign_in");163		expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403);164	});165166	test("five failures lock one address out for 15 minutes, even with the right password", async () => {167		const browser = new Browser(inst.app, "198.51.100.2");168		for (let i = 0; i < 5; i++) expect((await browser.signIn("nope")).status).toBe(401);169		const locked = await browser.signIn();170		expect(locked.status).toBe(429);171		expect(locked.headers.get("retry-after")).toBe("900");172		expect((await new Browser(inst.app, "198.51.100.3").signIn()).status).toBe(303);173		tick(901);174		expect((await browser.signIn()).status).toBe(303);175	});176177	test("thirty failures from anywhere back every sign-in off until the window passes", async () => {178		for (let i = 0; i < 30; i++) {179			expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401);180		}181		expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(429);182		tick(901);183		expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303);184	});185186	test("every admin route: signed out is 401; a post needs the origin and the form token", async () => {187		const routes = adminRoutes(inst.app);188		expect(routes.length).toBeGreaterThan(1);189		const stranger = new Browser(inst.app);190		for (const { method, path } of routes) {191			const response =192				method === "GET" ? await stranger.get(path) : await stranger.post(path, { form: "x" });193			expect(`${method} ${path} ${response.status}`).toBe(`${method} ${path} 401`);194		}195		const browser = new Browser(inst.app);196		await browser.signIn();197		for (const { path } of routes.filter((route) => route.method === "POST")) {198			const foreign = await browser.post(path, {}, "https://evil.example");199			expect(`${path} ${foreign.status}`).toBe(`${path} 403`);200			const bare = await browser.post(path, {}, "");201			expect(`${path} ${bare.status}`).toBe(`${path} 403`);202			const tokenless = await browser.post(path, { form: "" });203			expect(`${path} ${tokenless.status}`).toBe(`${path} 403`);204			const other = new Browser(inst.app);205			await other.signIn();206			const foreignToken = await browser.post(path, { form: other.form });207			expect(`${path} ${foreignToken.status}`).toBe(`${path} 403`);208		}209	});210211	test("a session expires after 30 days", async () => {212		const browser = new Browser(inst.app);213		await browser.signIn();214		tick(30 * 86400 - 10);215		expect((await browser.get("/admin")).status).toBe(200);216		tick(11);217		expect((await browser.get("/admin")).status).toBe(401);218	});219220	test("signing out revokes the session on the server, not just the cookie", async () => {221		const browser = new Browser(inst.app);222		await browser.signIn();223		const token = browser.cookies.get("kepr_session") ?? "";224		const out = await browser.post("/admin/sign-out", {});225		expect(out.status).toBe(303);226		expect(browser.cookies.has("kepr_session")).toBe(false);227		browser.cookies.set("kepr_session", token);228		expect((await browser.get("/admin")).status).toBe(401);229	});230});