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 KEY_IDS = new Set(["KEY-ID", "NEW-KEY"]);16const READ_ONLY = "READ-ONLY";17const written: string[] = [];18192021function 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});