1234import { 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[] = [];181920function 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 remote is shown read-only and never rewritten", async () => {190 const { browser, configPath, original, config } = 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).not.toContain('name="endpoint"');194 const response = await browser.post("/admin/bucket", form({ action: "save" }));195 expect(response.status).toBe(400);196 expect(await Bun.file(configPath).text()).toBe(original);197 });198});