// 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://");
});
});