1import { beforeAll, describe, expect, test } from "bun:test";2import { mkdir, 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, type Save } from "../src/mirror/queries.ts";9import { openMirror } from "../src/mirror/store.ts";10import { timelineSaves, timelineSvg } from "../src/pages/timeline.ts";11import { createApp } from "../src/server/app.ts";12import { openKeprDb } from "../src/server/db.ts";13import { type Fixture, makeKohBucket } from "./fixture.ts";1415let fx: Fixture;16let app: Hono;17let mirrorDir: string;18let tailSaves: string[];19const get = (path: string) => app.request(path);20const body = async (path: string) => (await get(path)).text();2122const historyIds = (page: string) => {23 const list = /data-list="history">(.*?)<\/div>/s.exec(page)?.[1] ?? "";24 return [...list.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);25};2627beforeAll(async () => {28 fx = await makeKohBucket();29 const secretRepo = await makeKohBucket("hidden");30 const large = await makeKohBucket("large");31 32 await writeFile(33 join(large.repoDir, "big.txt"),34 `${"a".repeat(1024 * 1024 - 1)}é${"b".repeat(1000)}\n`,35 );36 await writeFile(join(large.repoDir, "big.bin"), new Uint8Array(1024 * 1024 + 500).fill(0xff));37 Bun.spawnSync(["koh3", "save", "add large files"], {38 cwd: large.repoDir,39 env: { PATH: process.env.PATH, HOME: join(large.repoDir, "../../home"), NO_COLOR: "1" },40 });41 42 const tail = await makeKohBucket("tail");43 const tailEnv = { PATH: process.env.PATH, HOME: join(tail.repoDir, "../../home"), NO_COLOR: "1" };44 for (const [text, message] of [45 ["a\nb", "add tail"],46 ["a\nb\n", "end tail with a newline"],47 ["a\nb", "drop the newline"],48 ] as const) {49 await writeFile(join(tail.repoDir, "tail.txt"), text);50 Bun.spawnSync(["koh3", "save", message], { cwd: tail.repoDir, env: tailEnv });51 }52 tailSaves = JSON.parse(53 Bun.spawnSync(["koh3", "view", "--json"], {54 cwd: tail.repoDir,55 env: tailEnv,56 }).stdout.toString(),57 ).saves.map((save: { id: string }) => save.id);58 const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));59 await mkdir(join(dataDir, "assets"));60 await writeFile(join(dataDir, "assets", "plain.txt"), "asset\n");61 const kepr = openKeprDb(dataDir);62 mirrorDir = join(dataDir, "mirror");63 for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {64 const bucket = openBucket({ kind: "folder", path: bucketDir });65 await syncAll({ bucket, kepr, mirrorDir });66 }67 kepr.run("UPDATE repos SET public = 1, homepage = 'https://demo.example' WHERE name = 'demo'");68 kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");69 app = createApp(70 {71 domain: "kepr.example",72 dataDir,73 adminPasswordHash: "$argon2id$x",74 bucket: { kind: "folder", path: fx.bucketDir },75 },76 kepr,77 );78});7980const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);8182describe("the visibility gate", () => {83 test("a private repo and an unknown one are the same 404, everywhere", async () => {84 for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {85 const hidden = await get(`/hidden${suffix}`);86 const missing = await get(`/nowhere${suffix}`);87 expect(hidden.status).toBe(404);88 expect(missing.status).toBe(404);89 expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());90 }91 });92});9394describe("the repo page", () => {95 test("renders header, timeline, history and README at HEAD with a short cache", async () => {96 const response = await get("/demo");97 expect(response.status).toBe(200);98 expect(response.headers.get("cache-control")).toBe("public, max-age=60");99 const page = await response.text();100 expect(page).toContain('<h1 class="repo-name">demo</h1>');101 102 expect(/<div class="repo-desc"><p>(.*?)<\/p>/.exec(page)?.[1]).toBe(103 "A small repo for <strong>Kepr's</strong> tests.",104 );105 expect(page).toContain(106 '<meta property="og:description" content="A small repo for Kepr's tests.">',107 );108 expect(page).toContain('href="https://demo.example"');109 expect(page).toContain('<span class="lang-badge">TypeScript</span>');110 expect(page).toContain("3 saves");111 expect(page).not.toContain('class="install-line"');112 expect(page.match(/<circle class="dot d\d"/g)?.length).toBe(3);113 expect(page).not.toContain("density = activity");114 expect(historyIds(page)).toEqual([head(), second(), first()]);115 expect(page).toContain('data-copy="koh steal kepr.example/demo"');116 expect(page).toContain('href="/demo/releases" data-panel="releases">builds</a>');117 });118119 test("a page past the last, or too large to bind, is page 1", async () => {120 for (const query of ["?page=2", `?page=${"9".repeat(400)}`, "?page=1e999", "?page=x"]) {121 const response = await get(`/demo${query}`);122 expect([query, response.status]).toEqual([query, 200]);123 expect(historyIds(await response.text()).length).toBe(3);124 }125 });126127 test("checkpoints never appear", async () => {128 expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);129 });130131 test("the README points relative links and images at raw files and drops unsafe links", async () => {132 const page = await body("/demo");133 expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');134 expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');135 expect(page).toContain('<a href="https://example.com">the site</a>');136 expect(page).not.toContain('href="javascript:');137 expect(page).toContain("<script>alert");138 expect(page).not.toContain("<script>alert");139 });140});141142describe("the timeline", () => {143 144 const saves = [300, 100, 500, 200].map(145 (created_at, i): Save => ({146 id: `00000000000${i}`,147 parent: "",148 message: "m",149 created_at: created_at * 86400,150 machine: "m",151 agent: "",152 session: "",153 is_checkpoint: false,154 }),155 );156157 test("span from the earliest to the latest save, whatever the line's order", () => {158 const dots = [...timelineSvg("demo", saves).value.matchAll(/cx="([\d.]+)%"/g)].map((m) => m[1]);159 expect(dots).toEqual(["50.000", "1.000", "99.000", "25.500"]);160 });161162 test("a dot's neighbours are counted within a fractional reach exactly", () => {163 164 const at = (created_at: number) => ({ ...(saves[0] as Save), created_at });165 const svg = timelineSvg("demo", [at(0), at(3601), at(144001)]).value;166 expect(svg.match(/class="dot d(\d)"/g)).toEqual([167 'class="dot d0"',168 'class="dot d0"',169 'class="dot d0"',170 ]);171 });172173 test("a dot's tooltip keeps a long message's first 120 characters", () => {174 const long = { ...(saves[0] as Save), message: `${"x".repeat(150)}\nsecond line` };175 expect(timelineSaves([long])[0]?.message).toBe(`${"x".repeat(119)}…`);176 });177178 test("the page carries plain dots; their words come from /<repo>/timeline", async () => {179 const page = await (await get("/demo")).text();180 expect(page).toContain('data-src="/demo/timeline"');181 expect(/<svg class="timeline"[\s\S]*?<\/svg>/.exec(page)?.[0]).not.toContain("<title>");182 const response = await get("/demo/timeline");183 expect(response.headers.get("cache-control")).toBe("public, max-age=60");184 const saves = (await response.json()) as { id: string; message: string }[];185 expect(saves.map((save) => save.message)).toContain("say hello");186 expect(saves.length).toBe(3);187 expect((await get("/nope/timeline")).status).toBe(404);188 });189});190191describe("the file viewer", () => {192 test("shows a file at HEAD and at an old save, with line anchors", async () => {193 const now = await get("/demo/files/src/main.ts");194 expect(now.headers.get("cache-control")).toBe("public, max-age=60");195 const nowPage = await now.text();196 expect(nowPage).toContain("hello");197 expect(nowPage).toContain(198 '<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',199 );200 expect(nowPage).toContain('class="tk-');201202 const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);203 expect(then.headers.get("cache-control")).toBe("private, max-age=31536000");204 const thenPage = await then.text();205 expect(thenPage).toContain("'hi'");206 expect(thenPage).not.toContain("hello");207 expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);208 });209210 test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {211 expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);212 expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);213 expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);214 expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);215 expect((await get("/demo/files/nothing.txt")).status).toBe(404);216 });217218 test("Markdown renders, with a source toggle", async () => {219 const rendered = await body("/demo/files/docs/guide.md");220 expect(rendered).toContain("<h1>Guide</h1>");221 const source = await body("/demo/files/docs/guide.md?view=source");222 expect(source).toContain('id="L1"');223 expect(source).not.toContain("<h1>Guide</h1>");224 });225226 test("an image displays inline from its raw file", async () => {227 expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');228 });229});230231describe("files over 1 MB", () => {232 test("text shows its first 1 MB with a raw link, even with a character cut at the mark", async () => {233 const page = await body("/large/files/big.txt");234 expect(page).toContain("showing the first 1 MB");235 expect(page).toContain('href="/large/raw/big.txt"');236 expect(page).not.toContain("bbb");237 const raw = await get("/large/raw/big.txt");238 expect(raw.headers.get("content-type")).toBe("text/plain; charset=utf-8");239 });240241 test("invalid UTF-8 without a NUL is binary, not text", async () => {242 const page = await body("/large/files/big.bin");243 expect(page).toContain("binary file");244 expect(page).not.toContain('class="code"');245 const raw = await get("/large/raw/big.bin");246 expect(raw.headers.get("content-type")).toBe("application/octet-stream");247 });248});249250describe("the Atlas", () => {251 252 const panelOf = (path: string, at?: string) =>253 body(`/demo/atlas/panel?${new URLSearchParams(at ? { path, at } : { path })}`);254 const rowOf = (page: string, name: string) =>255 new RegExp(`data-name="${name}"[^>]*>(.*?)</a>`, "s").exec(page)?.[1] ?? "";256257 test("sits between the timeline and History", async () => {258 const page = await body("/demo");259 const atlas = page.indexOf('id="atlas"');260 expect(page.indexOf('class="timeline"')).toBeLessThan(atlas);261 expect(atlas).toBeLessThan(page.indexOf('id="history"'));262 });263264 test("is a folder tree, folders first, then files, alphabetical", async () => {265 const names = [...(await body("/demo")).matchAll(/class="atlas-name">([^<]*)</g)].map(266 (m) => m[1],267 );268 expect(names).toEqual([269 "docs/",270 "guide.md",271 "logo.png",272 "src/",273 "lib/",274 "deep/",275 "util.ts",276 "main.ts",277 "vendor/",278 "big.c",279 "README.md",280 "secret.txt",281 ]);282 });283284 test("rows carry the first sentence of the file's own comment, rendered, and link to the file", async () => {285 const page = await body("/demo");286 expect(rowOf(page, "util.ts")).toContain(287 '<span class="atlas-desc">The <em>one</em> helper.</span>',288 );289 expect(rowOf(page, "main.ts")).toContain("Entry point.");290 expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');291 expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');292 expect(page).toContain(293 '<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts" data-path="src/main.ts">',294 );295 const util = await panelOf("src/lib/deep/util.ts");296 expect(util).toContain("<p>The <em>one</em> helper. Deep down.</p>");297 expect(util).toContain("<dt>exports</dt><dd>one</dd>");298 expect(await panelOf("src/main.ts")).not.toContain("atlas-map");299 });300301 test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {302 const page = await body("/demo");303 const main = await panelOf("src/main.ts");304 expect(main).toContain("<dt>lines</dt><dd>2</dd>");305 expect(main).toContain("<dt>saves</dt><dd>2</dd>");306 expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);307 expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);308 const guide = await panelOf("docs/guide.md");309 expect(guide).toContain("<dt>saves</dt><dd>2</dd>");310 expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);311 expect(await panelOf("docs/logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");312 expect(page).not.toContain(fx.saveIds[1] as string);313 });314315 test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {316 const docs = await panelOf("docs/");317 expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');318 expect(docs).toContain("<dt>lines</dt><dd>3</dd>");319 expect(docs).toContain("<dt>files</dt><dd>2</dd>");320 expect(docs).toContain("<dt>saves</dt><dd>2</dd>");321 expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);322 expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);323 const src = await panelOf("src/");324 expect(src).toContain("<dt>saves</dt><dd>3</dd>");325 });326327 test("can be viewed at an older save, and an unknown save is a 404", async () => {328 const page = await body(`/demo?at=${second().slice(0, 8)}`);329 expect(page).toContain(`title="explore through time">${second()}</summary>`);330 const picker = await body(`/demo/atlas/saves?at=${second()}`);331 expect(picker).toContain(332 `<a class="atlas-picker-row active" href="/demo?at=${second()}#atlas"`,333 );334 expect(picker).toContain(`<a class="atlas-picker-row" href="/demo?at=${head()}#atlas"`);335 expect(page).toContain('<a class="atlas-head-link mono" href="/demo#atlas">back to HEAD</a>');336 expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);337 const main = await panelOf("src/main.ts", second().slice(0, 8));338 expect(main).toContain("<dt>saves</dt><dd>1</dd>");339 expect(main).not.toContain(head());340 const oldest = await body(`/demo?at=${first()}`);341 expect(oldest).not.toContain('data-name="main.ts"');342 expect((await get("/demo?at=abcdef")).status).toBe(404);343 expect((await get("/demo?at=../x")).status).toBe(404);344 });345346 test("the page carries no panels; each is fetched alone, and anything unknown is a 404", async () => {347 const page = await body("/demo");348 expect(page).not.toContain("<template>");349 expect(page).not.toContain('class="atlas-picker-row" href="/demo?at=');350 expect(page).toContain('data-src="/demo/atlas/saves"');351 expect((await get("/demo/atlas/saves?at=abcdef")).status).toBe(404);352 expect((await get("/nobody/atlas/saves")).status).toBe(404);353 const panel = await get("/demo/atlas/panel?path=src/main.ts");354 expect(panel.status).toBe(200);355 expect(panel.headers.get("content-type")).toContain("text/html");356 for (const query of [357 "path=nope.ts",358 "path=src/main.ts&at=abcdef",359 "path=../secret.txt",360 "path=",361 "",362 "path=nope/",363 ]) {364 expect(`${query} ${(await get(`/demo/atlas/panel?${query}`)).status}`).toBe(`${query} 404`);365 }366 expect((await get("/nobody/atlas/panel?path=README.md")).status).toBe(404);367 });368});369370describe("the save page", () => {371 test("shows the save's facts and diffs against the previous real save, skipping the checkpoint", async () => {372 const response = await get(`/demo/saves/${head()}`);373 expect(response.status).toBe(200);374 expect(response.headers.get("cache-control")).toBe("public, max-age=60");375 const page = await response.text();376 expect(page).toContain('<h1 class="save-message-head">say hello</h1>');377 expect(page).toContain(378 `<dt>parent</dt><dd><a href="/demo/saves/${second()}">${second()}</a></dd>`,379 );380 expect(page).not.toContain(fx.saveIds[1] as string);381 expect(page).not.toContain("save-flag");382 383 const rows = [384 ...page.matchAll(385 /class="change-row change-(\w+)" href="#diff-\d"><span class="change-path">([^<]+)</g,386 ),387 ];388 expect(rows.map((m) => [m[1], m[2]])).toEqual([389 ["modified", "docs/guide.md"],390 ["modified", "src/main.ts"],391 ]);392 expect(page).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');393 expect(page).toContain('<span class="dh">@@ -1,2 +1,2 @@</span>');394 expect(page).toContain(395 '<span class="dl dl-del"><span class="ln">2</span><span class="ln"></span><span class="ds">-</span>',396 );397 expect(page).toContain('class="tk-');398 expect(page).toContain(`href="/demo/archive/${head()}.tar.gz"`);399 expect(page).toContain(`href="/demo?at=${head()}#atlas"`);400 });401402 test("the first save diffs against nothing, notes binaries, and shows its flag and agent", async () => {403 const page = await body(`/demo/saves/${first()}`);404 expect(page).toContain('<span class="save-flag-label mono">flagged</span> holds a secret');405 expect(page).toContain("<dt>parent</dt><dd>none, the first save</dd>");406 expect(page).toContain(407 'change-added" href="#diff-2"><span class="change-path">docs/logo.png</span><span class="change-stat mono"><span class="diff-note">binary</span>',408 );409 expect(page).toContain("binary file, not shown");410 expect(page).toContain('<span class="dh">@@ -0,0 +1 @@</span>');411 const second_ = await body(`/demo/saves/${second()}`);412 expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");413 expect(second_).not.toContain("save-flag");414 });415416 test("a change to the final newline alone is a changed last line, marked as in GNU diff", async () => {417 const marker = '<span class="dh">\\ No newline at end of file</span>';418 const row = (kind: string, a: string, b: string, sign: string) =>419 `<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>`;420 const gained = await body(`/tail/saves/${tailSaves[1]}`);421 expect(gained).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');422 expect(gained).toContain(`${row("del", "2", "", "-")}${marker}${row("add", "", "2", "+")}<`);423 const lost = await body(`/tail/saves/${tailSaves[0]}`);424 expect(lost).toContain(`${row("del", "2", "", "-")}${row("add", "", "2", "+")}${marker}`);425 });426427 test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {428 const short = await get(`/demo/saves/${head().slice(0, 6)}`);429 expect(short.status).toBe(302);430 expect(short.headers.get("location")).toBe(`/demo/saves/${head()}`);431 expect((await get(`/demo/saves/${fx.saveIds[1]}`)).status).toBe(404);432 expect((await get("/demo/saves/abcdef")).status).toBe(404);433 expect((await get("/demo/saves/abc")).status).toBe(404);434 expect((await get(`/hidden/saves/${head()}`)).status).toBe(404);435 });436});437438describe("raw files", () => {439 test("are the exact object bytes, text as text/plain", async () => {440 const mirror = openMirror(mirrorDir, "demo");441 for (const [path, type] of [442 ["src/main.ts", "text/plain; charset=utf-8"],443 ["docs/logo.png", "image/png"],444 ["README.md", "text/plain; charset=utf-8"],445 ] as const) {446 const response = await get(`/demo/raw/${path}`);447 const file = fileAt(mirror, head(), path);448 expect(response.headers.get("content-type")).toBe(type);449 expect(new Uint8Array(await response.arrayBuffer())).toEqual(450 objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,451 );452 }453 expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");454 });455});456457test("static assets and raw files carry route-based sandboxing", async () => {458 const css = await get("/static/css/kepr.css");459 expect(css.status).toBe(200);460 expect(css.headers.get("content-type")).toContain("text/css");461 const raw = await get("/demo/raw/README.md");462 expect(raw.headers.get("content-security-policy")).toContain("sandbox");463 const asset = await get("/static/assets/plain.txt");464 expect(asset.headers.get("content-security-policy")).toContain("sandbox");465 const favicon = await get("/favicon.svg");466 expect(favicon.headers.get("content-type")).toBe("image/svg+xml");467 expect(favicon.headers.get("content-security-policy")).not.toContain("sandbox");468 expect((await get("/static/../package.json")).status).toBe(404);469 const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";470 expect(csp).toContain("style-src 'self';");471 expect(csp).toContain("font-src 'self';");472 expect(csp).not.toContain("unsafe-inline");473});474475describe("housekeeping routes", () => {476 test("robots.txt points at the sitemap, which lists public repos only", async () => {477 expect(await body("/robots.txt")).toBe(478 "User-agent: *\nAllow: /\n\nSitemap: https://kepr.example/sitemap.xml\n",479 );480 const sitemap = await get("/sitemap.xml");481 expect(sitemap.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");482 const xml = await sitemap.text();483 expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>\n<urlset');484 for (const repo of ["demo", "large", "tail"]) {485 expect(xml).toContain(`<loc>https://kepr.example/${repo}</loc>`);486 }487 expect(xml).not.toContain("hidden");488 });489490 test("favicon.svg is Kepr 2's mark, and every page links it", async () => {491 const res = await get("/favicon.svg");492 expect(res.headers.get("Content-Type")).toBe("image/svg+xml");493 expect(await res.text()).toContain('<polygon points="16,3 27,23 5,23"');494 expect(await body("/demo")).toContain(495 '<link rel="icon" href="/favicon.svg" type="image/svg+xml">',496 );497 });498});499500test("pages are prefetched on hover, never a link whose GET does something", async () => {501 const page = await get("/demo");502 expect(page.headers.get("speculation-rules")).toBe('"/speculation.json"');503 const rules = await get("/speculation.json");504 expect(rules.headers.get("content-type")).toBe("application/speculationrules+json");505 const text = await rules.text();506 for (const pattern of [507 "/*/archive/*",508 "/*/raw/*",509 "/*/releases/*",510 "/*/koh/*",511 "/api/*",512 "/admin/backup*",513 "/admin/notifications/*",514 "/*/issues/*",515 "/sign-in*",516 "/connect*",517 ]) {518 expect(text).toContain(JSON.stringify(pattern));519 }520});