1234import { beforeAll, describe, expect, test } from "bun:test";5import { mkdir, writeFile } from "node:fs/promises";6import { join } from "node:path";7import { openBucket } from "../src/bucket/reader.ts";8import { syncAll } from "../src/bucket/sync.ts";9import { openMirror } from "../src/mirror/store.ts";10import { Browser, type Instance, instance } from "./browser.ts";11import { makeRepos } from "./fixture.ts";1213describe("hidden paths", () => {14 let inst: Instance;15 let browser: Browser;16 let repos: Awaited<ReturnType<typeof makeRepos>>;17 let work: string;18 const status = async (path: string) => (await inst.app.request(path)).status;19 const text = async (path: string) => (await inst.app.request(path)).text();20 const hide = (hidden_paths: string) =>21 browser.post("/admin/repos/app", { public: "1", hidden_paths });22 23 const sync = (fail = "\0") => {24 const bucket = openBucket({ kind: "folder", path: repos.bucketDir });25 const { put, list } = bucket;26 const reach = (key: string) =>27 key.startsWith(fail) ? Promise.reject(new Error("bucket unreachable")) : Promise.resolve();28 bucket.put = (key, ...rest) => reach(key).then(() => put(key, ...rest));29 bucket.list = (prefix) => reach(prefix).then(() => list(prefix));30 return syncAll({ bucket, kepr: inst.kepr, mirrorDir: join(inst.dataDir, "mirror") });31 };32 33 const handed = async () => {34 await sync();35 const file = Bun.file(join(repos.bucketDir, ".kepr/hidden/app"));36 return (await file.exists()) ? await file.text() : null;37 };3839 beforeAll(async () => {40 repos = await makeRepos(["app"]);41 work = join(repos.bucketDir, "..", "work", "app");42 await mkdir(join(work, "private"));43 await writeFile(join(work, "private", "notes.md"), "# private\n");44 await writeFile(join(work, "tool.py"), "print(1)\n");45 await writeFile(join(work, "main.ts"), "export const a = 1;\n");46 repos.koh("app", "save", "add sources");47 repos.koh("app", "offer");48 inst = await instance(repos.bucketDir);49 browser = new Browser(inst.app);50 await browser.signIn();51 await hide("");52 });5354 test("refuses unsafe paths and changes nothing", async () => {55 for (const path of ["../x", "/etc", "a//b", "x".repeat(501)]) {56 expect((await hide(path)).status).toBe(400);57 }58 expect(await status("/app/files/tool.py")).toBe(200);59 });6061 test("a hidden file or folder is gone everywhere and handed to Koh", async () => {62 const saved = await hide("tool.py\r\nprivate/\n");63 expect(saved.status).toBe(303);64 for (const path of ["/app/files/tool.py", "/app/raw/tool.py", "/app/raw/private/notes.md"]) {65 expect(`${path} ${await status(path)}`).toBe(`${path} 404`);66 }67 expect(await status("/app/files/main.ts")).toBe(200);68 const page = await text("/app");69 expect(page).not.toContain("tool.py");70 expect(page).not.toContain("notes.md");71 expect(page).not.toContain("Python");72 const head = (await inst.app.request("/app/archive/HEAD.tar.gz")).headers.get("location");73 const tarball = new Uint8Array(await (await inst.app.request(head ?? "")).arrayBuffer());74 const listed = Bun.spawnSync(["tar", "-tzf", "-"], { stdin: tarball }).stdout.toString();75 expect(listed).toContain("main.ts");76 expect(listed).not.toContain("tool.py");77 expect(await handed()).toBe("tool.py\nprivate\n");78 expect(await (await browser.get("/admin/repos?repo=app&tab=settings")).text()).toContain(79 ">tool.py\nprivate</textarea>",80 );81 });8283 test("stays hidden in saves that arrive later", async () => {84 await writeFile(join(work, "tool.py"), "print(2)\n");85 repos.koh("app", "save", "change the tool");86 repos.koh("app", "offer");87 await sync();88 expect(await status("/app/files/tool.py")).toBe(404);89 expect(await text("/app")).toContain("change the tool");90 });9192 test("unhiding brings the files back", async () => {93 expect((await hide("")).status).toBe(303);94 expect(await status("/app/files/tool.py")).toBe(200);95 expect(await text("/app/raw/tool.py")).toBe("print(2)\n");96 expect(await status("/app/raw/private/notes.md")).toBe(200);97 expect(await handed()).toBe("");98 });99100 test("an old kept tarball is never served once a path is hidden, even if cleanup failed", async () => {101 const head = (await inst.app.request("/app/archive/HEAD.tar.gz")).headers.get("location") ?? "";102 await inst.app.request(head);103 const keptDir = join(repos.bucketDir, ".kepr/tarballs/app");104 const old = new Map<string, Uint8Array>();105 for (const path of new Bun.Glob("**/*.tar.gz").scanSync(keptDir)) {106 old.set(path, await Bun.file(join(keptDir, path)).bytes());107 }108 expect(old.size).toBeGreaterThan(0);109 await hide("tool.py");110 for (const [path, bytes] of old) await Bun.write(join(keptDir, path), bytes);111 const tarball = new Uint8Array(await (await inst.app.request(head)).arrayBuffer());112 const listed = Bun.spawnSync(["tar", "-tzf", "-"], { stdin: tarball }).stdout.toString();113 expect(listed).toContain("main.ts");114 expect(listed).not.toContain("tool.py");115 await hide("");116 });117118 test("a list the bucket refused is handed at a later sync", async () => {119 await hide("tool.py");120 expect(await status("/app/files/tool.py")).toBe(404);121 await sync(".kepr/hidden/");122 expect(await handed()).toBe("tool.py\n");123 await hide("");124 await sync();125 });126127 test("a list written before a failed cleanup is not undone by changing back", async () => {128 await hide("tool.py");129 await sync(".kepr/tarballs/");130 await hide("");131 expect(await handed()).toBe("");132 });133134 test("a hidden file's object is neither listed nor served on the Koh routes", async () => {135 const mirror = openMirror(join(inst.dataDir, "mirror"), "app");136 const hash = mirror137 .query<{ hash: string }, []>("SELECT hash FROM save_files WHERE path = 'private/notes.md'")138 .get()?.hash as string;139 await hide("private");140 expect(await text("/app/koh/objects")).not.toContain(hash);141 expect(await status(`/app/koh/objects/${hash}`)).toBe(404);142 await hide("");143 expect(await text("/app/koh/objects")).toContain(hash);144 expect(await status(`/app/koh/objects/${hash}`)).toBe(200);145 });146147 test("rebuilding a mirror keeps handles that requests already hold usable", async () => {148 const mirror = openMirror(join(inst.dataDir, "mirror"), "app");149 const count = () =>150 mirror151 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM save_files WHERE path = 'tool.py'")152 .get()?.n;153 await hide("tool.py");154 expect(count()).toBe(0);155 await hide("");156 expect(count()).toBeGreaterThan(0);157 });158});