pages.test.ts

12.5 KB · raw

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	// Over 1 MB each: text with a two-byte "é" split by the 1 MB mark, and invalid UTF-8 with no NUL.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		await syncAll({ bucket: openBucket({ kind: "folder", path: bucketDir }), kepr, mirrorDir });39	}40	kepr.run(41		"UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",42	);43	kepr.run("UPDATE repos SET public = 1 WHERE name = 'large'");44	app = createApp(45		{46			domain: "kepr.example",47			dataDir,48			adminPasswordHash: "$argon2id$x",49			bucket: { kind: "folder", path: fx.bucketDir },50		},51		kepr,52	);53});5455const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);5657describe("the visibility gate", () => {58	test("a private repo and an unknown one are the same 404, everywhere", async () => {59		for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {60			const hidden = await get(`/hidden${suffix}`);61			const missing = await get(`/nowhere${suffix}`);62			expect(hidden.status).toBe(404);63			expect(missing.status).toBe(404);64			expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());65		}66	});67});6869describe("the repo page", () => {70	test("renders header, timeline, history and README at HEAD with a short cache", async () => {71		const response = await get("/demo");72		expect(response.status).toBe(200);73		expect(response.headers.get("cache-control")).toBe("public, max-age=60");74		const page = await response.text();75		expect(page).toContain('<h1 class="repo-name">demo</h1>');76		// The README opens with an HTML comment and a raw <p align>; neither is the intro.77		expect(/<div class="repo-desc"><p>(.*?)<\/p>/.exec(page)?.[1]).toBe(78			"A small repo for <strong>Kepr's</strong> tests.",79		);80		expect(page).toContain(81			'<meta property="og:description" content="A small repo for Kepr&#39;s tests.">',82		);83		expect(page).toContain('href="https://demo.example"');84		expect(page).toContain('<span class="lang-badge">TypeScript</span>');85		expect(page).toContain("3 saves");86		expect(page).toContain('data-copy="nix profile add x"');87		expect(page.match(/class="dot d\d"/g)?.length).toBe(3);88		expect(page).toContain("density = activity · position = time");89		const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);90		expect(history).toEqual([head(), second(), first()]);91	});9293	test("checkpoints never appear", async () => {94		expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);95	});9697	test("the README points relative links and images at raw files and drops unsafe links", async () => {98		const page = await body("/demo");99		expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');100		expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');101		expect(page).toContain('<a href="https://example.com">the site</a>');102		expect(page).not.toContain('href="javascript:');103		expect(page).toContain("&lt;script&gt;alert");104		expect(page).not.toContain("<script>alert");105	});106});107108describe("the file viewer", () => {109	test("shows a file at HEAD and at an old save, with line anchors", async () => {110		const now = await get("/demo/files/src/main.ts");111		expect(now.headers.get("cache-control")).toBe("public, max-age=60");112		const nowPage = await now.text();113		expect(nowPage).toContain("hello");114		expect(nowPage).toContain(115			'<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',116		);117		expect(nowPage).toContain('class="tk-');118119		const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);120		expect(then.headers.get("cache-control")).toBe("public, max-age=31536000");121		const thenPage = await then.text();122		expect(thenPage).toContain("&#39;hi&#39;");123		expect(thenPage).not.toContain("hello");124		expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);125	});126127	test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {128		expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);129		expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);130		expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);131		expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);132		expect((await get("/demo/files/nothing.txt")).status).toBe(404);133	});134135	test("Markdown renders, with a source toggle", async () => {136		const rendered = await body("/demo/files/docs/guide.md");137		expect(rendered).toContain("<h1>Guide</h1>");138		const source = await body("/demo/files/docs/guide.md?view=source");139		expect(source).toContain('id="L1"');140		expect(source).not.toContain("<h1>Guide</h1>");141	});142143	test("an image displays inline from its raw file", async () => {144		expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');145	});146});147148describe("files over 1 MB", () => {149	test("text shows its first 1 MB with a raw link, even with a character cut at the mark", async () => {150		const page = await body("/large/files/big.txt");151		expect(page).toContain("showing the first 1 MB");152		expect(page).toContain('href="/large/raw/big.txt"');153		expect(page).not.toContain("bbb");154		const raw = await get("/large/raw/big.txt");155		expect(raw.headers.get("content-type")).toBe("text/plain; charset=utf-8");156	});157158	test("invalid UTF-8 without a NUL is binary, not text", async () => {159		const page = await body("/large/files/big.bin");160		expect(page).toContain("binary file");161		expect(page).not.toContain('class="code"');162		const raw = await get("/large/raw/big.bin");163		expect(raw.headers.get("content-type")).toBe("application/octet-stream");164	});165});166167describe("the Atlas", () => {168	// The detail panel markup waiting in the <template> after a file's row.169	const panelOf = (page: string, name: string) =>170		new RegExp(`data-name="${name}">.*?</a>\\n<template>(.*?)</template>`, "s").exec(page)?.[1] ??171		"";172	const rowOf = (page: string, name: string) =>173		new RegExp(`data-name="${name}">(.*?)</a>`, "s").exec(page)?.[1] ?? "";174175	test("sits between the timeline and History", async () => {176		const page = await body("/demo");177		const atlas = page.indexOf('id="atlas"');178		expect(page.indexOf('class="timeline"')).toBeLessThan(atlas);179		expect(atlas).toBeLessThan(page.indexOf('id="history"'));180	});181182	test("is a folder tree, folders first, then files, alphabetical", async () => {183		const names = [...(await body("/demo")).matchAll(/class="atlas-name">([^<]*)</g)].map(184			(m) => m[1],185		);186		expect(names).toEqual([187			"docs/",188			"guide.md",189			"logo.png",190			"src/",191			"lib/",192			"deep/",193			"util.ts",194			"main.ts",195			"vendor/",196			"big.c",197			".kohignore",198			"README.md",199			"secret.txt",200		]);201	});202203	test("rows carry the first sentence of the file's own comment, rendered, and link to the file", async () => {204		const page = await body("/demo");205		expect(rowOf(page, "util.ts")).toContain(206			'<span class="atlas-desc">The <em>one</em> helper.</span>',207		);208		expect(rowOf(page, "main.ts")).toContain("Entry point.");209		expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');210		expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');211		expect(page).toContain(212			'<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts">',213		);214		expect(panelOf(page, "util.ts")).toContain("<p>The <em>one</em> helper. Deep down.</p>");215	});216217	test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {218		const page = await body("/demo");219		const main = panelOf(page, "main.ts");220		expect(main).toContain("<dt>lines</dt><dd>2</dd>");221		expect(main).toContain("<dt>saves</dt><dd>2</dd>");222		expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);223		expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);224		expect(main.indexOf(head())).toBeLessThan(main.lastIndexOf(second()));225		const guide = panelOf(page, "guide.md");226		expect(guide).toContain("<dt>saves</dt><dd>2</dd>");227		expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);228		expect(panelOf(page, "logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");229		expect(page).not.toContain(fx.saveIds[1] as string);230	});231232	test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {233		const page = await body("/demo");234		const folderPanel = (path: string) =>235			new RegExp(236				`data-path="${path}">\\n<summary.*?</summary>\\n<template>(.*?)</template>`,237				"s",238			).exec(page)?.[1] ?? "";239		const docs = folderPanel("docs");240		expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');241		expect(docs).toContain("<dt>lines</dt><dd>3</dd>");242		expect(docs).toContain("<dt>files</dt><dd>2</dd>");243		expect(docs).toContain("<dt>saves</dt><dd>2</dd>");244		expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);245		expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);246		const src = folderPanel("src");247		expect(src).toContain("<dt>saves</dt><dd>3</dd>");248		const recentIds = [249			...(/<ul class="atlas-recent">(.*?)<\/ul>/s.exec(src)?.[1] ?? "").matchAll(250				/saves\/([0-9a-f]+)/g,251			),252		].map((m) => m[1]);253		expect(recentIds).toEqual([head(), second(), first()]);254	});255256	test("can be viewed at an older save, and an unknown save is a 404", async () => {257		const page = await body(`/demo?at=${second().slice(0, 8)}`);258		expect(page).toContain(`at <a href="/demo/saves/${second()}">${second()}</a>`);259		expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);260		expect(panelOf(page, "main.ts")).toContain("<dt>saves</dt><dd>1</dd>");261		expect(panelOf(page, "main.ts")).not.toContain(head());262		const oldest = await body(`/demo?at=${first()}`);263		expect(oldest).not.toContain('data-name="main.ts"');264		expect((await get("/demo?at=abcdef")).status).toBe(404);265		expect((await get("/demo?at=../x")).status).toBe(404);266	});267});268269describe("raw files", () => {270	test("are the exact object bytes, text as text/plain", async () => {271		const mirror = openMirror(mirrorDir, "demo");272		for (const [path, type] of [273			["src/main.ts", "text/plain; charset=utf-8"],274			["docs/logo.png", "image/png"],275			["README.md", "text/plain; charset=utf-8"],276		] as const) {277			const response = await get(`/demo/raw/${path}`);278			const file = fileAt(mirror, head(), path);279			expect(response.headers.get("content-type")).toBe(type);280			expect(new Uint8Array(await response.arrayBuffer())).toEqual(281				objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,282			);283		}284		expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");285	});286});287288test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {289	const css = await get("/static/css/kepr.css");290	expect(css.status).toBe(200);291	expect(css.headers.get("content-type")).toContain("text/css");292	expect((await get("/static/../package.json")).status).toBe(404);293	const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";294	expect(csp).toContain("style-src 'self' https://fonts.bunny.net");295	expect(csp).toContain("font-src https://fonts.bunny.net");296	expect(csp).not.toContain("unsafe-inline");297});