12345import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";6import { join } from "node:path";7import { createApp } from "../src/server/app.ts";8import { loadConfig, saveConfig } from "../src/server/config.ts";9import { codeAt } from "../src/server/totp.ts";10import { Browser, type Instance, instance, PASSWORD } from "./browser.ts";11import { makeRepos } from "./fixture.ts";1213const NEW_PASSWORD = "a much longer passphrase now";1415describe("the Account section", () => {16 let inst: Instance;17 let operator: Browser;18 let configPath: string;19 const change = (fields: Record<string, string>) =>20 operator.post("/admin/account", { action: "password", ...fields });2122 beforeAll(async () => {23 const repos = await makeRepos(["app"]);24 inst = await instance(repos.bucketDir);25 configPath = join(inst.dataDir, "kepr.toml");26 await saveConfig(configPath, inst.config);27 inst.app = createApp(inst.config, inst.kepr, undefined, configPath);28 operator = new Browser(inst.app);29 await operator.signIn();30 });3132 test("lists the sections's forms and never the hash", async () => {33 const page = await (await operator.get("/admin/account")).text();34 expect(page).toContain('name="current_password"');35 expect(page).toContain('value="kepr.example"');36 expect(page).not.toContain("$argon2id$");37 });3839 test("refuses a wrong current password, a short one, or a mismatch", async () => {40 const cases: [Record<string, string>, string][] = [41 [42 { current_password: "wrong", password: NEW_PASSWORD, confirm: NEW_PASSWORD },43 "The current password is not right.",44 ],45 [46 { current_password: PASSWORD, password: "short", confirm: "short" },47 "at least 12 characters",48 ],49 [50 { current_password: PASSWORD, password: NEW_PASSWORD, confirm: "different" },51 "The two new passwords differ.",52 ],53 ];54 for (const [fields, error] of cases) {55 const response = await change(fields);56 expect(response.status).toBe(400);57 expect(await response.text()).toContain(error);58 }59 expect(60 await Bun.password.verify(PASSWORD, (await loadConfig(configPath)).adminPasswordHash),61 ).toBe(true);62 });6364 test("a new password is saved, works at once, and signs every other session out", async () => {65 const other = new Browser(inst.app, "198.51.100.9");66 await other.signIn();67 const response = await change({68 current_password: PASSWORD,69 password: NEW_PASSWORD,70 confirm: NEW_PASSWORD,71 });72 expect(response.status).toBe(303);73 const saved = await loadConfig(configPath);74 expect(await Bun.password.verify(NEW_PASSWORD, saved.adminPasswordHash)).toBe(true);75 expect((await other.get("/admin")).status).toBe(401);76 expect((await operator.get("/admin")).status).toBe(200);77 expect((await new Browser(inst.app, "198.51.100.10").signIn(PASSWORD)).status).toBe(401);78 expect((await new Browser(inst.app, "198.51.100.11").signIn(NEW_PASSWORD)).status).toBe(303);79 });8081 test("signing out everywhere else keeps only this session", async () => {82 const other = new Browser(inst.app, "198.51.100.12");83 await other.signIn(NEW_PASSWORD);84 expect(await (await operator.get("/admin/account")).text()).toMatch(85 /[2-9] sessions? signed in/,86 );87 expect((await operator.post("/admin/account", { action: "sessions" })).status).toBe(303);88 expect((await other.get("/admin")).status).toBe(401);89 expect((await operator.get("/admin")).status).toBe(200);90 });9192 test("the domain is checked, saved, and used at once", async () => {93 const bad = await operator.post("/admin/account", { action: "domain", domain: "not a domain" });94 expect(bad.status).toBe(400);95 const moved = await operator.post("/admin/account", {96 action: "domain",97 domain: "code.example",98 });99 expect(moved.status).toBe(303);100 expect((await loadConfig(configPath)).domain).toBe("code.example");101 const origin = "https://code.example";102 expect((await operator.post("/admin/sync", {}, origin)).status).toBe(303);103 expect((await operator.post("/admin/sync", {})).status).toBe(403);104 const page = await (await operator.get("/admin/account")).text();105 expect(page).toContain('<link rel="canonical" href="https://code.example/admin/account">');106 });107});108109describe("two-step sign-in (§24)", () => {110 let inst: Instance;111 let operator: Browser;112 let configPath: string;113 const at = Date.parse("2026-10-08T12:00:00Z");114 const seconds = (offset: number) => Math.floor(at / 1000) + offset;115116 beforeAll(async () => {117 setSystemTime(new Date(at));118 const repos = await makeRepos(["app"]);119 inst = await instance(repos.bucketDir);120 configPath = join(inst.dataDir, "kepr.toml");121 await saveConfig(configPath, inst.config);122 inst.app = createApp(inst.config, inst.kepr, undefined, configPath);123 operator = new Browser(inst.app);124 await operator.signIn();125 });126 afterAll(() => setSystemTime());127128 test("a key is offered, and only a code from it turns two-step on", async () => {129 const offered = await (await operator.post("/admin/account", { action: "totp-start" })).text();130 const secret = /key: ([A-Z2-7]{32})/.exec(offered)?.[1] ?? "";131 expect(secret).toHaveLength(32);132 expect(offered).toContain("otpauth://totp/kepr:kepr.example");133 134 expect(offered).toContain(`data-copy="${secret}"`);135 expect(offered).toContain(`data-copy="otpauth://totp/kepr:kepr.example?secret=${secret}`);136 expect(offered).toContain(`href="otpauth://totp/kepr:kepr.example?secret=${secret}`);137 138 expect(offered).toContain("&issuer=kepr");139 expect(inst.config.totpSecret).toBeUndefined();140 const wrong = await operator.post("/admin/account", {141 action: "totp-confirm",142 secret,143 code: "000000",144 });145 expect(wrong.status).toBe(400);146 expect(inst.config.totpSecret).toBeUndefined();147 const confirmed = await operator.post("/admin/account", {148 action: "totp-confirm",149 secret,150 code: codeAt(secret, seconds(0)) ?? "",151 });152 expect(confirmed.status).toBe(303);153 expect(inst.config.totpSecret).toBe(secret);154 expect((await loadConfig(configPath)).totpSecret).toBe(secret);155 });156157 test("sign-in then takes the code as well, and a code signs in once", async () => {158 const secret = inst.config.totpSecret ?? "";159 const bare = new Browser(inst.app, "198.51.100.90");160 expect((await bare.signIn(PASSWORD)).status).toBe(401);161 const wrong = new Browser(inst.app, "198.51.100.91");162 expect((await wrong.signIn(PASSWORD, "000000")).status).toBe(401);163 164 const code = codeAt(secret, seconds(30)) ?? "";165 const fresh = new Browser(inst.app, "198.51.100.92");166 expect((await fresh.signIn(PASSWORD, code)).status).toBe(303);167 expect((await fresh.get("/admin")).status).toBe(200);168 const replay = new Browser(inst.app, "198.51.100.93");169 expect((await replay.signIn(PASSWORD, code)).status).toBe(401);170 });171172 test("turning it off takes the password, and then the password alone signs in", async () => {173 const wrong = await operator.post("/admin/account", {174 action: "totp-off",175 current_password: "not the password",176 });177 expect(wrong.status).toBe(400);178 expect(inst.config.totpSecret).toBeDefined();179 const off = await operator.post("/admin/account", {180 action: "totp-off",181 current_password: PASSWORD,182 });183 expect(off.status).toBe(303);184 expect(inst.config.totpSecret).toBeUndefined();185 expect((await loadConfig(configPath)).totpSecret).toBeUndefined();186 const plain = new Browser(inst.app, "198.51.100.94");187 expect((await plain.signIn(PASSWORD)).status).toBe(303);188 });189});