bucketadmin.test.ts

9.1 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 READ_IDS = new Set(["READ-ID", "NEW-READ"]);16const WRITE_ID = "WRITE-ID";17const written: string[] = [];1819// Just enough S3 for the connection test: ListObjectsV2 and PUT, allowed by the signing key's ID.20function fakeS3() {21	return Bun.serve({22		port: 0,23		async fetch(request) {24			const keyId = /Credential=([^/]+)\//.exec(request.headers.get("authorization") ?? "")?.[1];25			const denied = () =>26				new Response("<Error><Code>AccessDenied</Code><Message>no</Message></Error>", {27					status: 403,28				});29			if (request.method === "PUT") {30				if (keyId !== WRITE_ID) return denied();31				written.push(new URL(request.url).pathname);32				return new Response("", { headers: { etag: '"1"' } });33			}34			if (!keyId || !READ_IDS.has(keyId)) return denied();35			return new Response(36				"<ListBucketResult><IsTruncated>false</IsTruncated><Contents><Key>app/head</Key></Contents></ListBucketResult>",37			);38		},39	});40}41const first = fakeS3();42const second = fakeS3();43afterAll(() => {44	first.stop(true);45	second.stop(true);46});4748async function build(folder = false) {49	const dataDir = await mkdtemp(join(tmpdir(), "kepr-bucket-admin-"));50	const configPath = join(dataDir, "kepr.toml");51	const bucket: Config["bucket"] = folder52		? { kind: "folder", path: dataDir }53		: {54				kind: "s3",55				endpoint: `http://127.0.0.1:${first.port}`,56				name: "koh",57				region: "fr-par",58				accessKeyId: "READ-ID",59				secretAccessKey: "READ-SECRET",60			};61	const config: Config = { domain: "kepr.example", dataDir, adminPasswordHash: HASH, bucket };62	const original = folder63		? "folder instance\n"64		: `# 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 = "READ-ID"\nsecret_access_key = "READ-SECRET"\n`;65	await Bun.write(configPath, original);66	const kepr = openKeprDb(dataDir);67	const shared = openBucket(config.bucket);68	const app = createApp(config, kepr, shared, configPath);69	const browser = new Browser(app);70	await browser.signIn();71	await browser.get("/admin/bucket");72	return { app, config, configPath, original, shared, browser };73}7475const form = (fields: Record<string, string>) => ({76	endpoint: `http://127.0.0.1:${first.port}`,77	name: "koh",78	region: "fr-par",79	access_key_id: "READ-ID",80	secret_access_key: "",81	write_access_key_id: "",82	write_secret_access_key: "",83	...fields,84});8586beforeEach(() => {87	written.length = 0;88});8990describe("the Bucket section (§24)", () => {91	test("shows every setting but the secrets", async () => {92		const { browser } = await build();93		const text = await (await browser.get("/admin/bucket")).text();94		expect(text).toContain(`http://127.0.0.1:${first.port}`);95		expect(text).toContain('value="READ-ID"');96		expect(text).toContain('href="/admin/bucket" aria-current="page"');97		expect(text).not.toContain("READ-SECRET");98	});99100	test("the test checks the form and writes nothing to kepr.toml", async () => {101		const { browser, configPath, original } = await build();102		const works = await browser.post("/admin/bucket", form({ action: "test" }));103		expect(works.status).toBe(200);104		expect(await works.text()).toContain("Connection works");105		const refused = await browser.post(106			"/admin/bucket",107			form({ action: "test", access_key_id: "WRONG" }),108		);109		expect(refused.status).toBe(400);110		expect(await refused.text()).toContain("The read key cannot list the bucket");111		expect(await Bun.file(configPath).text()).toBe(original);112	});113114	test("a write key must write Kepr's own test file, and only that", async () => {115		const { browser, configPath, original } = await build();116		const refused = await browser.post(117			"/admin/bucket",118			form({ action: "save", write_access_key_id: "READ-ID", write_secret_access_key: "x" }),119		);120		expect(refused.status).toBe(400);121		expect(await refused.text()).toContain("The write key cannot write");122		expect(await Bun.file(configPath).text()).toBe(original);123		const works = await browser.post(124			"/admin/bucket",125			form({ action: "test", write_access_key_id: WRITE_ID, write_secret_access_key: "ws" }),126		);127		expect(works.status).toBe(200);128		expect(written).toEqual(["/koh/.kepr/connection-test"]);129	});130131	test("save rewrites kepr.toml, keeps blank secrets and applies at once", async () => {132		const { app, browser, configPath, shared } = await build();133		expect((await browser.get("/connect?machine=laptop")).status).toBe(404);134		const endpoint = `http://127.0.0.1:${second.port}`;135		const saved = await browser.post(136			"/admin/bucket",137			form({138				action: "save",139				endpoint,140				region: "nl-ams",141				write_access_key_id: WRITE_ID,142				write_secret_access_key: "WRITE-SECRET",143			}),144		);145		expect(saved.status).toBe(303);146		expect(saved.headers.get("location")).toBe("/admin/bucket?saved=1");147		const reloaded = await loadConfig(configPath);148		expect(reloaded.bucket).toEqual({149			kind: "s3",150			endpoint,151			name: "koh",152			region: "nl-ams",153			accessKeyId: "READ-ID",154			secretAccessKey: "READ-SECRET",155			write: { accessKeyId: WRITE_ID, secretAccessKey: "WRITE-SECRET" },156		});157		expect(reloaded.adminPasswordHash).toBe(HASH);158		expect((await stat(configPath)).mode & 0o777).toBe(0o600);159		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith(`${endpoint}/koh/app/x`);160		const csp = (await app.request("/")).headers.get("content-security-policy") ?? "";161		expect(csp).toContain(endpoint);162		expect(csp).not.toContain(`127.0.0.1:${first.port}`);163		expect((await browser.get("/connect?machine=laptop")).status).toBe(200);164		const page = await (await browser.get("/admin/bucket")).text();165		expect(page).not.toContain("WRITE-SECRET");166167		const cleared = await browser.post("/admin/bucket", form({ action: "save", endpoint }));168		expect(cleared.status).toBe(303);169		expect((await loadConfig(configPath)).bucket).not.toHaveProperty("write");170		expect((await browser.get("/connect?machine=laptop")).status).toBe(404);171	});172173	test("refuses a malformed form before testing anything", async () => {174		const { browser, configPath, original } = await build();175		const cases: [Record<string, string>, string][] = [176			[{ endpoint: "ftp://example.com" }, "The endpoint must be an http or https URL."],177			[{ name: "" }, "The bucket name is required."],178			[{ region: "fr\npar" }, "The region cannot contain spaces or control characters."],179			[{ write_access_key_id: "NEW-WRITE" }, "A new write key needs its secret."],180		];181		for (const [fields, error] of cases) {182			const response = await browser.post("/admin/bucket", form({ action: "save", ...fields }));183			expect(response.status).toBe(400);184			expect(await response.text()).toContain(error);185		}186		expect(await Bun.file(configPath).text()).toBe(original);187	});188189	test("a folder instance can switch to S3 from the console", async () => {190		const { browser, configPath, config, shared } = await build(true);191		const text = await (await browser.get("/admin/bucket")).text();192		expect(text).toContain(config.bucket.kind === "folder" ? config.bucket.path : "");193		expect(text).toContain('name="endpoint"');194		const secretless = await browser.post("/admin/bucket", form({ action: "save" }));195		expect(secretless.status).toBe(400);196		expect(await secretless.text()).toContain("The read secret key is required.");197		const saved = await browser.post(198			"/admin/bucket",199			form({ action: "save", secret_access_key: "READ-SECRET" }),200		);201		expect(saved.status).toBe(303);202		expect((await loadConfig(configPath)).bucket).toMatchObject({ kind: "s3", name: "koh" });203		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith(204			`http://127.0.0.1:${first.port}/koh/app/x`,205		);206	});207208	test("an S3 instance can switch to a folder remote, which must exist", async () => {209		const { browser, configPath, shared, original } = await build();210		const missing = await browser.post("/admin/bucket", {211			action: "folder",212			folder: "/no/such/dir",213		});214		expect(missing.status).toBe(400);215		expect(await missing.text()).toContain("is not a folder Kepr can read");216		const relative = await browser.post("/admin/bucket", { action: "folder", folder: "bucket" });217		expect(relative.status).toBe(400);218		expect(await Bun.file(configPath).text()).toBe(original);219		const folder = await mkdtemp(join(tmpdir(), "kepr-folder-remote-"));220		const saved = await browser.post("/admin/bucket", { action: "folder", folder });221		expect(saved.status).toBe(303);222		expect((await loadConfig(configPath)).bucket).toEqual({ kind: "folder", path: folder });223		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith("file://");224	});225});