1import { beforeAll, describe, expect, test } from "bun:test";2import { mkdtemp, writeFile } from "node:fs/promises";3import { tmpdir } from "node:os";4import { join } from "node:path";5import type { Hono } from "hono";6import { openBucket } from "../src/bucket/reader.ts";7import { syncAll } from "../src/bucket/sync.ts";8import { fileAt, objectBytes } from "../src/mirror/queries.ts";9import { openMirror } from "../src/mirror/store.ts";10import { createApp } from "../src/server/app.ts";11import { openKeprDb } from "../src/server/db.ts";12import { type Fixture, makeKohBucket } from "./fixture.ts";1314let fx: Fixture;15let app: Hono;16let mirrorDir: string;17const get = (path: string) => app.request(path);18const body = async (path: string) => (await get(path)).text();1920beforeAll(async () => {21 fx = await makeKohBucket();22 const secretRepo = await makeKohBucket("hidden");23 const large = await makeKohBucket("large");24 25 await writeFile(26 join(large.repoDir, "big.txt"),27 `${"a".repeat(1024 * 1024 - 1)}é${"b".repeat(1000)}\n`,28 );29 await writeFile(join(large.repoDir, "big.bin"), new Uint8Array(1024 * 1024 + 500).fill(0xff));30 Bun.spawnSync(["koh3", "save", "add large files"], {31 cwd: large.repoDir,32 env: { PATH: process.env.PATH, HOME: join(large.repoDir, "../../home"), NO_COLOR: "1" },33 });34 const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));35 mirrorDir = join(dataDir, "mirror");36 const kepr = openKeprDb(dataDir);37 for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir]) {38 await syncAll({ bucket: openBucket({ kind: "folder", path: bucketDir }), kepr, mirrorDir });39 }40 kepr.run(41 "UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",42 );43 kepr.run("UPDATE repos SET public = 1 WHERE name = 'large'");44 app = createApp(45 {46 domain: "kepr.example",47 dataDir,48 adminPasswordHash: "$argon2id$x",49 bucket: { kind: "folder", path: fx.bucketDir },50 },51 kepr,52 );53});5455const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);5657describe("the visibility gate", () => {58 test("a private repo and an unknown one are the same 404, everywhere", async () => {59 for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {60 const hidden = await get(`/hidden${suffix}`);61 const missing = await get(`/nowhere${suffix}`);62 expect(hidden.status).toBe(404);63 expect(missing.status).toBe(404);64 expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());65 }66 });67});6869describe("the repo page", () => {70 test("renders header, timeline, history and README at HEAD with a short cache", async () => {71 const response = await get("/demo");72 expect(response.status).toBe(200);73 expect(response.headers.get("cache-control")).toBe("public, max-age=60");74 const page = await response.text();75 expect(page).toContain('<h1 class="repo-name">demo</h1>');76 expect(page).toContain("A small repo for <strong>Kepr's</strong> tests.");77 expect(page).toContain(78 '<meta property="og:description" content="A small repo for Kepr's tests.">',79 );80 expect(page).toContain('href="https://demo.example"');81 expect(page).toContain('<span class="lang-badge">TypeScript</span>');82 expect(page).toContain("3 saves");83 expect(page).toContain('data-copy="nix profile add x"');84 expect(page.match(/class="dot d\d"/g)?.length).toBe(3);85 expect(page).toContain("density = activity · position = time");86 const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);87 expect(history).toEqual([head(), second(), first()]);88 });8990 test("checkpoints never appear", async () => {91 expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);92 });9394 test("the README points relative links and images at raw files and drops unsafe links", async () => {95 const page = await body("/demo");96 expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');97 expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');98 expect(page).toContain('<a href="https://example.com">the site</a>');99 expect(page).not.toContain('href="javascript:');100 expect(page).toContain("<script>alert");101 expect(page).not.toContain("<script>alert");102 });103});104105describe("the file viewer", () => {106 test("shows a file at HEAD and at an old save, with line anchors", async () => {107 const now = await get("/demo/files/src/main.ts");108 expect(now.headers.get("cache-control")).toBe("public, max-age=60");109 const nowPage = await now.text();110 expect(nowPage).toContain("hello");111 expect(nowPage).toContain(112 '<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',113 );114 expect(nowPage).toContain('class="tk-');115116 const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);117 expect(then.headers.get("cache-control")).toBe("public, max-age=31536000");118 const thenPage = await then.text();119 expect(thenPage).toContain("'hi'");120 expect(thenPage).not.toContain("hello");121 expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);122 });123124 test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {125 expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);126 expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);127 expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);128 expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);129 expect((await get("/demo/files/nothing.txt")).status).toBe(404);130 });131132 test("Markdown renders, with a source toggle", async () => {133 const rendered = await body("/demo/files/docs/guide.md");134 expect(rendered).toContain("<h1>Guide</h1>");135 const source = await body("/demo/files/docs/guide.md?view=source");136 expect(source).toContain('id="L1"');137 expect(source).not.toContain("<h1>Guide</h1>");138 });139140 test("an image displays inline from its raw file", async () => {141 expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');142 });143});144145describe("files over 1 MB", () => {146 test("text shows its first 1 MB with a raw link, even with a character cut at the mark", async () => {147 const page = await body("/large/files/big.txt");148 expect(page).toContain("showing the first 1 MB");149 expect(page).toContain('href="/large/raw/big.txt"');150 expect(page).not.toContain("bbb");151 const raw = await get("/large/raw/big.txt");152 expect(raw.headers.get("content-type")).toBe("text/plain; charset=utf-8");153 });154155 test("invalid UTF-8 without a NUL is binary, not text", async () => {156 const page = await body("/large/files/big.bin");157 expect(page).toContain("binary file");158 expect(page).not.toContain('class="code"');159 const raw = await get("/large/raw/big.bin");160 expect(raw.headers.get("content-type")).toBe("application/octet-stream");161 });162});163164describe("raw files", () => {165 test("are the exact object bytes, text as text/plain", async () => {166 const mirror = openMirror(mirrorDir, "demo");167 for (const [path, type] of [168 ["src/main.ts", "text/plain; charset=utf-8"],169 ["docs/logo.png", "image/png"],170 ["README.md", "text/plain; charset=utf-8"],171 ] as const) {172 const response = await get(`/demo/raw/${path}`);173 const file = fileAt(mirror, head(), path);174 expect(response.headers.get("content-type")).toBe(type);175 expect(new Uint8Array(await response.arrayBuffer())).toEqual(176 objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,177 );178 }179 expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");180 });181});182183test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {184 const css = await get("/static/css/kepr.css");185 expect(css.status).toBe(200);186 expect(css.headers.get("content-type")).toContain("text/css");187 expect((await get("/static/../package.json")).status).toBe(404);188 const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";189 expect(csp).toContain("style-src 'self' https://fonts.bunny.net");190 expect(csp).toContain("font-src https://fonts.bunny.net");191 expect(csp).not.toContain("unsafe-inline");192});