account.test.ts

7.3 KB · raw

1// Covers the console's Account section (June, 2026-09-30): the admin password, other sessions and2// the domain, all changed without touching kepr.toml by hand, and applied without a restart. Also3// §24's two-step sign-in, which is set up here and then used at /sign-in.45import { 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		expect(inst.config.totpSecret).toBeUndefined();134		const wrong = await operator.post("/admin/account", {135			action: "totp-confirm",136			secret,137			code: "000000",138		});139		expect(wrong.status).toBe(400);140		expect(inst.config.totpSecret).toBeUndefined();141		const confirmed = await operator.post("/admin/account", {142			action: "totp-confirm",143			secret,144			code: codeAt(secret, seconds(0)) ?? "",145		});146		expect(confirmed.status).toBe(303);147		expect(inst.config.totpSecret).toBe(secret);148		expect((await loadConfig(configPath)).totpSecret).toBe(secret);149	});150151	test("sign-in then takes the code as well, and a code signs in once", async () => {152		const secret = inst.config.totpSecret ?? "";153		const bare = new Browser(inst.app, "198.51.100.90");154		expect((await bare.signIn(PASSWORD)).status).toBe(401);155		const wrong = new Browser(inst.app, "198.51.100.91");156		expect((await wrong.signIn(PASSWORD, "000000")).status).toBe(401);157		// The code that set it up is spent, so a later step's code is the one to use.158		const code = codeAt(secret, seconds(30)) ?? "";159		const fresh = new Browser(inst.app, "198.51.100.92");160		expect((await fresh.signIn(PASSWORD, code)).status).toBe(303);161		expect((await fresh.get("/admin")).status).toBe(200);162		const replay = new Browser(inst.app, "198.51.100.93");163		expect((await replay.signIn(PASSWORD, code)).status).toBe(401);164	});165166	test("turning it off takes the password, and then the password alone signs in", async () => {167		const wrong = await operator.post("/admin/account", {168			action: "totp-off",169			current_password: "not the password",170		});171		expect(wrong.status).toBe(400);172		expect(inst.config.totpSecret).toBeDefined();173		const off = await operator.post("/admin/account", {174			action: "totp-off",175			current_password: PASSWORD,176		});177		expect(off.status).toBe(303);178		expect(inst.config.totpSecret).toBeUndefined();179		expect((await loadConfig(configPath)).totpSecret).toBeUndefined();180		const plain = new Browser(inst.app, "198.51.100.94");181		expect((await plain.signIn(PASSWORD)).status).toBe(303);182	});183});