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";678function 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 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 expect(text).toContain("Bucket reachable: 1 repo.");72 expect(text).toContain("format ok");73 expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/);74 expect(text).toContain('href="/admin?section=backups"');75 const backupPage = await (await browser.get("/admin?section=backups")).text();76 expect(backupPage).toContain("<h1>backups</h1>");77 expect(backupPage).toContain("Sessions and bucket credentials are not included");78 expect(backupPage).not.toContain("Bucket reachable:");79 });8081 test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => {82 const browser = new Browser(inst.app, "198.51.100.1");83 expect((await browser.signIn("wrong")).status).toBe(401);84 expect(browser.cookies.has("kepr_session")).toBe(false);85 await browser.get("/sign-in");86 expect(87 (await browser.post("/sign-in", { password: PASSWORD }, "https://evil.example")).status,88 ).toBe(403);89 expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403);90 browser.cookies.delete("kepr_sign_in");91 expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403);92 });9394 test("plain http is the site's origin only when the domain is localhost", async () => {95 const plain = new Browser(inst.app, "198.51.100.4");96 await plain.get("/sign-in");97 const http = "http://kepr.example";98 expect((await plain.post("/sign-in", { password: PASSWORD }, http)).status).toBe(403);99 const local = createApp({ ...inst.config, domain: "localhost:3000" }, inst.kepr);100 const browser = new Browser(local, "198.51.100.4");101 const origin = "http://localhost:3000";102 await browser.get("/sign-in");103 expect((await browser.post("/sign-in", { password: PASSWORD }, origin)).status).toBe(303);104 await browser.get("/admin");105 expect((await browser.post("/admin/sync", {}, origin)).status).toBe(303);106 expect((await browser.post("/admin/sync", {}, "http://localhost:4000")).status).toBe(403);107 });108109 test("five failures lock one address out for 15 minutes, even with the right password", async () => {110 const browser = new Browser(inst.app, "198.51.100.2");111 for (let i = 0; i < 5; i++) expect((await browser.signIn("nope")).status).toBe(401);112 const locked = await browser.signIn();113 expect(locked.status).toBe(429);114 expect(locked.headers.get("retry-after")).toBe("900");115 expect((await new Browser(inst.app, "198.51.100.3").signIn()).status).toBe(303);116 tick(901);117 expect((await browser.signIn()).status).toBe(303);118 });119120 test("failures are limited per address, with no global cap", async () => {121 for (let i = 0; i < 30; i++) {122 expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401);123 }124 expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303);125 });126127 test("every admin route: signed out is 401; a post needs the origin and the form token", async () => {128 const routes = adminRoutes(inst.app);129 expect(routes.length).toBeGreaterThan(1);130 const stranger = new Browser(inst.app);131 for (const { method, path } of routes) {132 const response =133 method === "GET" ? await stranger.get(path) : await stranger.post(path, { form: "x" });134 expect(`${method} ${path} ${response.status}`).toBe(`${method} ${path} 401`);135 }136 const browser = new Browser(inst.app);137 await browser.signIn();138 for (const { path } of routes.filter((route) => route.method === "POST")) {139 const foreign = await browser.post(path, {}, "https://evil.example");140 expect(`${path} ${foreign.status}`).toBe(`${path} 403`);141 const bare = await browser.post(path, {}, "");142 expect(`${path} ${bare.status}`).toBe(`${path} 403`);143 const tokenless = await browser.post(path, { form: "" });144 expect(`${path} ${tokenless.status}`).toBe(`${path} 403`);145 const other = new Browser(inst.app);146 await other.signIn();147 const foreignToken = await browser.post(path, { form: other.form });148 expect(`${path} ${foreignToken.status}`).toBe(`${path} 403`);149 }150 });151152 test("a session expires after 30 days", async () => {153 const browser = new Browser(inst.app);154 await browser.signIn();155 tick(30 * 86400 - 10);156 expect((await browser.get("/admin")).status).toBe(200);157 tick(11);158 expect((await browser.get("/admin")).status).toBe(401);159 });160161 test("signing out revokes the session on the server, not just the cookie", async () => {162 const browser = new Browser(inst.app);163 await browser.signIn();164 const token = browser.cookies.get("kepr_session") ?? "";165 const out = await browser.post("/admin/sign-out", {});166 expect(out.status).toBe(303);167 expect(browser.cookies.has("kepr_session")).toBe(false);168 browser.cookies.set("kepr_session", token);169 expect((await browser.get("/admin")).status).toBe(401);170 });171});