// Covers per-repo hidden paths (June, 2026-09-30): gone from every page, the stats and new // tarballs, kept hidden through later syncs, handed to Koh under `.kepr/hidden/`, and back on unhide. import { beforeAll, describe, expect, test } from "bun:test"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { openBucket } from "../src/bucket/reader.ts"; import { syncAll } from "../src/bucket/sync.ts"; import { openMirror } from "../src/mirror/store.ts"; import { Browser, type Instance, instance } from "./browser.ts"; import { makeRepos } from "./fixture.ts"; describe("hidden paths", () => { let inst: Instance; let browser: Browser; let repos: Awaited>; let work: string; const status = async (path: string) => (await inst.app.request(path)).status; const text = async (path: string) => (await inst.app.request(path)).text(); const hide = (hidden_paths: string) => browser.post("/admin/repos/app", { public: "1", hidden_paths }); // `fail` makes calls under that key prefix fail, as an unreachable bucket would. const sync = (fail = "\0") => { const bucket = openBucket({ kind: "folder", path: repos.bucketDir }); const { put, list } = bucket; const reach = (key: string) => key.startsWith(fail) ? Promise.reject(new Error("bucket unreachable")) : Promise.resolve(); bucket.put = (key, ...rest) => reach(key).then(() => put(key, ...rest)); bucket.list = (prefix) => reach(prefix).then(() => list(prefix)); return syncAll({ bucket, kepr: inst.kepr, mirrorDir: join(inst.dataDir, "mirror") }); }; // The mirror has a saved list before the response; the next sync hands it to Koh. const handed = async () => { await sync(); const file = Bun.file(join(repos.bucketDir, ".kepr/hidden/app")); return (await file.exists()) ? await file.text() : null; }; beforeAll(async () => { repos = await makeRepos(["app"]); work = join(repos.bucketDir, "..", "work", "app"); await mkdir(join(work, "private")); await writeFile(join(work, "private", "notes.md"), "# private\n"); await writeFile(join(work, "tool.py"), "print(1)\n"); await writeFile(join(work, "main.ts"), "export const a = 1;\n"); repos.koh("app", "save", "add sources"); repos.koh("app", "offer"); inst = await instance(repos.bucketDir); browser = new Browser(inst.app); await browser.signIn(); await hide(""); }); test("refuses unsafe paths and changes nothing", async () => { for (const path of ["../x", "/etc", "a//b", "x".repeat(501)]) { expect((await hide(path)).status).toBe(400); } expect(await status("/app/files/tool.py")).toBe(200); }); test("a hidden file or folder is gone everywhere and handed to Koh", async () => { const saved = await hide("tool.py\r\nprivate/\n"); expect(saved.status).toBe(303); for (const path of ["/app/files/tool.py", "/app/raw/tool.py", "/app/raw/private/notes.md"]) { expect(`${path} ${await status(path)}`).toBe(`${path} 404`); } expect(await status("/app/files/main.ts")).toBe(200); const page = await text("/app"); expect(page).not.toContain("tool.py"); expect(page).not.toContain("notes.md"); expect(page).not.toContain("Python"); const head = (await inst.app.request("/app/archive/HEAD.tar.gz")).headers.get("location"); const tarball = new Uint8Array(await (await inst.app.request(head ?? "")).arrayBuffer()); const listed = Bun.spawnSync(["tar", "-tzf", "-"], { stdin: tarball }).stdout.toString(); expect(listed).toContain("main.ts"); expect(listed).not.toContain("tool.py"); expect(await handed()).toBe("tool.py\nprivate\n"); expect(await (await browser.get("/admin/repos?repo=app")).text()).toContain( ">tool.py\nprivate", ); }); test("stays hidden in saves that arrive later", async () => { await writeFile(join(work, "tool.py"), "print(2)\n"); repos.koh("app", "save", "change the tool"); repos.koh("app", "offer"); await sync(); expect(await status("/app/files/tool.py")).toBe(404); expect(await text("/app")).toContain("change the tool"); }); test("unhiding brings the files back", async () => { expect((await hide("")).status).toBe(303); expect(await status("/app/files/tool.py")).toBe(200); expect(await text("/app/raw/tool.py")).toBe("print(2)\n"); expect(await status("/app/raw/private/notes.md")).toBe(200); expect(await handed()).toBe(""); }); test("an old kept tarball is never served once a path is hidden, even if cleanup failed", async () => { const head = (await inst.app.request("/app/archive/HEAD.tar.gz")).headers.get("location") ?? ""; await inst.app.request(head); const keptDir = join(repos.bucketDir, ".kepr/tarballs/app"); const old = new Map(); for (const path of new Bun.Glob("**/*.tar.gz").scanSync(keptDir)) { old.set(path, await Bun.file(join(keptDir, path)).bytes()); } expect(old.size).toBeGreaterThan(0); await hide("tool.py"); for (const [path, bytes] of old) await Bun.write(join(keptDir, path), bytes); const tarball = new Uint8Array(await (await inst.app.request(head)).arrayBuffer()); const listed = Bun.spawnSync(["tar", "-tzf", "-"], { stdin: tarball }).stdout.toString(); expect(listed).toContain("main.ts"); expect(listed).not.toContain("tool.py"); await hide(""); }); test("a list the bucket refused is handed at a later sync", async () => { await hide("tool.py"); expect(await status("/app/files/tool.py")).toBe(404); await sync(".kepr/hidden/"); expect(await handed()).toBe("tool.py\n"); await hide(""); await sync(); }); test("a list written before a failed cleanup is not undone by changing back", async () => { await hide("tool.py"); await sync(".kepr/tarballs/"); await hide(""); expect(await handed()).toBe(""); }); test("a hidden file's object is neither listed nor served on the Koh routes", async () => { const mirror = openMirror(join(inst.dataDir, "mirror"), "app"); const hash = mirror .query<{ hash: string }, []>("SELECT hash FROM save_files WHERE path = 'private/notes.md'") .get()?.hash as string; await hide("private"); expect(await text("/app/koh/objects")).not.toContain(hash); expect(await status(`/app/koh/objects/${hash}`)).toBe(404); await hide(""); expect(await text("/app/koh/objects")).toContain(hash); expect(await status(`/app/koh/objects/${hash}`)).toBe(200); }); test("rebuilding a mirror keeps handles that requests already hold usable", async () => { const mirror = openMirror(join(inst.dataDir, "mirror"), "app"); const count = () => mirror .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM save_files WHERE path = 'tool.py'") .get()?.n; await hide("tool.py"); expect(count()).toBe(0); await hide(""); expect(count()).toBeGreaterThan(0); }); });