pages.test.ts

19.6 KB · raw

1import { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { mkdtemp, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import type { Hono } from "hono";7import { openBucket } from "../src/bucket/reader.ts";8import { syncAll } from "../src/bucket/sync.ts";9import { fileAt, objectBytes, type Save } from "../src/mirror/queries.ts";10import { openMirror } from "../src/mirror/store.ts";11import { repoCards } from "../src/pages/library.ts";12import { timelineSvg } from "../src/pages/timeline.ts";13import { createApp } from "../src/server/app.ts";14import { openKeprDb } from "../src/server/db.ts";15import { type Fixture, makeKohBucket } from "./fixture.ts";1617let fx: Fixture;18let app: Hono;19let mirrorDir: string;20let tailSaves: string[];21const get = (path: string) => app.request(path);22const body = async (path: string) => (await get(path)).text();2324beforeAll(async () => {25	fx = await makeKohBucket();26	const secretRepo = await makeKohBucket("hidden");27	const large = await makeKohBucket("large");28	// Over 1 MB each: text with a two-byte "é" split by the 1 MB mark, and invalid UTF-8 with no NUL.29	await writeFile(30		join(large.repoDir, "big.txt"),31		`${"a".repeat(1024 * 1024 - 1)}é${"b".repeat(1000)}\n`,32	);33	await writeFile(join(large.repoDir, "big.bin"), new Uint8Array(1024 * 1024 + 500).fill(0xff));34	Bun.spawnSync(["koh3", "save", "add large files"], {35		cwd: large.repoDir,36		env: { PATH: process.env.PATH, HOME: join(large.repoDir, "../../home"), NO_COLOR: "1" },37	});38	// A file that gains its final newline, then loses it again.39	const tail = await makeKohBucket("tail");40	const tailEnv = { PATH: process.env.PATH, HOME: join(tail.repoDir, "../../home"), NO_COLOR: "1" };41	for (const [text, message] of [42		["a\nb", "add tail"],43		["a\nb\n", "end tail with a newline"],44		["a\nb", "drop the newline"],45	] as const) {46		await writeFile(join(tail.repoDir, "tail.txt"), text);47		Bun.spawnSync(["koh3", "save", message], { cwd: tail.repoDir, env: tailEnv });48	}49	tailSaves = JSON.parse(50		Bun.spawnSync(["koh3", "log", "--json"], { cwd: tail.repoDir, env: tailEnv }).stdout.toString(),51	).saves.map((save: { id: string }) => save.id);52	const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));53	mirrorDir = join(dataDir, "mirror");54	const kepr = openKeprDb(dataDir);55	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {56		const bucket = openBucket({ kind: "folder", path: bucketDir });57		await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });58	}59	kepr.run(60		"UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",61	);62	kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");63	app = createApp(64		{65			domain: "kepr.example",66			dataDir,67			adminPasswordHash: "$argon2id$x",68			bucket: { kind: "folder", path: fx.bucketDir },69		},70		kepr,71	);72});7374const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);7576describe("the visibility gate", () => {77	test("a private repo and an unknown one are the same 404, everywhere", async () => {78		for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {79			const hidden = await get(`/hidden${suffix}`);80			const missing = await get(`/nowhere${suffix}`);81			expect(hidden.status).toBe(404);82			expect(missing.status).toBe(404);83			expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());84		}85	});86});8788describe("the repo page", () => {89	test("renders header, timeline, history and README at HEAD with a short cache", async () => {90		const response = await get("/demo");91		expect(response.status).toBe(200);92		expect(response.headers.get("cache-control")).toBe("public, max-age=60");93		const page = await response.text();94		expect(page).toContain('<h1 class="repo-name">demo</h1>');95		// The README opens with an HTML comment and a raw <p align>; neither is the intro.96		expect(/<div class="repo-desc"><p>(.*?)<\/p>/.exec(page)?.[1]).toBe(97			"A small repo for <strong>Kepr's</strong> tests.",98		);99		expect(page).toContain(100			'<meta property="og:description" content="A small repo for Kepr&#39;s tests.">',101		);102		expect(page).toContain('href="https://demo.example"');103		expect(page).toContain('<span class="lang-badge">TypeScript</span>');104		expect(page).toContain("3 saves");105		expect(page).toContain('data-copy="nix profile add x"');106		expect(page.match(/class="dot d\d"/g)?.length).toBe(3);107		expect(page).toContain("density = activity · position = time");108		const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);109		expect(history).toEqual([head(), second(), first()]);110	});111112	test("a page past the last, or too large to bind, is page 1", async () => {113		const history = (page: string) => [...page.matchAll(/class="history-id">([0-9a-f]+)</g)];114		for (const query of ["?page=2", `?page=${"9".repeat(400)}`, "?page=1e999", "?page=x"]) {115			const response = await get(`/demo${query}`);116			expect([query, response.status]).toEqual([query, 200]);117			expect(history(await response.text()).length).toBe(3);118		}119	});120121	test("checkpoints never appear", async () => {122		expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);123	});124125	test("the README points relative links and images at raw files and drops unsafe links", async () => {126		const page = await body("/demo");127		expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');128		expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');129		expect(page).toContain('<a href="https://example.com">the site</a>');130		expect(page).not.toContain('href="javascript:');131		expect(page).toContain("&lt;script&gt;alert");132		expect(page).not.toContain("<script>alert");133	});134});135136describe("the timeline and card ticks", () => {137	// A replayed save keeps its original time, so newest-first order is not time order.138	const saves = [300, 100, 500, 200].map(139		(created_at, i): Save => ({140			id: `00000000000${i}`,141			parent: "",142			message: "m",143			created_at: created_at * 86400,144			machine: "m",145			agent: "",146			session: "",147			is_checkpoint: false,148		}),149	);150151	test("span from the earliest to the latest save, whatever the line's order", () => {152		const dots = [...timelineSvg("demo", saves).value.matchAll(/cx="([\d.]+)%"/g)].map((m) => m[1]);153		expect(dots).toEqual(["50.000", "1.000", "99.000", "25.500"]);154		const card = repoCards([155			{156				settings: { name: "demo", category: "", homepage: "", installLines: [] },157				mirror: new Database(":memory:"),158				stats: { saves: 4, lines: 0, language: "", updated: null },159				description: "",160				saves,161			},162		]).value;163		const ticks = [...card.matchAll(/<line x1="([\d.]+)"/g)].map((m) => m[1]);164		expect(ticks).toEqual(["50.00", "1.00", "99.00", "25.50"]);165	});166});167168describe("the file viewer", () => {169	test("shows a file at HEAD and at an old save, with line anchors", async () => {170		const now = await get("/demo/files/src/main.ts");171		expect(now.headers.get("cache-control")).toBe("public, max-age=60");172		const nowPage = await now.text();173		expect(nowPage).toContain("hello");174		expect(nowPage).toContain(175			'<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',176		);177		expect(nowPage).toContain('class="tk-');178179		const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);180		expect(then.headers.get("cache-control")).toBe("private, max-age=31536000");181		const thenPage = await then.text();182		expect(thenPage).toContain("&#39;hi&#39;");183		expect(thenPage).not.toContain("hello");184		expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);185	});186187	test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {188		expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);189		expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);190		expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);191		expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);192		expect((await get("/demo/files/nothing.txt")).status).toBe(404);193	});194195	test("Markdown renders, with a source toggle", async () => {196		const rendered = await body("/demo/files/docs/guide.md");197		expect(rendered).toContain("<h1>Guide</h1>");198		const source = await body("/demo/files/docs/guide.md?view=source");199		expect(source).toContain('id="L1"');200		expect(source).not.toContain("<h1>Guide</h1>");201	});202203	test("an image displays inline from its raw file", async () => {204		expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');205	});206});207208describe("files over 1 MB", () => {209	test("text shows its first 1 MB with a raw link, even with a character cut at the mark", async () => {210		const page = await body("/large/files/big.txt");211		expect(page).toContain("showing the first 1 MB");212		expect(page).toContain('href="/large/raw/big.txt"');213		expect(page).not.toContain("bbb");214		const raw = await get("/large/raw/big.txt");215		expect(raw.headers.get("content-type")).toBe("text/plain; charset=utf-8");216	});217218	test("invalid UTF-8 without a NUL is binary, not text", async () => {219		const page = await body("/large/files/big.bin");220		expect(page).toContain("binary file");221		expect(page).not.toContain('class="code"');222		const raw = await get("/large/raw/big.bin");223		expect(raw.headers.get("content-type")).toBe("application/octet-stream");224	});225});226227describe("the Atlas", () => {228	// The detail panel markup waiting in the <template> after a file's row.229	const panelOf = (page: string, name: string) =>230		new RegExp(`data-name="${name}">.*?</a>\\n<template>(.*?)</template>`, "s").exec(page)?.[1] ??231		"";232	const rowOf = (page: string, name: string) =>233		new RegExp(`data-name="${name}">(.*?)</a>`, "s").exec(page)?.[1] ?? "";234235	test("sits between the timeline and History", async () => {236		const page = await body("/demo");237		const atlas = page.indexOf('id="atlas"');238		expect(page.indexOf('class="timeline"')).toBeLessThan(atlas);239		expect(atlas).toBeLessThan(page.indexOf('id="history"'));240	});241242	test("is a folder tree, folders first, then files, alphabetical", async () => {243		const names = [...(await body("/demo")).matchAll(/class="atlas-name">([^<]*)</g)].map(244			(m) => m[1],245		);246		expect(names).toEqual([247			"docs/",248			"guide.md",249			"logo.png",250			"src/",251			"lib/",252			"deep/",253			"util.ts",254			"main.ts",255			"vendor/",256			"big.c",257			".kohignore",258			"README.md",259			"secret.txt",260		]);261	});262263	test("rows carry the first sentence of the file's own comment, rendered, and link to the file", async () => {264		const page = await body("/demo");265		expect(rowOf(page, "util.ts")).toContain(266			'<span class="atlas-desc">The <em>one</em> helper.</span>',267		);268		expect(rowOf(page, "main.ts")).toContain("Entry point.");269		expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');270		expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');271		expect(page).toContain(272			'<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts">',273		);274		expect(panelOf(page, "util.ts")).toContain("<p>The <em>one</em> helper. Deep down.</p>");275	});276277	test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {278		const page = await body("/demo");279		const main = panelOf(page, "main.ts");280		expect(main).toContain("<dt>lines</dt><dd>2</dd>");281		expect(main).toContain("<dt>saves</dt><dd>2</dd>");282		expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);283		expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);284		expect(main.indexOf(head())).toBeLessThan(main.lastIndexOf(second()));285		const guide = panelOf(page, "guide.md");286		expect(guide).toContain("<dt>saves</dt><dd>2</dd>");287		expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);288		expect(panelOf(page, "logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");289		expect(page).not.toContain(fx.saveIds[1] as string);290	});291292	test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {293		const page = await body("/demo");294		const folderPanel = (path: string) =>295			new RegExp(296				`data-path="${path}">\\n<summary.*?</summary>\\n<template>(.*?)</template>`,297				"s",298			).exec(page)?.[1] ?? "";299		const docs = folderPanel("docs");300		expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');301		expect(docs).toContain("<dt>lines</dt><dd>3</dd>");302		expect(docs).toContain("<dt>files</dt><dd>2</dd>");303		expect(docs).toContain("<dt>saves</dt><dd>2</dd>");304		expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);305		expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);306		const src = folderPanel("src");307		expect(src).toContain("<dt>saves</dt><dd>3</dd>");308		const recentIds = [309			...(/<ul class="atlas-recent">(.*?)<\/ul>/s.exec(src)?.[1] ?? "").matchAll(310				/saves\/([0-9a-f]+)/g,311			),312		].map((m) => m[1]);313		expect(recentIds).toEqual([head(), second(), first()]);314	});315316	test("can be viewed at an older save, and an unknown save is a 404", async () => {317		const page = await body(`/demo?at=${second().slice(0, 8)}`);318		expect(page).toContain(`at <a href="/demo/saves/${second()}">${second()}</a>`);319		expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);320		expect(panelOf(page, "main.ts")).toContain("<dt>saves</dt><dd>1</dd>");321		expect(panelOf(page, "main.ts")).not.toContain(head());322		const oldest = await body(`/demo?at=${first()}`);323		expect(oldest).not.toContain('data-name="main.ts"');324		expect((await get("/demo?at=abcdef")).status).toBe(404);325		expect((await get("/demo?at=../x")).status).toBe(404);326	});327});328329describe("the save page", () => {330	test("shows the save's facts and diffs against the previous real save, skipping the checkpoint", async () => {331		const response = await get(`/demo/saves/${head()}`);332		expect(response.status).toBe(200);333		expect(response.headers.get("cache-control")).toBe("public, max-age=60");334		const page = await response.text();335		expect(page).toContain('<h1 class="save-message-head">say hello</h1>');336		expect(page).toContain(337			`<dt>parent</dt><dd><a href="/demo/saves/${second()}">${second()}</a></dd>`,338		);339		expect(page).not.toContain(fx.saveIds[1] as string);340		expect(page).not.toContain("save-flag");341		// The guide's edit was first recorded by the checkpoint; it belongs to this save.342		const rows = [343			...page.matchAll(344				/class="change-row change-(\w+)" href="#diff-\d"><span class="change-path">([^<]+)</g,345			),346		];347		expect(rows.map((m) => [m[1], m[2]])).toEqual([348			["modified", "docs/guide.md"],349			["modified", "src/main.ts"],350		]);351		expect(page).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');352		expect(page).toContain('<span class="dh">@@ -1,2 +1,2 @@</span>');353		expect(page).toContain(354			'<span class="dl dl-del"><span class="ln">2</span><span class="ln"></span><span class="ds">-</span>',355		);356		expect(page).toContain('class="tk-');357		expect(page).toContain(`href="/demo/archive/${head()}.tar.gz"`);358		expect(page).toContain(`href="/demo?at=${head()}#atlas"`);359	});360361	test("the first save diffs against nothing, notes binaries, and shows its flag and agent", async () => {362		const page = await body(`/demo/saves/${first()}`);363		expect(page).toContain('<span class="save-flag-label mono">flagged</span> holds a secret');364		expect(page).toContain("<dt>parent</dt><dd>none, the first save</dd>");365		expect(page).toContain(366			'change-added" href="#diff-2"><span class="change-path">docs/logo.png</span><span class="change-stat mono"><span class="diff-note">binary</span>',367		);368		expect(page).toContain("binary file, not shown");369		expect(page).toContain('<span class="dh">@@ -0,0 +1 @@</span>');370		const second_ = await body(`/demo/saves/${second()}`);371		expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");372		expect(second_).not.toContain("save-flag");373	});374375	test("a change to the final newline alone is a changed last line, marked as in GNU diff", async () => {376		const marker = '<span class="dh">\\ No newline at end of file</span>';377		const row = (kind: string, a: string, b: string, sign: string) =>378			`<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>`;379		const gained = await body(`/tail/saves/${tailSaves[1]}`);380		expect(gained).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');381		expect(gained).toContain(`${row("del", "2", "", "-")}${marker}${row("add", "", "2", "+")}<`);382		const lost = await body(`/tail/saves/${tailSaves[0]}`);383		expect(lost).toContain(`${row("del", "2", "", "-")}${row("add", "", "2", "+")}${marker}`);384	});385386	test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {387		const short = await get(`/demo/saves/${head().slice(0, 6)}`);388		expect(short.status).toBe(302);389		expect(short.headers.get("location")).toBe(`/demo/saves/${head()}`);390		expect((await get(`/demo/saves/${fx.saveIds[1]}`)).status).toBe(404);391		expect((await get("/demo/saves/abcdef")).status).toBe(404);392		expect((await get("/demo/saves/abc")).status).toBe(404);393		expect((await get(`/hidden/saves/${head()}`)).status).toBe(404);394	});395});396397describe("raw files", () => {398	test("are the exact object bytes, text as text/plain", async () => {399		const mirror = openMirror(mirrorDir, "demo");400		for (const [path, type] of [401			["src/main.ts", "text/plain; charset=utf-8"],402			["docs/logo.png", "image/png"],403			["README.md", "text/plain; charset=utf-8"],404		] as const) {405			const response = await get(`/demo/raw/${path}`);406			const file = fileAt(mirror, head(), path);407			expect(response.headers.get("content-type")).toBe(type);408			expect(new Uint8Array(await response.arrayBuffer())).toEqual(409				objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,410			);411		}412		expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");413	});414});415416test("static assets are served and SVG responses are sandboxed", async () => {417	const css = await get("/static/css/kepr.css");418	expect(css.status).toBe(200);419	expect(css.headers.get("content-type")).toContain("text/css");420	const svg = await get("/favicon.svg");421	expect(svg.headers.get("content-type")).toBe("image/svg+xml");422	expect(svg.headers.get("content-security-policy")).toBe("sandbox");423	expect((await get("/static/../package.json")).status).toBe(404);424	const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";425	expect(csp).toContain("style-src 'self' https://fonts.bunny.net");426	expect(csp).toContain("font-src https://fonts.bunny.net");427	expect(csp).not.toContain("unsafe-inline");428});429430describe("housekeeping routes", () => {431	test("robots.txt points at the sitemap, which lists public repos only", async () => {432		expect(await body("/robots.txt")).toBe(433			"User-agent: *\nAllow: /\n\nSitemap: https://kepr.example/sitemap.xml\n",434		);435		const sitemap = await get("/sitemap.xml");436		expect(sitemap.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");437		const xml = await sitemap.text();438		expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>\n<urlset');439		for (const repo of ["demo", "large", "tail"]) {440			expect(xml).toContain(`<loc>https://kepr.example/${repo}</loc>`);441		}442		expect(xml).not.toContain("hidden");443	});444445	test("favicon.svg is Kepr 2's mark, and every page links it", async () => {446		const res = await get("/favicon.svg");447		expect(res.headers.get("Content-Type")).toBe("image/svg+xml");448		expect(await res.text()).toContain('<polygon points="16,3 27,23 5,23"');449		expect(await body("/demo")).toContain(450			'<link rel="icon" href="/favicon.svg" type="image/svg+xml">',451		);452	});453});