import type { Database } from "bun:sqlite"; import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Hono } from "hono"; import { openBucket } from "../src/bucket/reader.ts"; import { syncAll } from "../src/bucket/sync.ts"; import { createApp } from "../src/server/app.ts"; import type { Config } from "../src/server/config.ts"; import { openKeprDb } from "../src/server/db.ts"; import { makeRepos, writeRelease } from "./fixture.ts"; const ORIGIN = "https://kepr.example"; const PASSWORD = "correct horse battery staple"; // The env Bun.serve passes: its requestIP gives the socket peer. const peer = (address: string) => ({ requestIP: () => ({ address }) }); /** A browser against one app: a cookie jar, and the form token of the last admin page read. */ class Browser { cookies = new Map(); form = ""; constructor( readonly app: Hono, readonly address = "203.0.113.5", ) {} async request(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); headers.set("cookie", [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ")); const response = await this.app.request(path, { ...init, headers }, peer(this.address)); for (const line of response.headers.getSetCookie()) { const [pair = "", ...attributes] = line.split(";"); const [name = "", value = ""] = pair.split("="); if (attributes.some((a) => /max-age=0/i.test(a.trim())) || value === "") { this.cookies.delete(name); } else this.cookies.set(name, value); } return response; } async get(path: string): Promise { const response = await this.request(path); const text = await response.clone().text(); this.form = /name="form" value="([^"]+)"/.exec(text)?.[1] ?? this.form; return response; } post(path: string, fields: Record | FormData, origin = ORIGIN) { const body = fields instanceof FormData ? fields : new URLSearchParams(fields); if (!(fields instanceof FormData) && !("form" in fields)) body.set("form", this.form); else if (fields instanceof FormData && !fields.has("form")) body.set("form", this.form); return this.request(path, { method: "POST", headers: origin ? { origin } : {}, body }); } async signIn(password = PASSWORD): Promise { await this.get("/sign-in"); const response = await this.post("/sign-in", { password }); if (response.status === 303) await this.get("/admin"); return response; } } type Instance = { app: Hono; kepr: Database; dataDir: string; config: Config }; async function instance(bucketDir: string, dataDir?: string): Promise { const dir = dataDir ?? (await mkdtemp(join(tmpdir(), "kepr-admin-"))); const kepr = openKeprDb(dir); const bucket = { kind: "folder" as const, path: bucketDir }; await syncAll({ bucket: openBucket(bucket), kepr, mirrorDir: join(dir, "mirror"), tarballDir: join(dir, "tarballs"), }); const config: Config = { domain: "kepr.example", dataDir: dir, adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"), bucket, }; return { app: createApp(config, kepr), kepr, dataDir: dir, config }; } // Every admin route with sample parameters, from the app's own route table. function adminRoutes(app: Hono): { method: string; path: string }[] { return app.routes .filter((route) => route.path.startsWith("/admin") && route.method !== "ALL") .map((route) => ({ method: route.method, path: route.path .replace(":repo", "app") .replace(/:n\{[^}]+\}/, "1") .replace(/:version(\{[^}]+\})?/, "1.0.0"), })); } describe("admin sign-in, sessions and the guard", () => { let inst: Instance; let clock = Date.parse("2026-09-26T12:00:00Z"); const tick = (seconds: number) => { clock += seconds * 1000; setSystemTime(new Date(clock)); }; beforeAll(async () => { setSystemTime(new Date(clock)); const repos = await makeRepos(["app"]); await writeRelease(repos.bucketDir, "app", { version: "1.0.0", save: repos.saves.app as string, files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }], }); inst = await instance(repos.bucketDir); }); afterAll(() => setSystemTime()); test("the sign-in page sets a strict double-submit cookie and is never cached", async () => { const response = await new Browser(inst.app).request("/sign-in"); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); const cookie = response.headers.getSetCookie()[0] ?? ""; expect(cookie).toMatch( /^kepr_sign_in=[0-9a-f]{64}; Path=\/; HttpOnly; Secure; SameSite=Strict$/, ); }); test("the right password sets a 30-day strict session and opens the console", async () => { const browser = new Browser(inst.app); await browser.get("/sign-in"); const response = await browser.post("/sign-in", { password: PASSWORD }); expect(response.status).toBe(303); expect(response.headers.get("location")).toBe("/admin"); const cookie = response.headers.getSetCookie().find((c) => c.startsWith("kepr_session=")); expect(cookie).toMatch( /^kepr_session=[0-9a-f]{64}; Max-Age=2592000; Path=\/; HttpOnly; Secure; SameSite=Strict$/, ); // Only the token's hash is stored. const token = browser.cookies.get("kepr_session") ?? ""; const stored = inst.kepr .query<{ token_hash: string }, []>("SELECT token_hash FROM sessions") .all(); expect(stored.some((row) => row.token_hash === token)).toBe(false); const admin = await browser.get("/admin"); expect(admin.status).toBe(200); expect(admin.headers.get("cache-control")).toBe("no-store"); const text = await admin.text(); expect(text).toContain("Bucket reachable: 1 repos."); expect(text).toContain("format ok"); expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/); }); test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => { const browser = new Browser(inst.app, "198.51.100.1"); expect((await browser.signIn("wrong")).status).toBe(401); expect(browser.cookies.has("kepr_session")).toBe(false); await browser.get("/sign-in"); expect( (await browser.post("/sign-in", { password: PASSWORD }, "https://evil.example")).status, ).toBe(403); expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403); browser.cookies.delete("kepr_sign_in"); expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403); }); test("five failures lock one address out for 15 minutes, even with the right password", async () => { const browser = new Browser(inst.app, "198.51.100.2"); for (let i = 0; i < 5; i++) expect((await browser.signIn("nope")).status).toBe(401); const locked = await browser.signIn(); expect(locked.status).toBe(429); expect(locked.headers.get("retry-after")).toBe("900"); expect((await new Browser(inst.app, "198.51.100.3").signIn()).status).toBe(303); tick(901); expect((await browser.signIn()).status).toBe(303); }); test("thirty failures from anywhere back every sign-in off until the window passes", async () => { for (let i = 0; i < 30; i++) { expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401); } expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(429); tick(901); expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303); }); test("every admin route: signed out is 401; a post needs the origin and the form token", async () => { const routes = adminRoutes(inst.app); expect(routes.length).toBeGreaterThan(1); const stranger = new Browser(inst.app); for (const { method, path } of routes) { const response = method === "GET" ? await stranger.get(path) : await stranger.post(path, { form: "x" }); expect(`${method} ${path} ${response.status}`).toBe(`${method} ${path} 401`); } const browser = new Browser(inst.app); await browser.signIn(); for (const { path } of routes.filter((route) => route.method === "POST")) { const foreign = await browser.post(path, {}, "https://evil.example"); expect(`${path} ${foreign.status}`).toBe(`${path} 403`); const bare = await browser.post(path, {}, ""); expect(`${path} ${bare.status}`).toBe(`${path} 403`); const tokenless = await browser.post(path, { form: "" }); expect(`${path} ${tokenless.status}`).toBe(`${path} 403`); const other = new Browser(inst.app); await other.signIn(); const foreignToken = await browser.post(path, { form: other.form }); expect(`${path} ${foreignToken.status}`).toBe(`${path} 403`); } }); test("a session expires after 30 days", async () => { const browser = new Browser(inst.app); await browser.signIn(); tick(30 * 86400 - 10); expect((await browser.get("/admin")).status).toBe(200); tick(11); expect((await browser.get("/admin")).status).toBe(401); }); test("signing out revokes the session on the server, not just the cookie", async () => { const browser = new Browser(inst.app); await browser.signIn(); const token = browser.cookies.get("kepr_session") ?? ""; const out = await browser.post("/admin/sign-out", {}); expect(out.status).toBe(303); expect(browser.cookies.has("kepr_session")).toBe(false); browser.cookies.set("kepr_session", token); expect((await browser.get("/admin")).status).toBe(401); }); });