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;17let tailSaves: string[];18const get = (path: string) => app.request(path);19const body = async (path: string) => (await get(path)).text();2021beforeAll(async () => {22 fx = await makeKohBucket();23 const secretRepo = await makeKohBucket("hidden");24 const large = await makeKohBucket("large");25 26 await writeFile(27 join(large.repoDir, "big.txt"),28 `${"a".repeat(1024 * 1024 - 1)}é${"b".repeat(1000)}\n`,29 );30 await writeFile(join(large.repoDir, "big.bin"), new Uint8Array(1024 * 1024 + 500).fill(0xff));31 Bun.spawnSync(["koh3", "save", "add large files"], {32 cwd: large.repoDir,33 env: { PATH: process.env.PATH, HOME: join(large.repoDir, "../../home"), NO_COLOR: "1" },34 });35 36 const tail = await makeKohBucket("tail");37 const tailEnv = { PATH: process.env.PATH, HOME: join(tail.repoDir, "../../home"), NO_COLOR: "1" };38 for (const [text, message] of [39 ["a\nb", "add tail"],40 ["a\nb\n", "end tail with a newline"],41 ["a\nb", "drop the newline"],42 ] as const) {43 await writeFile(join(tail.repoDir, "tail.txt"), text);44 Bun.spawnSync(["koh3", "save", message], { cwd: tail.repoDir, env: tailEnv });45 }46 tailSaves = JSON.parse(47 Bun.spawnSync(["koh3", "log", "--json"], { cwd: tail.repoDir, env: tailEnv }).stdout.toString(),48 ).saves.map((save: { id: string }) => save.id);49 const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));50 mirrorDir = join(dataDir, "mirror");51 const kepr = openKeprDb(dataDir);52 for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {53 const bucket = openBucket({ kind: "folder", path: bucketDir });54 await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });55 }56 kepr.run(57 "UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",58 );59 kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");60 app = createApp(61 {62 domain: "kepr.example",63 dataDir,64 adminPasswordHash: "$argon2id$x",65 bucket: { kind: "folder", path: fx.bucketDir },66 },67 kepr,68 );69});7071const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);7273describe("the visibility gate", () => {74 test("a private repo and an unknown one are the same 404, everywhere", async () => {75 for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {76 const hidden = await get(`/hidden${suffix}`);77 const missing = await get(`/nowhere${suffix}`);78 expect(hidden.status).toBe(404);79 expect(missing.status).toBe(404);80 expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());81 }82 });83});8485describe("the repo page", () => {86 test("renders header, timeline, history and README at HEAD with a short cache", async () => {87 const response = await get("/demo");88 expect(response.status).toBe(200);89 expect(response.headers.get("cache-control")).toBe("public, max-age=60");90 const page = await response.text();91 expect(page).toContain('<h1 class="repo-name">demo</h1>');92 93 expect(/<div class="repo-desc"><p>(.*?)<\/p>/.exec(page)?.[1]).toBe(94 "A small repo for <strong>Kepr's</strong> tests.",95 );96 expect(page).toContain(97 '<meta property="og:description" content="A small repo for Kepr's tests.">',98 );99 expect(page).toContain('href="https://demo.example"');100 expect(page).toContain('<span class="lang-badge">TypeScript</span>');101 expect(page).toContain("3 saves");102 expect(page).toContain('data-copy="nix profile add x"');103 expect(page.match(/class="dot d\d"/g)?.length).toBe(3);104 expect(page).toContain("density = activity · position = time");105 const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);106 expect(history).toEqual([head(), second(), first()]);107 });108109 test("a page past the last, or too large to bind, is page 1", async () => {110 const history = (page: string) => [...page.matchAll(/class="history-id">([0-9a-f]+)</g)];111 for (const query of ["?page=2", `?page=${"9".repeat(400)}`, "?page=1e999", "?page=x"]) {112 const response = await get(`/demo${query}`);113 expect([query, response.status]).toEqual([query, 200]);114 expect(history(await response.text()).length).toBe(3);115 }116 });117118 test("checkpoints never appear", async () => {119 expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);120 });121122 test("the README points relative links and images at raw files and drops unsafe links", async () => {123 const page = await body("/demo");124 expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');125 expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');126 expect(page).toContain('<a href="https://example.com">the site</a>');127 expect(page).not.toContain('href="javascript:');128 expect(page).toContain("<script>alert");129 expect(page).not.toContain("<script>alert");130 });131});132133describe("the file viewer", () => {134 test("shows a file at HEAD and at an old save, with line anchors", async () => {135 const now = await get("/demo/files/src/main.ts");136 expect(now.headers.get("cache-control")).toBe("public, max-age=60");137 const nowPage = await now.text();138 expect(nowPage).toContain("hello");139 expect(nowPage).toContain(140 '<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',141 );142 expect(nowPage).toContain('class="tk-');143144 const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);145 expect(then.headers.get("cache-control")).toBe("private, max-age=31536000");146 const thenPage = await then.text();147 expect(thenPage).toContain("'hi'");148 expect(thenPage).not.toContain("hello");149 expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);150 });151152 test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {153 expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);154 expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);155 expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);156 expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);157 expect((await get("/demo/files/nothing.txt")).status).toBe(404);158 });159160 test("Markdown renders, with a source toggle", async () => {161 const rendered = await body("/demo/files/docs/guide.md");162 expect(rendered).toContain("<h1>Guide</h1>");163 const source = await body("/demo/files/docs/guide.md?view=source");164 expect(source).toContain('id="L1"');165 expect(source).not.toContain("<h1>Guide</h1>");166 });167168 test("an image displays inline from its raw file", async () => {169 expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');170 });171});172173describe("files over 1 MB", () => {174 test("text shows its first 1 MB with a raw link, even with a character cut at the mark", async () => {175 const page = await body("/large/files/big.txt");176 expect(page).toContain("showing the first 1 MB");177 expect(page).toContain('href="/large/raw/big.txt"');178 expect(page).not.toContain("bbb");179 const raw = await get("/large/raw/big.txt");180 expect(raw.headers.get("content-type")).toBe("text/plain; charset=utf-8");181 });182183 test("invalid UTF-8 without a NUL is binary, not text", async () => {184 const page = await body("/large/files/big.bin");185 expect(page).toContain("binary file");186 expect(page).not.toContain('class="code"');187 const raw = await get("/large/raw/big.bin");188 expect(raw.headers.get("content-type")).toBe("application/octet-stream");189 });190});191192describe("the Atlas", () => {193 194 const panelOf = (page: string, name: string) =>195 new RegExp(`data-name="${name}">.*?</a>\\n<template>(.*?)</template>`, "s").exec(page)?.[1] ??196 "";197 const rowOf = (page: string, name: string) =>198 new RegExp(`data-name="${name}">(.*?)</a>`, "s").exec(page)?.[1] ?? "";199200 test("sits between the timeline and History", async () => {201 const page = await body("/demo");202 const atlas = page.indexOf('id="atlas"');203 expect(page.indexOf('class="timeline"')).toBeLessThan(atlas);204 expect(atlas).toBeLessThan(page.indexOf('id="history"'));205 });206207 test("is a folder tree, folders first, then files, alphabetical", async () => {208 const names = [...(await body("/demo")).matchAll(/class="atlas-name">([^<]*)</g)].map(209 (m) => m[1],210 );211 expect(names).toEqual([212 "docs/",213 "guide.md",214 "logo.png",215 "src/",216 "lib/",217 "deep/",218 "util.ts",219 "main.ts",220 "vendor/",221 "big.c",222 ".kohignore",223 "README.md",224 "secret.txt",225 ]);226 });227228 test("rows carry the first sentence of the file's own comment, rendered, and link to the file", async () => {229 const page = await body("/demo");230 expect(rowOf(page, "util.ts")).toContain(231 '<span class="atlas-desc">The <em>one</em> helper.</span>',232 );233 expect(rowOf(page, "main.ts")).toContain("Entry point.");234 expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');235 expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');236 expect(page).toContain(237 '<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts">',238 );239 expect(panelOf(page, "util.ts")).toContain("<p>The <em>one</em> helper. Deep down.</p>");240 });241242 test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {243 const page = await body("/demo");244 const main = panelOf(page, "main.ts");245 expect(main).toContain("<dt>lines</dt><dd>2</dd>");246 expect(main).toContain("<dt>saves</dt><dd>2</dd>");247 expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);248 expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);249 expect(main.indexOf(head())).toBeLessThan(main.lastIndexOf(second()));250 const guide = panelOf(page, "guide.md");251 expect(guide).toContain("<dt>saves</dt><dd>2</dd>");252 expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);253 expect(panelOf(page, "logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");254 expect(page).not.toContain(fx.saveIds[1] as string);255 });256257 test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {258 const page = await body("/demo");259 const folderPanel = (path: string) =>260 new RegExp(261 `data-path="${path}">\\n<summary.*?</summary>\\n<template>(.*?)</template>`,262 "s",263 ).exec(page)?.[1] ?? "";264 const docs = folderPanel("docs");265 expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');266 expect(docs).toContain("<dt>lines</dt><dd>3</dd>");267 expect(docs).toContain("<dt>files</dt><dd>2</dd>");268 expect(docs).toContain("<dt>saves</dt><dd>2</dd>");269 expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);270 expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);271 const src = folderPanel("src");272 expect(src).toContain("<dt>saves</dt><dd>3</dd>");273 const recentIds = [274 ...(/<ul class="atlas-recent">(.*?)<\/ul>/s.exec(src)?.[1] ?? "").matchAll(275 /saves\/([0-9a-f]+)/g,276 ),277 ].map((m) => m[1]);278 expect(recentIds).toEqual([head(), second(), first()]);279 });280281 test("can be viewed at an older save, and an unknown save is a 404", async () => {282 const page = await body(`/demo?at=${second().slice(0, 8)}`);283 expect(page).toContain(`at <a href="/demo/saves/${second()}">${second()}</a>`);284 expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);285 expect(panelOf(page, "main.ts")).toContain("<dt>saves</dt><dd>1</dd>");286 expect(panelOf(page, "main.ts")).not.toContain(head());287 const oldest = await body(`/demo?at=${first()}`);288 expect(oldest).not.toContain('data-name="main.ts"');289 expect((await get("/demo?at=abcdef")).status).toBe(404);290 expect((await get("/demo?at=../x")).status).toBe(404);291 });292});293294describe("the save page", () => {295 test("shows the save's facts and diffs against the previous real save, skipping the checkpoint", async () => {296 const response = await get(`/demo/saves/${head()}`);297 expect(response.status).toBe(200);298 expect(response.headers.get("cache-control")).toBe("public, max-age=60");299 const page = await response.text();300 expect(page).toContain('<h1 class="save-message-head">say hello</h1>');301 expect(page).toContain(302 `<dt>parent</dt><dd><a href="/demo/saves/${second()}">${second()}</a></dd>`,303 );304 expect(page).not.toContain(fx.saveIds[1] as string);305 expect(page).not.toContain("save-flag");306 307 const rows = [308 ...page.matchAll(309 /class="change-row change-(\w+)" href="#diff-\d"><span class="change-path">([^<]+)</g,310 ),311 ];312 expect(rows.map((m) => [m[1], m[2]])).toEqual([313 ["modified", "docs/guide.md"],314 ["modified", "src/main.ts"],315 ]);316 expect(page).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');317 expect(page).toContain('<span class="dh">@@ -1,2 +1,2 @@</span>');318 expect(page).toContain(319 '<span class="dl dl-del"><span class="ln">2</span><span class="ln"></span><span class="ds">-</span>',320 );321 expect(page).toContain('class="tk-');322 expect(page).toContain(`href="/demo/archive/${head()}.tar.gz"`);323 expect(page).toContain(`href="/demo?at=${head()}#atlas"`);324 });325326 test("the first save diffs against nothing, notes binaries, and shows its flag and agent", async () => {327 const page = await body(`/demo/saves/${first()}`);328 expect(page).toContain('<span class="save-flag-label mono">flagged</span> holds a secret');329 expect(page).toContain("<dt>parent</dt><dd>none, the first save</dd>");330 expect(page).toContain(331 'change-added" href="#diff-2"><span class="change-path">docs/logo.png</span><span class="change-stat mono"><span class="diff-note">binary</span>',332 );333 expect(page).toContain("binary file, not shown");334 expect(page).toContain('<span class="dh">@@ -0,0 +1 @@</span>');335 const second_ = await body(`/demo/saves/${second()}`);336 expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");337 expect(second_).not.toContain("save-flag");338 });339340 test("a change to the final newline alone is a changed last line, marked as in GNU diff", async () => {341 const marker = '<span class="dh">\\ No newline at end of file</span>';342 const row = (kind: string, a: string, b: string, sign: string) =>343 `<span class="dl dl-${kind}"><span class="ln">${a}</span><span class="ln">${b}</span><span class="ds">${sign}</span><span class="lc">b</span></span>`;344 const gained = await body(`/tail/saves/${tailSaves[1]}`);345 expect(gained).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');346 expect(gained).toContain(`${row("del", "2", "", "-")}${marker}${row("add", "", "2", "+")}<`);347 const lost = await body(`/tail/saves/${tailSaves[0]}`);348 expect(lost).toContain(`${row("del", "2", "", "-")}${row("add", "", "2", "+")}${marker}`);349 });350351 test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {352 const short = await get(`/demo/saves/${head().slice(0, 6)}`);353 expect(short.status).toBe(302);354 expect(short.headers.get("location")).toBe(`/demo/saves/${head()}`);355 expect((await get(`/demo/saves/${fx.saveIds[1]}`)).status).toBe(404);356 expect((await get("/demo/saves/abcdef")).status).toBe(404);357 expect((await get("/demo/saves/abc")).status).toBe(404);358 expect((await get(`/hidden/saves/${head()}`)).status).toBe(404);359 });360});361362describe("raw files", () => {363 test("are the exact object bytes, text as text/plain", async () => {364 const mirror = openMirror(mirrorDir, "demo");365 for (const [path, type] of [366 ["src/main.ts", "text/plain; charset=utf-8"],367 ["docs/logo.png", "image/png"],368 ["README.md", "text/plain; charset=utf-8"],369 ] as const) {370 const response = await get(`/demo/raw/${path}`);371 const file = fileAt(mirror, head(), path);372 expect(response.headers.get("content-type")).toBe(type);373 expect(new Uint8Array(await response.arrayBuffer())).toEqual(374 objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,375 );376 }377 expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");378 });379});380381test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {382 const css = await get("/static/css/kepr.css");383 expect(css.status).toBe(200);384 expect(css.headers.get("content-type")).toContain("text/css");385 expect((await get("/static/../package.json")).status).toBe(404);386 const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";387 expect(csp).toContain("style-src 'self' https://fonts.bunny.net");388 expect(csp).toContain("font-src https://fonts.bunny.net");389 expect(csp).not.toContain("unsafe-inline");390});391392describe("housekeeping routes", () => {393 test("robots.txt points at the sitemap, which lists public repos only", async () => {394 expect(await body("/robots.txt")).toBe(395 "User-agent: *\nAllow: /\n\nSitemap: https://kepr.example/sitemap.xml\n",396 );397 const sitemap = await get("/sitemap.xml");398 expect(sitemap.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");399 const xml = await sitemap.text();400 expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>\n<urlset');401 for (const repo of ["demo", "large", "tail"]) {402 expect(xml).toContain(`<loc>https://kepr.example/${repo}</loc>`);403 }404 expect(xml).not.toContain("hidden");405 });406407 test("favicon.svg is Kepr 2's mark, and every page links it", async () => {408 const res = await get("/favicon.svg");409 expect(res.headers.get("Content-Type")).toBe("image/svg+xml");410 expect(await res.text()).toContain('<polygon points="16,3 27,23 5,23"');411 expect(await body("/demo")).toContain(412 '<link rel="icon" href="/favicon.svg" type="image/svg+xml">',413 );414 });415});