// Covers connect setup, validation, one-time redemption, rate limits, admin history, and secrecy. import { Database } from "bun:sqlite"; import { afterAll, describe, expect, setSystemTime, test } from "bun:test"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createApp } from "../src/server/app.ts"; import { makeBackup } from "../src/server/backup.ts"; import { type Config, parseConfig } from "../src/server/config.ts"; import { openKeprDb } from "../src/server/db.ts"; import { Browser, PASSWORD } from "./browser.ts"; const ORIGIN = "https://kepr.example"; const WRITE = { accessKeyId: "WRITE-ID", secretAccessKey: "WRITE-SECRET" }; // One argon2 hash for the whole file: every instance shares it, so the suite stays quick. const HASH = await Bun.password.hash(PASSWORD, "argon2id"); let clock = Date.parse("2026-09-27T12:00:00Z"); setSystemTime(clock); afterAll(() => setSystemTime()); async function build(write = true, folder = false) { const dataDir = await mkdtemp(join(tmpdir(), "kepr-connect-")); const kepr = openKeprDb(dataDir); const bucket: Config["bucket"] = folder ? { kind: "folder", path: dataDir } : { kind: "s3", endpoint: "https://s3.fr-par.scw.cloud", name: "my-bucket", region: "fr-par", accessKeyId: "read-id", secretAccessKey: "read-secret", ...(write ? { write: WRITE } : {}), }; const config: Config = { domain: "kepr.example", dataDir, adminPasswordHash: HASH, bucket, }; return { app: createApp(config, kepr), kepr, config }; } async function connected(write = true) { const built = await build(write); const browser = new Browser(built.app, `198.51.100.${Math.floor(Math.random() * 200) + 1}`); await browser.signIn(); return { ...built, browser }; } async function issue(browser: Browser, machine = "laptop") { await browser.get(`/connect?machine=${encodeURIComponent(machine)}`); const page = await browser.post("/connect", { machine }); const body = await page.text(); const match = /class="mono code">([^<]+)/.exec(body); const code = match?.[1]?.trim() ?? ""; if (code === "") { // A page without a code is the interesting failure: say what it said instead. throw new Error(`no code on the connect page (${page.status}): ${body.slice(0, 300)}`); } return { page, body, code }; } async function redeem(app: ReturnType, code: string, address = "203.0.113.77") { const browser = new Browser(app, address); return browser.post("/api/connect", { code }); } describe.serial("connect configuration and page", () => { test("parseConfig rejects write keys beside a folder bucket and empty write values", () => { const hash = "$argon2id$v=19$m=65536,t=2,p=1$abc$abc"; expect(() => parseConfig( `domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/x"\n[bucket.write]\naccess_key_id = "x"\nsecret_access_key = "y"`, ), ).toThrow(); expect(() => parseConfig( `domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nendpoint = "https://x"\nname = "n"\nregion = "r"\naccess_key_id = "a"\nsecret_access_key = "s"\n[bucket.write]\naccess_key_id = ""\nsecret_access_key = "y"`, ), ).toThrow(); }); test("folder buckets and S3 buckets without write keys hide all connect routes", async () => { for (const built of [await build(false), await build(false, true)]) { const browser = new Browser(built.app); expect((await browser.get("/connect?machine=x")).status).toBe(404); expect((await browser.post("/connect", { machine: "x" })).status).toBe(404); expect((await browser.post("/api/connect", { code: "ABCDEFGH" })).status).toBe(404); } }); test("a disabled connect route is an unknown path, byte for byte", async () => { const { app } = await build(false); const browser = new Browser(app); for (const [method, path] of [ ["GET", "/connect?machine=x"], ["POST", "/connect"], ["POST", "/api/connect"], ] as const) { const asked = method === "GET" ? browser.get(path) : browser.post(path, {}); const unknown = method === "GET" ? browser.get("/nothing/here") : browser.post("/nothing/here", {}); const [found, absent] = await Promise.all([asked, unknown]); expect(`${found.status} ${await found.text()}`).toBe( `${absent.status} ${await absent.text()}`, ); expect(found.headers.get("cache-control")).toBe(absent.headers.get("cache-control")); } // Even an oversized body is answered here the way the unknown path answers it. const big = { code: "x".repeat(70_000) }; const oversized = await browser.post("/api/connect", big); const unrouted = await browser.post("/nothing/here", big); expect(`${oversized.status} ${await oversized.text()}`).toBe( `${unrouted.status} ${await unrouted.text()}`, ); expect(oversized.headers.get("cache-control")).toBe(unrouted.headers.get("cache-control")); }); test("a signed-out request redirects before the machine name is judged", async () => { const { app } = await build(); const browser = new Browser(app); const response = await browser.get(`/connect?machine=${encodeURIComponent("x".repeat(65))}`); expect(response.status).toBe(302); expect(response.headers.get("location")).toBe("/sign-in?next=%2Fconnect"); }); test("connect redirects signed out, preserves next, and rejects unsafe next values", async () => { const built = await build(); const browser = new Browser(built.app); const response = await browser.get("/connect?machine=desk%20%26%20rack"); expect(response.status).toBe(302); for (const next of ["https://evil.example", "//evil.example", "/bad\n"]) { const sign = await browser.get(`/sign-in?next=${encodeURIComponent(next)}`); const html = await sign.text(); expect(html).toContain('name="next" value="/admin"'); } const sign = await browser.get("/sign-in?next=%2Fconnect%3Fmachine%3Ddesk"); const form = /name="form" value="([^"]+)"/.exec(await sign.text())?.[1] ?? ""; const wrong = await browser.request("/sign-in", { method: "POST", headers: { origin: ORIGIN }, body: new URLSearchParams({ form, next: "/connect?machine=desk", password: "wrong" }), }); expect(wrong.status).toBe(401); expect(await wrong.text()).toContain('name="next" value="/connect?machine=desk"'); const good = await browser.request("/sign-in", { method: "POST", headers: { origin: ORIGIN }, body: new URLSearchParams({ form, next: "/connect?machine=desk", password: PASSWORD }), }); expect(good.status).toBe(303); expect(good.headers.get("location")).toBe("/connect?machine=desk"); }); test("connect validates and escapes machine names, and never caches or exposes secrets", async () => { const { browser } = await connected(); for (const machine of ["", "x".repeat(65), "bad\nname", "bad\u0085name", "bad\u202ename"]) expect((await browser.get(`/connect?machine=${encodeURIComponent(machine)}`)).status).toBe( 400, ); const result = await issue(browser, ""); expect(result.page.status).toBe(200); expect(result.body).toContain("<img src=x onerror=alert(1)>"); expect(result.body).not.toContain("WRITE-SECRET"); expect(result.page.headers.get("cache-control")).toBe("no-store"); }); }); describe.serial("connect redemption", () => { test("stores only a hash, redeems once with exact credentials, normalizes code, and gives identical failures", async () => { const { app, browser, kepr, config } = await connected(); const { code, body } = await issue(browser, "office"); expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/); expect(body).not.toContain("WRITE-SECRET"); const row = kepr.query("SELECT code_hash, machine FROM connect_codes").get() as { code_hash: string; machine: string; }; expect(row.code_hash).toMatch(/^[0-9a-f]{64}$/); expect(row.code_hash).not.toContain(code.replace("-", "")); expect(row.machine).toBe("office"); const success = await redeem(app, code.toLowerCase(), "203.0.113.88"); expect(success.status).toBe(200); expect(await success.json()).toEqual({ endpoint: config.bucket.kind === "s3" ? config.bucket.endpoint : "", bucket: "my-bucket", region: "fr-par", access_key_id: "WRITE-ID", secret_access_key: "WRITE-SECRET", }); expect(success.headers.get("cache-control")).toBe("no-store"); const used = await redeem(app, code, "203.0.113.89"); const unknown = await redeem(app, "ZZZZ-ZZZZ", "203.0.113.90"); expect(used.status).toBe(404); expect(await used.text()).toBe(await unknown.text()); expect(used.headers.get("cache-control")).toBe("no-store"); }); test("connect POST requires session, origin and fresh form token", async () => { const built = await build(); const browser = new Browser(built.app); expect((await browser.post("/connect", { machine: "x" })).status).toBe(401); await browser.signIn(); expect((await browser.post("/connect", { machine: "x" }, "https://evil.example")).status).toBe( 403, ); expect((await browser.post("/connect", { machine: "x", form: "stale" })).status).toBe(403); }); test("expired, malformed and missing codes all return the same no-code response", async () => { const { app, browser } = await connected(); const { code } = await issue(browser); clock += 601_000; setSystemTime(clock); const expired = await redeem(app, code, "198.51.100.201"); const malformed = await redeem(app, "bad", "198.51.100.202"); const missing = await new Browser(app, "198.51.100.203").request("/api/connect", { method: "POST", body: "not-a-form", }); expect(expired.status).toBe(404); const expected = await expired.text(); expect(expected).toBe('{"error":"no_code","message":"no code"}'); expect(await malformed.text()).toBe(expected); expect(await missing.text()).toBe(expected); }); test("rate limits ten attempts per peer while another peer succeeds", async () => { const { app, browser } = await connected(); const { code } = await issue(browser); for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.40"); const limited = await redeem(app, code, "192.0.2.40"); expect(limited.status).toBe(404); const other = await redeem(app, code, "192.0.2.41"); expect(other.status).toBe(200); // The window is an hour: once it has passed the same peer is served again. clock += 3_601_000; setSystemTime(clock); const { code: fresh } = await issue(browser, "later"); expect((await redeem(app, fresh, "192.0.2.40")).status).toBe(200); }); test("the attempt window is counted by time, not by the list's order", async () => { const { app, browser } = await connected(); const address = "192.0.2.70"; // Nine attempts at one moment... for (let i = 0; i < 9; i++) await redeem(app, "bad", address); // ...then the clock steps back — an NTP correction, or a test's fake timer — and a tenth // attempt lands *older* than the nine before it. A prune that walks the list by position // stops at the first entry still inside the window and never reaches that one. clock -= 120_000; setSystemTime(clock); await redeem(app, "bad", address); // Now: an hour and two minutes after the nine, so the older tenth has left the window while // they are still inside it — the peer has one attempt left, not none. clock += 3_620_000; setSystemTime(clock); const { code } = await issue(browser, "after the step"); expect((await redeem(app, code, address)).status).toBe(200); }); test("refused attempts are not recorded, so hammering neither grows the list nor the block", async () => { const { app, browser } = await connected(); const address = "192.0.2.90"; for (let i = 0; i < 10; i++) await redeem(app, "bad", address); // Fifty more inside the hour are all refused, and none of them is remembered: kept, they would // cost memory per request and move the block out an hour past the last one. clock += 3_540_000; setSystemTime(clock); for (let i = 0; i < 50; i++) expect((await redeem(app, "bad", address)).status).toBe(404); // An hour after the tenth counted attempt the peer is served again. clock += 61_000; setSystemTime(clock); const { code } = await issue(browser, "after the block"); expect((await redeem(app, code, address)).status).toBe(200); }); test("a forged X-Forwarded-For does not buy another bucketful", async () => { const { app, browser } = await connected(); const { code } = await issue(browser); for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.50"); const forged = await new Browser(app, "192.0.2.50").request("/api/connect", { method: "POST", headers: { "x-forwarded-for": "198.51.100.99", "content-type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ code }), }); expect(forged.status).toBe(404); }); test("two redemptions at once: exactly one wins", async () => { const { app, browser } = await connected(); const { code } = await issue(browser, "race"); const [first, second] = await Promise.all([ redeem(app, code, "203.0.113.101"), redeem(app, code, "203.0.113.102"), ]); expect([first.status, second.status].sort()).toEqual([200, 404]); }); test("an oversized redemption is refused with the same no-code body", async () => { const { app } = await connected(); const response = await new Browser(app, "192.0.2.60").post("/api/connect", { code: "x".repeat(70_000), }); expect(response.status).toBe(404); expect(await response.text()).toBe('{"error":"no_code","message":"no code"}'); expect(response.headers.get("cache-control")).toBe("no-store"); }); test("a backup carries the hashed code and never the write key", async () => { const { browser, kepr, config } = await connected(); const { code } = await issue(browser, "laptop"); const name = makeBackup(kepr, config.dataDir); const file = join(config.dataDir, "backups", name, "kepr.db"); const copy = new Database(file, { readonly: true }); const rows = copy.query("SELECT machine, code_hash FROM connect_codes").all(); copy.close(); expect(rows.length).toBe(1); expect(JSON.stringify(rows)).not.toContain(code.replace("-", "")); // The write key is read from kepr.toml alone, so no copy of kepr.db can hold it. const bytes = await Bun.file(file).bytes(); expect(new TextDecoder().decode(bytes)).not.toContain("WRITE-SECRET"); }); }); describe.serial("connected machines in admin", () => { test("lists redeemed machines newest first, at most twenty, with revocation guidance", async () => { const { app, browser, kepr } = await connected(); for (let i = 0; i < 22; i++) { const { code } = await issue(browser, `machine-${i}`); const response = await redeem(app, code, `10.0.0.${i + 1}`); expect(response.status).toBe(200); clock += 1000; setSystemTime(clock); } const admin = await browser.get("/admin"); const html = await admin.text(); expect(html).toContain("Machines connected"); expect(html).toContain("rotate the write key in Scaleway"); expect(html).not.toContain("machine-0 "); expect(html).toContain("machine-21"); expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-20")); // Only what the list can show is kept: the two oldest redemptions are gone from kepr.db. const kept = kepr .query<{ machine: string }, []>( "SELECT machine FROM connect_codes WHERE used_at IS NOT NULL ORDER BY used_at", ) .all(); expect(kept.length).toBe(20); expect(kept[0]?.machine).toBe("machine-2"); }); test("redemptions in one second keep and list the latest twenty", async () => { const { app, browser, kepr } = await connected(); for (let i = 0; i < 22; i++) { const { code } = await issue(browser, `machine-${i}`); expect((await redeem(app, code, `10.0.1.${i + 1}`)).status).toBe(200); } const html = await (await browser.get("/admin")).text(); expect(html).not.toContain("machine-1 "); expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-2 ")); const kept = kepr .query<{ machine: string }, []>("SELECT machine FROM connect_codes WHERE used_at IS NOT NULL") .all() .map((row) => row.machine); expect(kept).not.toContain("machine-0"); expect(kept).not.toContain("machine-1"); expect(kept.length).toBe(20); }); });