pages.test.ts

6.7 KB · raw

1import { beforeAll, describe, expect, test } from "bun:test";2import { mkdtemp } 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 dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));24	mirrorDir = join(dataDir, "mirror");25	const kepr = openKeprDb(dataDir);26	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir]) {27		await syncAll({ bucket: openBucket({ kind: "folder", path: bucketDir }), kepr, mirrorDir });28	}29	kepr.run(30		"UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",31	);32	app = createApp(33		{34			domain: "kepr.example",35			dataDir,36			adminPasswordHash: "$argon2id$x",37			bucket: { kind: "folder", path: fx.bucketDir },38		},39		kepr,40	);41});4243const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);4445describe("the visibility gate", () => {46	test("a private repo and an unknown one are the same 404, everywhere", async () => {47		for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {48			const hidden = await get(`/hidden${suffix}`);49			const missing = await get(`/nowhere${suffix}`);50			expect(hidden.status).toBe(404);51			expect(missing.status).toBe(404);52			expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());53		}54	});55});5657describe("the repo page", () => {58	test("renders header, timeline, history and README at HEAD with a short cache", async () => {59		const response = await get("/demo");60		expect(response.status).toBe(200);61		expect(response.headers.get("cache-control")).toBe("public, max-age=60");62		const page = await response.text();63		expect(page).toContain('<h1 class="repo-name">demo</h1>');64		expect(page).toContain("A small repo for <strong>Kepr's</strong> tests.");65		expect(page).toContain(66			'<meta property="og:description" content="A small repo for Kepr&#39;s tests.">',67		);68		expect(page).toContain('href="https://demo.example"');69		expect(page).toContain('<span class="lang-badge">TypeScript</span>');70		expect(page).toContain("3 saves");71		expect(page).toContain('data-copy="nix profile add x"');72		expect(page.match(/class="dot d\d"/g)?.length).toBe(3);73		expect(page).toContain("density = activity · position = time");74		const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);75		expect(history).toEqual([head(), second(), first()]);76	});7778	test("checkpoints never appear", async () => {79		expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);80	});8182	test("the README points relative links and images at raw files and drops unsafe links", async () => {83		const page = await body("/demo");84		expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');85		expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');86		expect(page).toContain('<a href="https://example.com">the site</a>');87		expect(page).not.toContain('href="javascript:');88		expect(page).toContain("&lt;script&gt;alert");89		expect(page).not.toContain("<script>alert");90	});91});9293describe("the file viewer", () => {94	test("shows a file at HEAD and at an old save, with line anchors", async () => {95		const now = await get("/demo/files/src/main.ts");96		expect(now.headers.get("cache-control")).toBe("public, max-age=60");97		const nowPage = await now.text();98		expect(nowPage).toContain("hello");99		expect(nowPage).toContain(100			'<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',101		);102		expect(nowPage).toContain('class="tk-');103104		const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);105		expect(then.headers.get("cache-control")).toBe("public, max-age=31536000");106		const thenPage = await then.text();107		expect(thenPage).toContain("&#39;hi&#39;");108		expect(thenPage).not.toContain("hello");109		expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);110	});111112	test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {113		expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);114		expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);115		expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);116		expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);117		expect((await get("/demo/files/nothing.txt")).status).toBe(404);118	});119120	test("Markdown renders, with a source toggle", async () => {121		const rendered = await body("/demo/files/docs/guide.md");122		expect(rendered).toContain("<h1>Guide</h1>");123		const source = await body("/demo/files/docs/guide.md?view=source");124		expect(source).toContain('id="L1"');125		expect(source).not.toContain("<h1>Guide</h1>");126	});127128	test("an image displays inline from its raw file", async () => {129		expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');130	});131});132133describe("raw files", () => {134	test("are the exact object bytes, text as text/plain", async () => {135		const mirror = openMirror(mirrorDir, "demo");136		for (const [path, type] of [137			["src/main.ts", "text/plain; charset=utf-8"],138			["docs/logo.png", "image/png"],139			["README.md", "text/plain; charset=utf-8"],140		] as const) {141			const response = await get(`/demo/raw/${path}`);142			const file = fileAt(mirror, head(), path);143			expect(response.headers.get("content-type")).toBe(type);144			expect(new Uint8Array(await response.arrayBuffer())).toEqual(145				objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,146			);147		}148		expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");149	});150});151152test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {153	const css = await get("/static/css/kepr.css");154	expect(css.status).toBe(200);155	expect(css.headers.get("content-type")).toContain("text/css");156	expect((await get("/static/../package.json")).status).toBe(404);157	const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";158	expect(csp).toContain("style-src 'self' https://fonts.bunny.net");159	expect(csp).toContain("font-src https://fonts.bunny.net");160	expect(csp).not.toContain("unsafe-inline");161});