// Covers §24's Bucket section: masked secrets, the connection test, and a save that rewrites // kepr.toml and applies the new settings without a restart. import { afterAll, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { openBucket } from "../src/bucket/reader.ts"; import { createApp } from "../src/server/app.ts"; import { type Config, loadConfig } from "../src/server/config.ts"; import { openKeprDb } from "../src/server/db.ts"; import { Browser, PASSWORD } from "./browser.ts"; const HASH = await Bun.password.hash(PASSWORD, "argon2id"); const KEY_IDS = new Set(["KEY-ID", "NEW-KEY"]); const READ_ONLY = "READ-ONLY"; const written: string[] = []; // Just enough S3 for the connection test: ListObjectsV2 and PUT, allowed by the signing key's ID; // READ-ONLY may list but not write. function fakeS3() { return Bun.serve({ port: 0, async fetch(request) { const keyId = /Credential=([^/]+)\//.exec(request.headers.get("authorization") ?? "")?.[1]; const denied = () => new Response("AccessDeniedno", { status: 403, }); if (request.method === "PUT") { if (!keyId || !KEY_IDS.has(keyId)) return denied(); written.push(new URL(request.url).pathname); return new Response("", { headers: { etag: '"1"' } }); } if (!keyId || !(KEY_IDS.has(keyId) || keyId === READ_ONLY)) return denied(); return new Response( "falseapp/head", ); }, }); } const first = fakeS3(); const second = fakeS3(); afterAll(() => { first.stop(true); second.stop(true); }); async function build(folder = false) { const dataDir = await mkdtemp(join(tmpdir(), "kepr-bucket-admin-")); const configPath = join(dataDir, "kepr.toml"); const bucket: Config["bucket"] = folder ? { kind: "folder", path: dataDir } : { kind: "s3", endpoint: `http://127.0.0.1:${first.port}`, name: "koh", region: "fr-par", accessKeyId: "KEY-ID", secretAccessKey: "KEY-SECRET", }; const config: Config = { domain: "kepr.example", dataDir, adminPasswordHash: HASH, bucket }; const original = folder ? "folder instance\n" : `# 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`; await Bun.write(configPath, original); const kepr = openKeprDb(dataDir); const shared = openBucket(config.bucket); const app = createApp(config, kepr, shared, configPath); const browser = new Browser(app); await browser.signIn(); await browser.get("/admin/bucket"); return { app, config, configPath, original, shared, browser }; } const form = (fields: Record) => ({ endpoint: `http://127.0.0.1:${first.port}`, name: "koh", region: "fr-par", access_key_id: "KEY-ID", secret_access_key: "", ...fields, }); beforeEach(() => { written.length = 0; }); describe("the Bucket section (§24)", () => { test("shows every setting but the secrets", async () => { const { browser } = await build(); const text = await (await browser.get("/admin/bucket")).text(); expect(text).toContain(`http://127.0.0.1:${first.port}`); expect(text).toContain('value="KEY-ID"'); expect(text).toContain('href="/admin/bucket" aria-current="page"'); expect(text).not.toContain("KEY-SECRET"); }); test("the test checks the form and writes nothing to kepr.toml", async () => { const { browser, configPath, original } = await build(); const works = await browser.post("/admin/bucket", form({ action: "test" })); expect(works.status).toBe(200); expect(await works.text()).toContain("Connection works"); const refused = await browser.post( "/admin/bucket", form({ action: "test", access_key_id: "WRONG" }), ); expect(refused.status).toBe(400); expect(await refused.text()).toContain("The key cannot list the bucket"); expect(await Bun.file(configPath).text()).toBe(original); expect(written).toEqual(["/koh/.kepr/connection-test"]); }); test("a key that can only read is refused", async () => { const { browser, configPath, original } = await build(); const refused = await browser.post( "/admin/bucket", form({ action: "save", access_key_id: READ_ONLY, secret_access_key: "x" }), ); expect(refused.status).toBe(400); expect(await refused.text()).toContain("The key cannot write"); expect(await Bun.file(configPath).text()).toBe(original); }); test("save rewrites kepr.toml, keeps blank secrets and applies at once", async () => { const { app, browser, configPath, shared } = await build(); const endpoint = `http://127.0.0.1:${second.port}`; const saved = await browser.post( "/admin/bucket", form({ action: "save", endpoint, region: "nl-ams", }), ); expect(saved.status).toBe(303); expect(saved.headers.get("location")).toBe("/admin/bucket?saved=1"); const reloaded = await loadConfig(configPath); expect(reloaded.bucket).toEqual({ kind: "s3", endpoint, name: "koh", region: "nl-ams", accessKeyId: "KEY-ID", secretAccessKey: "KEY-SECRET", }); expect(reloaded.adminPasswordHash).toBe(HASH); expect((await stat(configPath)).mode & 0o777).toBe(0o600); expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith(`${endpoint}/koh/app/x`); const csp = (await app.request("/")).headers.get("content-security-policy") ?? ""; expect(csp).toContain(endpoint); expect(csp).not.toContain(`127.0.0.1:${first.port}`); expect((await browser.get("/connect?machine=laptop")).status).toBe(200); const page = await (await browser.get("/admin/bucket")).text(); expect(page).not.toContain("KEY-SECRET"); }); test("refuses a malformed form before testing anything", async () => { const { browser, configPath, original } = await build(); const cases: [Record, string][] = [ [{ endpoint: "ftp://example.com" }, "The endpoint must be an http or https URL."], [{ name: "" }, "The bucket name is required."], [{ region: "fr\npar" }, "The region cannot contain spaces or control characters."], ]; for (const [fields, error] of cases) { const response = await browser.post("/admin/bucket", form({ action: "save", ...fields })); expect(response.status).toBe(400); expect(await response.text()).toContain(error); } expect(await Bun.file(configPath).text()).toBe(original); }); test("a folder instance can switch to S3 from the console", async () => { const { browser, configPath, config, shared } = await build(true); const text = await (await browser.get("/admin/bucket")).text(); expect(text).toContain(config.bucket.kind === "folder" ? config.bucket.path : ""); expect(text).toContain('name="endpoint"'); const secretless = await browser.post("/admin/bucket", form({ action: "save" })); expect(secretless.status).toBe(400); expect(await secretless.text()).toContain("The secret key is required."); const saved = await browser.post( "/admin/bucket", form({ action: "save", secret_access_key: "KEY-SECRET" }), ); expect(saved.status).toBe(303); expect((await loadConfig(configPath)).bucket).toMatchObject({ kind: "s3", name: "koh" }); expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith( `http://127.0.0.1:${first.port}/koh/app/x`, ); }); test("an S3 instance can switch to a folder remote, which must exist", async () => { const { browser, configPath, shared, original } = await build(); const missing = await browser.post("/admin/bucket", { action: "folder", folder: "/no/such/dir", }); expect(missing.status).toBe(400); expect(await missing.text()).toContain("is not a folder Kepr can read"); const relative = await browser.post("/admin/bucket", { action: "folder", folder: "bucket" }); expect(relative.status).toBe(400); expect(await Bun.file(configPath).text()).toBe(original); const folder = await mkdtemp(join(tmpdir(), "kepr-folder-remote-")); const saved = await browser.post("/admin/bucket", { action: "folder", folder }); expect(saved.status).toBe(303); expect((await loadConfig(configPath)).bucket).toEqual({ kind: "folder", path: folder }); expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith("file://"); }); });