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";567function 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 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 expect(text).toContain('href="/admin?section=backups"');74 const backupPage = await (await browser.get("/admin?section=backups")).text();75 expect(backupPage).toContain("<h1>backups</h1>");76 expect(backupPage).toContain("Backups stay on this server.");77 expect(backupPage).not.toContain("Bucket reachable:");78 });7980 test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => {81 const browser = new Browser(inst.app, "198.51.100.1");82 expect((await browser.signIn("wrong")).status).toBe(401);83 expect(browser.cookies.has("kepr_session")).toBe(false);84 await browser.get("/sign-in");85 expect(86 (await browser.post("/sign-in", { password: PASSWORD }, "https://evil.example")).status,87 ).toBe(403);88 expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403);89 browser.cookies.delete("kepr_sign_in");90 expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403);91 });9293 test("five failures lock one address out for 15 minutes, even with the right password", async () => {94 const browser = new Browser(inst.app, "198.51.100.2");95 for (let i = 0; i < 5; i++) expect((await browser.signIn("nope")).status).toBe(401);96 const locked = await browser.signIn();97 expect(locked.status).toBe(429);98 expect(locked.headers.get("retry-after")).toBe("900");99 expect((await new Browser(inst.app, "198.51.100.3").signIn()).status).toBe(303);100 tick(901);101 expect((await browser.signIn()).status).toBe(303);102 });103104 test("failures are limited per address, with no global cap", async () => {105 for (let i = 0; i < 30; i++) {106 expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401);107 }108 expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303);109 });110111 test("every admin route: signed out is 401; a post needs the origin and the form token", async () => {112 const routes = adminRoutes(inst.app);113 expect(routes.length).toBeGreaterThan(1);114 const stranger = new Browser(inst.app);115 for (const { method, path } of routes) {116 const response =117 method === "GET" ? await stranger.get(path) : await stranger.post(path, { form: "x" });118 expect(`${method} ${path} ${response.status}`).toBe(`${method} ${path} 401`);119 }120 const browser = new Browser(inst.app);121 await browser.signIn();122 for (const { path } of routes.filter((route) => route.method === "POST")) {123 const foreign = await browser.post(path, {}, "https://evil.example");124 expect(`${path} ${foreign.status}`).toBe(`${path} 403`);125 const bare = await browser.post(path, {}, "");126 expect(`${path} ${bare.status}`).toBe(`${path} 403`);127 const tokenless = await browser.post(path, { form: "" });128 expect(`${path} ${tokenless.status}`).toBe(`${path} 403`);129 const other = new Browser(inst.app);130 await other.signIn();131 const foreignToken = await browser.post(path, { form: other.form });132 expect(`${path} ${foreignToken.status}`).toBe(`${path} 403`);133 }134 });135136 test("a session expires after 30 days", async () => {137 const browser = new Browser(inst.app);138 await browser.signIn();139 tick(30 * 86400 - 10);140 expect((await browser.get("/admin")).status).toBe(200);141 tick(11);142 expect((await browser.get("/admin")).status).toBe(401);143 });144145 test("signing out revokes the session on the server, not just the cookie", async () => {146 const browser = new Browser(inst.app);147 await browser.signIn();148 const token = browser.cookies.get("kepr_session") ?? "";149 const out = await browser.post("/admin/sign-out", {});150 expect(out.status).toBe(303);151 expect(browser.cookies.has("kepr_session")).toBe(false);152 browser.cookies.set("kepr_session", token);153 expect((await browser.get("/admin")).status).toBe(401);154 });155});