bucketadmin.test.ts

8.3 KB · raw

1// Covers §24's Bucket section: masked secrets, the connection test, and a save that rewrites2// kepr.toml and applies the new settings without a restart.34import { afterAll, beforeEach, describe, expect, test } from "bun:test";5import { mkdtemp, stat } from "node:fs/promises";6import { tmpdir } from "node:os";7import { join } from "node:path";8import { openBucket } from "../src/bucket/reader.ts";9import { createApp } from "../src/server/app.ts";10import { type Config, loadConfig } from "../src/server/config.ts";11import { openKeprDb } from "../src/server/db.ts";12import { Browser, PASSWORD } from "./browser.ts";1314const HASH = await Bun.password.hash(PASSWORD, "argon2id");15const KEY_IDS = new Set(["KEY-ID", "NEW-KEY"]);16const READ_ONLY = "READ-ONLY";17const written: string[] = [];1819// Just enough S3 for the connection test: ListObjectsV2 and PUT, allowed by the signing key's ID;20// READ-ONLY may list but not write.21function fakeS3() {22	return Bun.serve({23		port: 0,24		async fetch(request) {25			const keyId = /Credential=([^/]+)\//.exec(request.headers.get("authorization") ?? "")?.[1];26			const denied = () =>27				new Response("<Error><Code>AccessDenied</Code><Message>no</Message></Error>", {28					status: 403,29				});30			if (request.method === "PUT") {31				if (!keyId || !KEY_IDS.has(keyId)) return denied();32				written.push(new URL(request.url).pathname);33				return new Response("", { headers: { etag: '"1"' } });34			}35			if (!keyId || !(KEY_IDS.has(keyId) || keyId === READ_ONLY)) return denied();36			return new Response(37				"<ListBucketResult><IsTruncated>false</IsTruncated><Contents><Key>app/head</Key></Contents></ListBucketResult>",38			);39		},40	});41}42const first = fakeS3();43const second = fakeS3();44afterAll(() => {45	first.stop(true);46	second.stop(true);47});4849async function build(folder = false) {50	const dataDir = await mkdtemp(join(tmpdir(), "kepr-bucket-admin-"));51	const configPath = join(dataDir, "kepr.toml");52	const bucket: Config["bucket"] = folder53		? { kind: "folder", path: dataDir }54		: {55				kind: "s3",56				endpoint: `http://127.0.0.1:${first.port}`,57				name: "koh",58				region: "fr-par",59				accessKeyId: "KEY-ID",60				secretAccessKey: "KEY-SECRET",61			};62	const config: Config = { domain: "kepr.example", dataDir, adminPasswordHash: HASH, bucket };63	const original = folder64		? "folder instance\n"65		: `# hand-written\ndomain = "kepr.example"\ndata_dir = "${dataDir}"\nadmin_password_hash = "${HASH}"\n[bucket]\nendpoint = "${bucket.kind === "s3" ? bucket.endpoint : ""}"\nname = "koh"\nregion = "fr-par"\naccess_key_id = "KEY-ID"\nsecret_access_key = "KEY-SECRET"\n`;66	await Bun.write(configPath, original);67	const kepr = openKeprDb(dataDir);68	const shared = openBucket(config.bucket);69	const app = createApp(config, kepr, shared, configPath);70	const browser = new Browser(app);71	await browser.signIn();72	await browser.get("/admin/bucket");73	return { app, config, configPath, original, shared, browser };74}7576const form = (fields: Record<string, string>) => ({77	endpoint: `http://127.0.0.1:${first.port}`,78	name: "koh",79	region: "fr-par",80	access_key_id: "KEY-ID",81	secret_access_key: "",82	...fields,83});8485beforeEach(() => {86	written.length = 0;87});8889describe("the Bucket section (§24)", () => {90	test("shows every setting but the secrets", async () => {91		const { browser } = await build();92		const text = await (await browser.get("/admin/bucket")).text();93		expect(text).toContain(`http://127.0.0.1:${first.port}`);94		expect(text).toContain('value="KEY-ID"');95		expect(text).toContain('href="/admin/bucket" aria-current="page"');96		expect(text).not.toContain("KEY-SECRET");97	});9899	test("the test checks the form and writes nothing to kepr.toml", async () => {100		const { browser, configPath, original } = await build();101		const works = await browser.post("/admin/bucket", form({ action: "test" }));102		expect(works.status).toBe(200);103		expect(await works.text()).toContain("Connection works");104		const refused = await browser.post(105			"/admin/bucket",106			form({ action: "test", access_key_id: "WRONG" }),107		);108		expect(refused.status).toBe(400);109		expect(await refused.text()).toContain("The key cannot list the bucket");110		expect(await Bun.file(configPath).text()).toBe(original);111		expect(written).toEqual(["/koh/.kepr/connection-test"]);112	});113114	test("a key that can only read is refused", async () => {115		const { browser, configPath, original } = await build();116		const refused = await browser.post(117			"/admin/bucket",118			form({ action: "save", access_key_id: READ_ONLY, secret_access_key: "x" }),119		);120		expect(refused.status).toBe(400);121		expect(await refused.text()).toContain("The key cannot write");122		expect(await Bun.file(configPath).text()).toBe(original);123	});124125	test("save rewrites kepr.toml, keeps blank secrets and applies at once", async () => {126		const { app, browser, configPath, shared } = await build();127		const endpoint = `http://127.0.0.1:${second.port}`;128		const saved = await browser.post(129			"/admin/bucket",130			form({131				action: "save",132				endpoint,133				region: "nl-ams",134			}),135		);136		expect(saved.status).toBe(303);137		expect(saved.headers.get("location")).toBe("/admin/bucket?saved=1");138		const reloaded = await loadConfig(configPath);139		expect(reloaded.bucket).toEqual({140			kind: "s3",141			endpoint,142			name: "koh",143			region: "nl-ams",144			accessKeyId: "KEY-ID",145			secretAccessKey: "KEY-SECRET",146		});147		expect(reloaded.adminPasswordHash).toBe(HASH);148		expect((await stat(configPath)).mode & 0o777).toBe(0o600);149		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith(`${endpoint}/koh/app/x`);150		const csp = (await app.request("/")).headers.get("content-security-policy") ?? "";151		expect(csp).toContain(endpoint);152		expect(csp).not.toContain(`127.0.0.1:${first.port}`);153		expect((await browser.get("/connect?machine=laptop")).status).toBe(200);154		const page = await (await browser.get("/admin/bucket")).text();155		expect(page).not.toContain("KEY-SECRET");156	});157158	test("refuses a malformed form before testing anything", async () => {159		const { browser, configPath, original } = await build();160		const cases: [Record<string, string>, string][] = [161			[{ endpoint: "ftp://example.com" }, "The endpoint must be an http or https URL."],162			[{ name: "" }, "The bucket name is required."],163			[{ region: "fr\npar" }, "The region cannot contain spaces or control characters."],164		];165		for (const [fields, error] of cases) {166			const response = await browser.post("/admin/bucket", form({ action: "save", ...fields }));167			expect(response.status).toBe(400);168			expect(await response.text()).toContain(error);169		}170		expect(await Bun.file(configPath).text()).toBe(original);171	});172173	test("a folder instance can switch to S3 from the console", async () => {174		const { browser, configPath, config, shared } = await build(true);175		const text = await (await browser.get("/admin/bucket")).text();176		expect(text).toContain(config.bucket.kind === "folder" ? config.bucket.path : "");177		expect(text).toContain('name="endpoint"');178		const secretless = await browser.post("/admin/bucket", form({ action: "save" }));179		expect(secretless.status).toBe(400);180		expect(await secretless.text()).toContain("The secret key is required.");181		const saved = await browser.post(182			"/admin/bucket",183			form({ action: "save", secret_access_key: "KEY-SECRET" }),184		);185		expect(saved.status).toBe(303);186		expect((await loadConfig(configPath)).bucket).toMatchObject({ kind: "s3", name: "koh" });187		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith(188			`http://127.0.0.1:${first.port}/koh/app/x`,189		);190	});191192	test("an S3 instance can switch to a folder remote, which must exist", async () => {193		const { browser, configPath, shared, original } = await build();194		const missing = await browser.post("/admin/bucket", {195			action: "folder",196			folder: "/no/such/dir",197		});198		expect(missing.status).toBe(400);199		expect(await missing.text()).toContain("is not a folder Kepr can read");200		const relative = await browser.post("/admin/bucket", { action: "folder", folder: "bucket" });201		expect(relative.status).toBe(400);202		expect(await Bun.file(configPath).text()).toBe(original);203		const folder = await mkdtemp(join(tmpdir(), "kepr-folder-remote-"));204		const saved = await browser.post("/admin/bucket", { action: "folder", folder });205		expect(saved.status).toBe(303);206		expect((await loadConfig(configPath)).bucket).toEqual({ kind: "folder", path: folder });207		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith("file://");208	});209});