1import { beforeAll, describe, expect, test } from "bun:test";2import { mkdtemp } 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 dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));24 mirrorDir = join(dataDir, "mirror");25 const kepr = openKeprDb(dataDir);26 for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir]) {27 await syncAll({ bucket: openBucket({ kind: "folder", path: bucketDir }), kepr, mirrorDir });28 }29 kepr.run(30 "UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",31 );32 app = createApp(33 {34 domain: "kepr.example",35 dataDir,36 adminPasswordHash: "$argon2id$x",37 bucket: { kind: "folder", path: fx.bucketDir },38 },39 kepr,40 );41});4243const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);4445describe("the visibility gate", () => {46 test("a private repo and an unknown one are the same 404, everywhere", async () => {47 for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {48 const hidden = await get(`/hidden${suffix}`);49 const missing = await get(`/nowhere${suffix}`);50 expect(hidden.status).toBe(404);51 expect(missing.status).toBe(404);52 expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());53 }54 });55});5657describe("the repo page", () => {58 test("renders header, timeline, history and README at HEAD with a short cache", async () => {59 const response = await get("/demo");60 expect(response.status).toBe(200);61 expect(response.headers.get("cache-control")).toBe("public, max-age=60");62 const page = await response.text();63 expect(page).toContain('<h1 class="repo-name">demo</h1>');64 expect(page).toContain("A small repo for <strong>Kepr's</strong> tests.");65 expect(page).toContain(66 '<meta property="og:description" content="A small repo for Kepr's tests.">',67 );68 expect(page).toContain('href="https://demo.example"');69 expect(page).toContain('<span class="lang-badge">TypeScript</span>');70 expect(page).toContain("3 saves");71 expect(page).toContain('data-copy="nix profile add x"');72 expect(page.match(/class="dot d\d"/g)?.length).toBe(3);73 expect(page).toContain("density = activity · position = time");74 const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);75 expect(history).toEqual([head(), second(), first()]);76 });7778 test("checkpoints never appear", async () => {79 expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);80 });8182 test("the README points relative links and images at raw files and drops unsafe links", async () => {83 const page = await body("/demo");84 expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');85 expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');86 expect(page).toContain('<a href="https://example.com">the site</a>');87 expect(page).not.toContain('href="javascript:');88 expect(page).toContain("<script>alert");89 expect(page).not.toContain("<script>alert");90 });91});9293describe("the file viewer", () => {94 test("shows a file at HEAD and at an old save, with line anchors", async () => {95 const now = await get("/demo/files/src/main.ts");96 expect(now.headers.get("cache-control")).toBe("public, max-age=60");97 const nowPage = await now.text();98 expect(nowPage).toContain("hello");99 expect(nowPage).toContain(100 '<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',101 );102 expect(nowPage).toContain('class="tk-');103104 const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);105 expect(then.headers.get("cache-control")).toBe("public, max-age=31536000");106 const thenPage = await then.text();107 expect(thenPage).toContain("'hi'");108 expect(thenPage).not.toContain("hello");109 expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);110 });111112 test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {113 expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);114 expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);115 expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);116 expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);117 expect((await get("/demo/files/nothing.txt")).status).toBe(404);118 });119120 test("Markdown renders, with a source toggle", async () => {121 const rendered = await body("/demo/files/docs/guide.md");122 expect(rendered).toContain("<h1>Guide</h1>");123 const source = await body("/demo/files/docs/guide.md?view=source");124 expect(source).toContain('id="L1"');125 expect(source).not.toContain("<h1>Guide</h1>");126 });127128 test("an image displays inline from its raw file", async () => {129 expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');130 });131});132133describe("raw files", () => {134 test("are the exact object bytes, text as text/plain", async () => {135 const mirror = openMirror(mirrorDir, "demo");136 for (const [path, type] of [137 ["src/main.ts", "text/plain; charset=utf-8"],138 ["docs/logo.png", "image/png"],139 ["README.md", "text/plain; charset=utf-8"],140 ] as const) {141 const response = await get(`/demo/raw/${path}`);142 const file = fileAt(mirror, head(), path);143 expect(response.headers.get("content-type")).toBe(type);144 expect(new Uint8Array(await response.arrayBuffer())).toEqual(145 objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,146 );147 }148 expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");149 });150});151152test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {153 const css = await get("/static/css/kepr.css");154 expect(css.status).toBe(200);155 expect(css.headers.get("content-type")).toContain("text/css");156 expect((await get("/static/../package.json")).status).toBe(404);157 const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";158 expect(csp).toContain("style-src 'self' https://fonts.bunny.net");159 expect(csp).toContain("font-src https://fonts.bunny.net");160 expect(csp).not.toContain("unsafe-inline");161});