123import { Database } from "bun:sqlite";4import { afterAll, describe, expect, setSystemTime, test } from "bun:test";5import { mkdtemp } from "node:fs/promises";6import { tmpdir } from "node:os";7import { join } from "node:path";8import { createApp } from "../src/server/app.ts";9import { makeBackup } from "../src/server/backup.ts";10import { type Config, parseConfig } from "../src/server/config.ts";11import { openKeprDb } from "../src/server/db.ts";12import { Browser, PASSWORD } from "./browser.ts";1314const ORIGIN = "https://kepr.example";15const WRITE = { accessKeyId: "WRITE-ID", secretAccessKey: "WRITE-SECRET" };1617const HASH = await Bun.password.hash(PASSWORD, "argon2id");18let clock = Date.parse("2026-09-27T12:00:00Z");19setSystemTime(clock);20afterAll(() => setSystemTime());2122async function build(write = true, folder = false) {23 const dataDir = await mkdtemp(join(tmpdir(), "kepr-connect-"));24 const kepr = openKeprDb(dataDir);25 const bucket: Config["bucket"] = folder26 ? { kind: "folder", path: dataDir }27 : {28 kind: "s3",29 endpoint: "https://s3.fr-par.scw.cloud",30 name: "my-bucket",31 region: "fr-par",32 accessKeyId: "read-id",33 secretAccessKey: "read-secret",34 ...(write ? { write: WRITE } : {}),35 };36 const config: Config = {37 domain: "kepr.example",38 dataDir,39 adminPasswordHash: HASH,40 bucket,41 };42 return { app: createApp(config, kepr), kepr, config };43}4445async function connected(write = true) {46 const built = await build(write);47 const browser = new Browser(built.app, `198.51.100.${Math.floor(Math.random() * 200) + 1}`);48 await browser.signIn();49 return { ...built, browser };50}5152async function issue(browser: Browser, machine = "laptop") {53 await browser.get(`/connect?machine=${encodeURIComponent(machine)}`);54 const page = await browser.post("/connect", { machine });55 const body = await page.text();56 const match = /class="mono code">([^<]+)/.exec(body);57 const code = match?.[1]?.trim() ?? "";58 if (code === "") {59 60 throw new Error(`no code on the connect page (${page.status}): ${body.slice(0, 300)}`);61 }62 return { page, body, code };63}6465async function redeem(app: ReturnType<typeof createApp>, code: string, address = "203.0.113.77") {66 const browser = new Browser(app, address);67 return browser.post("/api/connect", { code });68}6970describe.serial("connect configuration and page", () => {71 test("parseConfig rejects write keys beside a folder bucket and empty write values", () => {72 const hash = "$argon2id$v=19$m=65536,t=2,p=1$abc$abc";73 expect(() =>74 parseConfig(75 `domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/x"\n[bucket.write]\naccess_key_id = "x"\nsecret_access_key = "y"`,76 ),77 ).toThrow();78 expect(() =>79 parseConfig(80 `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"`,81 ),82 ).toThrow();83 });8485 test("folder buckets and S3 buckets without write keys hide all connect routes", async () => {86 for (const built of [await build(false), await build(false, true)]) {87 const browser = new Browser(built.app);88 expect((await browser.get("/connect?machine=x")).status).toBe(404);89 expect((await browser.post("/connect", { machine: "x" })).status).toBe(404);90 expect((await browser.post("/api/connect", { code: "ABCDEFGH" })).status).toBe(404);91 }92 });9394 test("a disabled connect route is an unknown path, byte for byte", async () => {95 const { app } = await build(false);96 const browser = new Browser(app);97 for (const [method, path] of [98 ["GET", "/connect?machine=x"],99 ["POST", "/connect"],100 ["POST", "/api/connect"],101 ] as const) {102 const asked = method === "GET" ? browser.get(path) : browser.post(path, {});103 const unknown =104 method === "GET" ? browser.get("/nothing/here") : browser.post("/nothing/here", {});105 const [found, absent] = await Promise.all([asked, unknown]);106 expect(`${found.status} ${await found.text()}`).toBe(107 `${absent.status} ${await absent.text()}`,108 );109 expect(found.headers.get("cache-control")).toBe(absent.headers.get("cache-control"));110 }111 112 const big = { code: "x".repeat(70_000) };113 const oversized = await browser.post("/api/connect", big);114 const unrouted = await browser.post("/nothing/here", big);115 expect(`${oversized.status} ${await oversized.text()}`).toBe(116 `${unrouted.status} ${await unrouted.text()}`,117 );118 expect(oversized.headers.get("cache-control")).toBe(unrouted.headers.get("cache-control"));119 });120121 test("a signed-out request redirects before the machine name is judged", async () => {122 const { app } = await build();123 const browser = new Browser(app);124 const response = await browser.get(`/connect?machine=${encodeURIComponent("x".repeat(65))}`);125 expect(response.status).toBe(302);126 expect(response.headers.get("location")).toBe("/sign-in?next=%2Fconnect");127 });128129 test("connect redirects signed out, preserves next, and rejects unsafe next values", async () => {130 const built = await build();131 const browser = new Browser(built.app);132 const response = await browser.get("/connect?machine=desk%20%26%20rack");133 expect(response.status).toBe(302);134 for (const next of ["https://evil.example", "//evil.example", "/bad\n"]) {135 const sign = await browser.get(`/sign-in?next=${encodeURIComponent(next)}`);136 const html = await sign.text();137 expect(html).toContain('name="next" value="/admin"');138 }139 const sign = await browser.get("/sign-in?next=%2Fconnect%3Fmachine%3Ddesk");140 const form = /name="form" value="([^"]+)"/.exec(await sign.text())?.[1] ?? "";141 const wrong = await browser.request("/sign-in", {142 method: "POST",143 headers: { origin: ORIGIN },144 body: new URLSearchParams({ form, next: "/connect?machine=desk", password: "wrong" }),145 });146 expect(wrong.status).toBe(401);147 expect(await wrong.text()).toContain('name="next" value="/connect?machine=desk"');148 const good = await browser.request("/sign-in", {149 method: "POST",150 headers: { origin: ORIGIN },151 body: new URLSearchParams({ form, next: "/connect?machine=desk", password: PASSWORD }),152 });153 expect(good.status).toBe(303);154 expect(good.headers.get("location")).toBe("/connect?machine=desk");155 });156157 test("connect validates and escapes machine names, and never caches or exposes secrets", async () => {158 const { browser } = await connected();159 for (const machine of ["", "x".repeat(65), "bad\nname", "bad\u0085name", "bad\u202ename"])160 expect((await browser.get(`/connect?machine=${encodeURIComponent(machine)}`)).status).toBe(161 400,162 );163 const result = await issue(browser, "<img src=x onerror=alert(1)>");164 expect(result.page.status).toBe(200);165 expect(result.body).toContain("<img src=x onerror=alert(1)>");166 expect(result.body).not.toContain("WRITE-SECRET");167 expect(result.page.headers.get("cache-control")).toBe("no-store");168 });169});170171describe.serial("connect redemption", () => {172 test("stores only a hash, redeems once with exact credentials, normalizes code, and gives identical failures", async () => {173 const { app, browser, kepr, config } = await connected();174 const { code, body } = await issue(browser, "office");175 expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);176 expect(body).not.toContain("WRITE-SECRET");177 const row = kepr.query("SELECT code_hash, machine FROM connect_codes").get() as {178 code_hash: string;179 machine: string;180 };181 expect(row.code_hash).toMatch(/^[0-9a-f]{64}$/);182 expect(row.code_hash).not.toContain(code.replace("-", ""));183 expect(row.machine).toBe("office");184 const success = await redeem(app, code.toLowerCase(), "203.0.113.88");185 expect(success.status).toBe(200);186 expect(await success.json()).toEqual({187 endpoint: config.bucket.kind === "s3" ? config.bucket.endpoint : "",188 bucket: "my-bucket",189 region: "fr-par",190 access_key_id: "WRITE-ID",191 secret_access_key: "WRITE-SECRET",192 });193 expect(success.headers.get("cache-control")).toBe("no-store");194 const used = await redeem(app, code, "203.0.113.89");195 const unknown = await redeem(app, "ZZZZ-ZZZZ", "203.0.113.90");196 expect(used.status).toBe(404);197 expect(await used.text()).toBe(await unknown.text());198 expect(used.headers.get("cache-control")).toBe("no-store");199 });200201 test("connect POST requires session, origin and fresh form token", async () => {202 const built = await build();203 const browser = new Browser(built.app);204 expect((await browser.post("/connect", { machine: "x" })).status).toBe(401);205 await browser.signIn();206 expect((await browser.post("/connect", { machine: "x" }, "https://evil.example")).status).toBe(207 403,208 );209 expect((await browser.post("/connect", { machine: "x", form: "stale" })).status).toBe(403);210 });211212 test("expired, malformed and missing codes all return the same no-code response", async () => {213 const { app, browser } = await connected();214 const { code } = await issue(browser);215 clock += 601_000;216 setSystemTime(clock);217 const expired = await redeem(app, code, "198.51.100.201");218 const malformed = await redeem(app, "bad", "198.51.100.202");219 const missing = await new Browser(app, "198.51.100.203").request("/api/connect", {220 method: "POST",221 body: "not-a-form",222 });223 expect(expired.status).toBe(404);224 const expected = await expired.text();225 expect(expected).toBe('{"error":"no_code","message":"no code"}');226 expect(await malformed.text()).toBe(expected);227 expect(await missing.text()).toBe(expected);228 });229230 test("rate limits ten attempts per peer while another peer succeeds", async () => {231 const { app, browser } = await connected();232 const { code } = await issue(browser);233 for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.40");234 const limited = await redeem(app, code, "192.0.2.40");235 expect(limited.status).toBe(404);236 const other = await redeem(app, code, "192.0.2.41");237 expect(other.status).toBe(200);238 239 clock += 3_601_000;240 setSystemTime(clock);241 const { code: fresh } = await issue(browser, "later");242 expect((await redeem(app, fresh, "192.0.2.40")).status).toBe(200);243 });244245 test("the attempt window is counted by time, not by the list's order", async () => {246 const { app, browser } = await connected();247 const address = "192.0.2.70";248 249 for (let i = 0; i < 9; i++) await redeem(app, "bad", address);250 251 252 253 clock -= 120_000;254 setSystemTime(clock);255 await redeem(app, "bad", address);256 257 258 clock += 3_620_000;259 setSystemTime(clock);260 const { code } = await issue(browser, "after the step");261 expect((await redeem(app, code, address)).status).toBe(200);262 });263264 test("refused attempts are not recorded, so hammering neither grows the list nor the block", async () => {265 const { app, browser } = await connected();266 const address = "192.0.2.90";267 for (let i = 0; i < 10; i++) await redeem(app, "bad", address);268 269 270 clock += 3_540_000;271 setSystemTime(clock);272 for (let i = 0; i < 50; i++) expect((await redeem(app, "bad", address)).status).toBe(404);273 274 clock += 61_000;275 setSystemTime(clock);276 const { code } = await issue(browser, "after the block");277 expect((await redeem(app, code, address)).status).toBe(200);278 });279280 test("a forged X-Forwarded-For does not buy another bucketful", async () => {281 const { app, browser } = await connected();282 const { code } = await issue(browser);283 for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.50");284 const forged = await new Browser(app, "192.0.2.50").request("/api/connect", {285 method: "POST",286 headers: {287 "x-forwarded-for": "198.51.100.99",288 "content-type": "application/x-www-form-urlencoded",289 },290 body: new URLSearchParams({ code }),291 });292 expect(forged.status).toBe(404);293 });294295 test("two redemptions at once: exactly one wins", async () => {296 const { app, browser } = await connected();297 const { code } = await issue(browser, "race");298 const [first, second] = await Promise.all([299 redeem(app, code, "203.0.113.101"),300 redeem(app, code, "203.0.113.102"),301 ]);302 expect([first.status, second.status].sort()).toEqual([200, 404]);303 });304305 test("an oversized redemption is refused with the same no-code body", async () => {306 const { app } = await connected();307 const response = await new Browser(app, "192.0.2.60").post("/api/connect", {308 code: "x".repeat(70_000),309 });310 expect(response.status).toBe(404);311 expect(await response.text()).toBe('{"error":"no_code","message":"no code"}');312 expect(response.headers.get("cache-control")).toBe("no-store");313 });314315 test("a backup carries the hashed code and never the write key", async () => {316 const { browser, kepr, config } = await connected();317 const { code } = await issue(browser, "laptop");318 const name = makeBackup(kepr, config.dataDir);319 const file = join(config.dataDir, "backups", name, "kepr.db");320 const copy = new Database(file, { readonly: true });321 const rows = copy.query("SELECT machine, code_hash FROM connect_codes").all();322 copy.close();323 expect(rows.length).toBe(1);324 expect(JSON.stringify(rows)).not.toContain(code.replace("-", ""));325 326 const bytes = await Bun.file(file).bytes();327 expect(new TextDecoder().decode(bytes)).not.toContain("WRITE-SECRET");328 });329});330331describe.serial("connected machines in admin", () => {332 test("lists redeemed machines newest first, at most twenty, with revocation guidance", async () => {333 const { app, browser, kepr } = await connected();334 for (let i = 0; i < 22; i++) {335 const { code } = await issue(browser, `machine-${i}`);336 const response = await redeem(app, code, `10.0.0.${i + 1}`);337 expect(response.status).toBe(200);338 clock += 1000;339 setSystemTime(clock);340 }341 const admin = await browser.get("/admin");342 const html = await admin.text();343 expect(html).toContain("Machines connected");344 expect(html).toContain("rotate the write key in Scaleway");345 expect(html).not.toContain("machine-0 ");346 expect(html).toContain("machine-21");347 expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-20"));348 349 const kept = kepr350 .query<{ machine: string }, []>(351 "SELECT machine FROM connect_codes WHERE used_at IS NOT NULL ORDER BY used_at",352 )353 .all();354 expect(kept.length).toBe(20);355 expect(kept[0]?.machine).toBe("machine-2");356 });357358 test("redemptions in one second keep and list the latest twenty", async () => {359 const { app, browser, kepr } = await connected();360 for (let i = 0; i < 22; i++) {361 const { code } = await issue(browser, `machine-${i}`);362 expect((await redeem(app, code, `10.0.1.${i + 1}`)).status).toBe(200);363 }364 const html = await (await browser.get("/admin")).text();365 expect(html).not.toContain("machine-1 ");366 expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-2 "));367 const kept = kepr368 .query<{ machine: string }, []>("SELECT machine FROM connect_codes WHERE used_at IS NOT NULL")369 .all()370 .map((row) => row.machine);371 expect(kept).not.toContain("machine-0");372 expect(kept).not.toContain("machine-1");373 expect(kept.length).toBe(20);374 });375});