hidden.test.ts

6.6 KB · raw

1// Covers per-repo hidden paths (June, 2026-09-30): gone from every page, the stats and new2// tarballs, kept hidden through later syncs, handed to Koh under `.kepr/hidden/`, and back on unhide.34import { 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	// `fail` makes calls under that key prefix fail, as an unreachable bucket would.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	// The mirror has a saved list before the response; the next sync hands it to Koh.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")).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});