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 const bucket = openBucket({ kind: "folder", path: bucketDir });39 await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });40 }41 kepr.run(42 "UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",43 );44 kepr.run("UPDATE repos SET public = 1 WHERE name = 'large'");45 app = createApp(46 {47 domain: "kepr.example",48 dataDir,49 adminPasswordHash: "$argon2id$x",50 bucket: { kind: "folder", path: fx.bucketDir },51 },52 kepr,53 );54});5556const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);5758describe("the visibility gate", () => {59 test("a private repo and an unknown one are the same 404, everywhere", async () => {60 for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {61 const hidden = await get(`/hidden${suffix}`);62 const missing = await get(`/nowhere${suffix}`);63 expect(hidden.status).toBe(404);64 expect(missing.status).toBe(404);65 expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());66 }67 });68});6970describe("the repo page", () => {71 test("renders header, timeline, history and README at HEAD with a short cache", async () => {72 const response = await get("/demo");73 expect(response.status).toBe(200);74 expect(response.headers.get("cache-control")).toBe("public, max-age=60");75 const page = await response.text();76 expect(page).toContain('<h1 class="repo-name">demo</h1>');77 78 expect(/<div class="repo-desc"><p>(.*?)<\/p>/.exec(page)?.[1]).toBe(79 "A small repo for <strong>Kepr's</strong> tests.",80 );81 expect(page).toContain(82 '<meta property="og:description" content="A small repo for Kepr's tests.">',83 );84 expect(page).toContain('href="https://demo.example"');85 expect(page).toContain('<span class="lang-badge">TypeScript</span>');86 expect(page).toContain("3 saves");87 expect(page).toContain('data-copy="nix profile add x"');88 expect(page.match(/class="dot d\d"/g)?.length).toBe(3);89 expect(page).toContain("density = activity · position = time");90 const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);91 expect(history).toEqual([head(), second(), first()]);92 });9394 test("checkpoints never appear", async () => {95 expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);96 });9798 test("the README points relative links and images at raw files and drops unsafe links", async () => {99 const page = await body("/demo");100 expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');101 expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');102 expect(page).toContain('<a href="https://example.com">the site</a>');103 expect(page).not.toContain('href="javascript:');104 expect(page).toContain("<script>alert");105 expect(page).not.toContain("<script>alert");106 });107});108109describe("the file viewer", () => {110 test("shows a file at HEAD and at an old save, with line anchors", async () => {111 const now = await get("/demo/files/src/main.ts");112 expect(now.headers.get("cache-control")).toBe("public, max-age=60");113 const nowPage = await now.text();114 expect(nowPage).toContain("hello");115 expect(nowPage).toContain(116 '<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',117 );118 expect(nowPage).toContain('class="tk-');119120 const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);121 expect(then.headers.get("cache-control")).toBe("public, max-age=31536000");122 const thenPage = await then.text();123 expect(thenPage).toContain("'hi'");124 expect(thenPage).not.toContain("hello");125 expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);126 });127128 test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {129 expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);130 expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);131 expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);132 expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);133 expect((await get("/demo/files/nothing.txt")).status).toBe(404);134 });135136 test("Markdown renders, with a source toggle", async () => {137 const rendered = await body("/demo/files/docs/guide.md");138 expect(rendered).toContain("<h1>Guide</h1>");139 const source = await body("/demo/files/docs/guide.md?view=source");140 expect(source).toContain('id="L1"');141 expect(source).not.toContain("<h1>Guide</h1>");142 });143144 test("an image displays inline from its raw file", async () => {145 expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');146 });147});148149describe("files over 1 MB", () => {150 test("text shows its first 1 MB with a raw link, even with a character cut at the mark", async () => {151 const page = await body("/large/files/big.txt");152 expect(page).toContain("showing the first 1 MB");153 expect(page).toContain('href="/large/raw/big.txt"');154 expect(page).not.toContain("bbb");155 const raw = await get("/large/raw/big.txt");156 expect(raw.headers.get("content-type")).toBe("text/plain; charset=utf-8");157 });158159 test("invalid UTF-8 without a NUL is binary, not text", async () => {160 const page = await body("/large/files/big.bin");161 expect(page).toContain("binary file");162 expect(page).not.toContain('class="code"');163 const raw = await get("/large/raw/big.bin");164 expect(raw.headers.get("content-type")).toBe("application/octet-stream");165 });166});167168describe("the Atlas", () => {169 170 const panelOf = (page: string, name: string) =>171 new RegExp(`data-name="${name}">.*?</a>\\n<template>(.*?)</template>`, "s").exec(page)?.[1] ??172 "";173 const rowOf = (page: string, name: string) =>174 new RegExp(`data-name="${name}">(.*?)</a>`, "s").exec(page)?.[1] ?? "";175176 test("sits between the timeline and History", async () => {177 const page = await body("/demo");178 const atlas = page.indexOf('id="atlas"');179 expect(page.indexOf('class="timeline"')).toBeLessThan(atlas);180 expect(atlas).toBeLessThan(page.indexOf('id="history"'));181 });182183 test("is a folder tree, folders first, then files, alphabetical", async () => {184 const names = [...(await body("/demo")).matchAll(/class="atlas-name">([^<]*)</g)].map(185 (m) => m[1],186 );187 expect(names).toEqual([188 "docs/",189 "guide.md",190 "logo.png",191 "src/",192 "lib/",193 "deep/",194 "util.ts",195 "main.ts",196 "vendor/",197 "big.c",198 ".kohignore",199 "README.md",200 "secret.txt",201 ]);202 });203204 test("rows carry the first sentence of the file's own comment, rendered, and link to the file", async () => {205 const page = await body("/demo");206 expect(rowOf(page, "util.ts")).toContain(207 '<span class="atlas-desc">The <em>one</em> helper.</span>',208 );209 expect(rowOf(page, "main.ts")).toContain("Entry point.");210 expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');211 expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');212 expect(page).toContain(213 '<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts">',214 );215 expect(panelOf(page, "util.ts")).toContain("<p>The <em>one</em> helper. Deep down.</p>");216 });217218 test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {219 const page = await body("/demo");220 const main = panelOf(page, "main.ts");221 expect(main).toContain("<dt>lines</dt><dd>2</dd>");222 expect(main).toContain("<dt>saves</dt><dd>2</dd>");223 expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);224 expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);225 expect(main.indexOf(head())).toBeLessThan(main.lastIndexOf(second()));226 const guide = panelOf(page, "guide.md");227 expect(guide).toContain("<dt>saves</dt><dd>2</dd>");228 expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);229 expect(panelOf(page, "logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");230 expect(page).not.toContain(fx.saveIds[1] as string);231 });232233 test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {234 const page = await body("/demo");235 const folderPanel = (path: string) =>236 new RegExp(237 `data-path="${path}">\\n<summary.*?</summary>\\n<template>(.*?)</template>`,238 "s",239 ).exec(page)?.[1] ?? "";240 const docs = folderPanel("docs");241 expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');242 expect(docs).toContain("<dt>lines</dt><dd>3</dd>");243 expect(docs).toContain("<dt>files</dt><dd>2</dd>");244 expect(docs).toContain("<dt>saves</dt><dd>2</dd>");245 expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);246 expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);247 const src = folderPanel("src");248 expect(src).toContain("<dt>saves</dt><dd>3</dd>");249 const recentIds = [250 ...(/<ul class="atlas-recent">(.*?)<\/ul>/s.exec(src)?.[1] ?? "").matchAll(251 /saves\/([0-9a-f]+)/g,252 ),253 ].map((m) => m[1]);254 expect(recentIds).toEqual([head(), second(), first()]);255 });256257 test("can be viewed at an older save, and an unknown save is a 404", async () => {258 const page = await body(`/demo?at=${second().slice(0, 8)}`);259 expect(page).toContain(`at <a href="/demo/saves/${second()}">${second()}</a>`);260 expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);261 expect(panelOf(page, "main.ts")).toContain("<dt>saves</dt><dd>1</dd>");262 expect(panelOf(page, "main.ts")).not.toContain(head());263 const oldest = await body(`/demo?at=${first()}`);264 expect(oldest).not.toContain('data-name="main.ts"');265 expect((await get("/demo?at=abcdef")).status).toBe(404);266 expect((await get("/demo?at=../x")).status).toBe(404);267 });268});269270describe("the save page", () => {271 test("shows the save's facts and diffs against the previous real save, skipping the checkpoint", async () => {272 const response = await get(`/demo/saves/${head()}`);273 expect(response.status).toBe(200);274 expect(response.headers.get("cache-control")).toBe("public, max-age=60");275 const page = await response.text();276 expect(page).toContain('<h1 class="save-message-head">say hello</h1>');277 expect(page).toContain(278 `<dt>parent</dt><dd><a href="/demo/saves/${second()}">${second()}</a></dd>`,279 );280 expect(page).not.toContain(fx.saveIds[1] as string);281 expect(page).not.toContain("save-flag");282 283 const rows = [284 ...page.matchAll(285 /class="change-row change-(\w+)" href="#diff-\d"><span class="change-path">([^<]+)</g,286 ),287 ];288 expect(rows.map((m) => [m[1], m[2]])).toEqual([289 ["modified", "docs/guide.md"],290 ["modified", "src/main.ts"],291 ]);292 expect(page).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');293 expect(page).toContain('<span class="dh">@@ -1,2 +1,2 @@</span>');294 expect(page).toContain(295 '<span class="dl dl-del"><span class="ln">2</span><span class="ln"></span><span class="ds">-</span>',296 );297 expect(page).toContain('class="tk-');298 expect(page).toContain(`href="/demo/archive/${head()}.tar.gz"`);299 expect(page).toContain(`href="/demo?at=${head()}#atlas"`);300 });301302 test("the first save diffs against nothing, notes binaries, and shows its flag and agent", async () => {303 const page = await body(`/demo/saves/${first()}`);304 expect(page).toContain('<span class="save-flag-label mono">flagged</span> holds a secret');305 expect(page).toContain("<dt>parent</dt><dd>none, the first save</dd>");306 expect(page).toContain(307 'change-added" href="#diff-2"><span class="change-path">docs/logo.png</span><span class="change-stat mono"><span class="diff-note">binary</span>',308 );309 expect(page).toContain("binary file, not shown");310 expect(page).toContain('<span class="dh">@@ -0,0 +1 @@</span>');311 const second_ = await body(`/demo/saves/${second()}`);312 expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");313 expect(second_).not.toContain("save-flag");314 });315316 test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {317 const short = await get(`/demo/saves/${head().slice(0, 6)}`);318 expect(short.status).toBe(302);319 expect(short.headers.get("location")).toBe(`/demo/saves/${head()}`);320 expect((await get(`/demo/saves/${fx.saveIds[1]}`)).status).toBe(404);321 expect((await get("/demo/saves/abcdef")).status).toBe(404);322 expect((await get("/demo/saves/abc")).status).toBe(404);323 expect((await get(`/hidden/saves/${head()}`)).status).toBe(404);324 });325});326327describe("raw files", () => {328 test("are the exact object bytes, text as text/plain", async () => {329 const mirror = openMirror(mirrorDir, "demo");330 for (const [path, type] of [331 ["src/main.ts", "text/plain; charset=utf-8"],332 ["docs/logo.png", "image/png"],333 ["README.md", "text/plain; charset=utf-8"],334 ] as const) {335 const response = await get(`/demo/raw/${path}`);336 const file = fileAt(mirror, head(), path);337 expect(response.headers.get("content-type")).toBe(type);338 expect(new Uint8Array(await response.arrayBuffer())).toEqual(339 objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,340 );341 }342 expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");343 });344});345346test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {347 const css = await get("/static/css/kepr.css");348 expect(css.status).toBe(200);349 expect(css.headers.get("content-type")).toContain("text/css");350 expect((await get("/static/../package.json")).status).toBe(404);351 const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";352 expect(csp).toContain("style-src 'self' https://fonts.bunny.net");353 expect(csp).toContain("font-src https://fonts.bunny.net");354 expect(csp).not.toContain("unsafe-inline");355});