// Covers the console's Account section (June, 2026-09-30): the admin password, other sessions and // the domain, all changed without touching kepr.toml by hand, and applied without a restart. Also // §24's two-step sign-in, which is set up here and then used at /sign-in. import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; import { join } from "node:path"; import { createApp } from "../src/server/app.ts"; import { loadConfig, saveConfig } from "../src/server/config.ts"; import { codeAt } from "../src/server/totp.ts"; import { Browser, type Instance, instance, PASSWORD } from "./browser.ts"; import { makeRepos } from "./fixture.ts"; const NEW_PASSWORD = "a much longer passphrase now"; describe("the Account section", () => { let inst: Instance; let operator: Browser; let configPath: string; const change = (fields: Record) => operator.post("/admin/account", { action: "password", ...fields }); beforeAll(async () => { const repos = await makeRepos(["app"]); inst = await instance(repos.bucketDir); configPath = join(inst.dataDir, "kepr.toml"); await saveConfig(configPath, inst.config); inst.app = createApp(inst.config, inst.kepr, undefined, configPath); operator = new Browser(inst.app); await operator.signIn(); }); test("lists the sections's forms and never the hash", async () => { const page = await (await operator.get("/admin/account")).text(); expect(page).toContain('name="current_password"'); expect(page).toContain('value="kepr.example"'); expect(page).not.toContain("$argon2id$"); }); test("refuses a wrong current password, a short one, or a mismatch", async () => { const cases: [Record, string][] = [ [ { current_password: "wrong", password: NEW_PASSWORD, confirm: NEW_PASSWORD }, "The current password is not right.", ], [ { current_password: PASSWORD, password: "short", confirm: "short" }, "at least 12 characters", ], [ { current_password: PASSWORD, password: NEW_PASSWORD, confirm: "different" }, "The two new passwords differ.", ], ]; for (const [fields, error] of cases) { const response = await change(fields); expect(response.status).toBe(400); expect(await response.text()).toContain(error); } expect( await Bun.password.verify(PASSWORD, (await loadConfig(configPath)).adminPasswordHash), ).toBe(true); }); test("a new password is saved, works at once, and signs every other session out", async () => { const other = new Browser(inst.app, "198.51.100.9"); await other.signIn(); const response = await change({ current_password: PASSWORD, password: NEW_PASSWORD, confirm: NEW_PASSWORD, }); expect(response.status).toBe(303); const saved = await loadConfig(configPath); expect(await Bun.password.verify(NEW_PASSWORD, saved.adminPasswordHash)).toBe(true); expect((await other.get("/admin")).status).toBe(401); expect((await operator.get("/admin")).status).toBe(200); expect((await new Browser(inst.app, "198.51.100.10").signIn(PASSWORD)).status).toBe(401); expect((await new Browser(inst.app, "198.51.100.11").signIn(NEW_PASSWORD)).status).toBe(303); }); test("signing out everywhere else keeps only this session", async () => { const other = new Browser(inst.app, "198.51.100.12"); await other.signIn(NEW_PASSWORD); expect(await (await operator.get("/admin/account")).text()).toMatch( /[2-9] sessions? signed in/, ); expect((await operator.post("/admin/account", { action: "sessions" })).status).toBe(303); expect((await other.get("/admin")).status).toBe(401); expect((await operator.get("/admin")).status).toBe(200); }); test("the domain is checked, saved, and used at once", async () => { const bad = await operator.post("/admin/account", { action: "domain", domain: "not a domain" }); expect(bad.status).toBe(400); const moved = await operator.post("/admin/account", { action: "domain", domain: "code.example", }); expect(moved.status).toBe(303); expect((await loadConfig(configPath)).domain).toBe("code.example"); const origin = "https://code.example"; expect((await operator.post("/admin/sync", {}, origin)).status).toBe(303); expect((await operator.post("/admin/sync", {})).status).toBe(403); const page = await (await operator.get("/admin/account")).text(); expect(page).toContain(''); }); }); describe("two-step sign-in (§24)", () => { let inst: Instance; let operator: Browser; let configPath: string; const at = Date.parse("2026-10-08T12:00:00Z"); const seconds = (offset: number) => Math.floor(at / 1000) + offset; beforeAll(async () => { setSystemTime(new Date(at)); const repos = await makeRepos(["app"]); inst = await instance(repos.bucketDir); configPath = join(inst.dataDir, "kepr.toml"); await saveConfig(configPath, inst.config); inst.app = createApp(inst.config, inst.kepr, undefined, configPath); operator = new Browser(inst.app); await operator.signIn(); }); afterAll(() => setSystemTime()); test("a key is offered, and only a code from it turns two-step on", async () => { const offered = await (await operator.post("/admin/account", { action: "totp-start" })).text(); const secret = /key: ([A-Z2-7]{32})/.exec(offered)?.[1] ?? ""; expect(secret).toHaveLength(32); expect(offered).toContain("otpauth://totp/kepr:kepr.example"); expect(inst.config.totpSecret).toBeUndefined(); const wrong = await operator.post("/admin/account", { action: "totp-confirm", secret, code: "000000", }); expect(wrong.status).toBe(400); expect(inst.config.totpSecret).toBeUndefined(); const confirmed = await operator.post("/admin/account", { action: "totp-confirm", secret, code: codeAt(secret, seconds(0)) ?? "", }); expect(confirmed.status).toBe(303); expect(inst.config.totpSecret).toBe(secret); expect((await loadConfig(configPath)).totpSecret).toBe(secret); }); test("sign-in then takes the code as well, and a code signs in once", async () => { const secret = inst.config.totpSecret ?? ""; const bare = new Browser(inst.app, "198.51.100.90"); expect((await bare.signIn(PASSWORD)).status).toBe(401); const wrong = new Browser(inst.app, "198.51.100.91"); expect((await wrong.signIn(PASSWORD, "000000")).status).toBe(401); // The code that set it up is spent, so a later step's code is the one to use. const code = codeAt(secret, seconds(30)) ?? ""; const fresh = new Browser(inst.app, "198.51.100.92"); expect((await fresh.signIn(PASSWORD, code)).status).toBe(303); expect((await fresh.get("/admin")).status).toBe(200); const replay = new Browser(inst.app, "198.51.100.93"); expect((await replay.signIn(PASSWORD, code)).status).toBe(401); }); test("turning it off takes the password, and then the password alone signs in", async () => { const wrong = await operator.post("/admin/account", { action: "totp-off", current_password: "not the password", }); expect(wrong.status).toBe(400); expect(inst.config.totpSecret).toBeDefined(); const off = await operator.post("/admin/account", { action: "totp-off", current_password: PASSWORD, }); expect(off.status).toBe(303); expect(inst.config.totpSecret).toBeUndefined(); expect((await loadConfig(configPath)).totpSecret).toBeUndefined(); const plain = new Browser(inst.app, "198.51.100.94"); expect((await plain.signIn(PASSWORD)).status).toBe(303); }); });