| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { |
| 5 | PDF_POLICY, |
| 6 | USERCONTENT_POLICY, |
| 7 | imageSource, |
| 8 | isCommit, |
| 9 | parseRawPath, |
| 10 | rawHeaders, |
| 11 | rawPath, |
| 12 | signRaw, |
| 13 | usercontentPath, |
| 14 | verifyRaw, |
| 15 | } from "./usercontent.ts"; |
| 16 | |
| 17 | const FILE = { owner: "acme", repo: "web", ref: "feat/new", path: "docs/a b.png" }; |
| 18 | |
| 19 | test("a raw file's path keeps a ref with slashes in one segment", () => { |
| 20 | const path = rawPath(FILE); |
| 21 | assert.equal(path, "/acme/web/raw/feat%2Fnew/docs/a%20b.png"); |
| 22 | assert.deepEqual(parseRawPath(path), FILE); |
| 23 | }); |
| 24 | |
| 25 | test("paths that are not a file, or climb out of the repository, are refused", () => { |
| 26 | for (const path of ["/acme/web/raw/main", "/acme/web/blob/main/a.png", "/acme/web/raw/main/../x", "/acme/web/raw/main/a//b", "/acme/web/raw/main/%E0%A4%A"]) { |
| 27 | assert.equal(parseRawPath(path), null, path); |
| 28 | } |
| 29 | }); |
| 30 | |
| 31 | test("usercontent is its own host, or a path on the site", () => { |
| 32 | const hosted = "https://g1tusercontent.com"; |
| 33 | assert.equal(usercontentPath(new URL("https://g1tusercontent.com/acme/web/raw/main/a.png"), hosted), "/acme/web/raw/main/a.png"); |
| 34 | assert.equal(usercontentPath(new URL("https://g1t.sh/acme/web/raw/main/a.png"), hosted), null); |
| 35 | const own = "https://git.example.com/-/usercontent"; |
| 36 | assert.equal(usercontentPath(new URL("http://localhost:8787/-/usercontent/avatars/ab"), own), "/avatars/ab"); |
| 37 | assert.equal(usercontentPath(new URL("http://localhost:8787/-/usercontentx"), own), null); |
| 38 | assert.equal(usercontentPath(new URL("http://localhost:8787/acme/web"), own), null); |
| 39 | }); |
| 40 | |
| 41 | test("a token is good for its file alone, until it ends", async () => { |
| 42 | const now = Date.UTC(2026, 9, 8, 12, 30); |
| 43 | const token = await signRaw("secret", FILE, "repo_1", now); |
| 44 | assert.match(token, /^\d+\.repo_1\.[A-Za-z0-9_-]{43}$/); |
| 45 | // The same within the hour, so a page's addresses are kept by the browser. |
| 46 | assert.equal(await signRaw("secret", FILE, "repo_1", now + 20 * 60_000), token); |
| 47 | assert.equal(await verifyRaw("secret", FILE, token, now), "repo_1"); |
| 48 | assert.equal(await verifyRaw("secret", { ...FILE, owner: "ACME" }, token, now), "repo_1"); |
| 49 | assert.equal(await verifyRaw("secret", { ...FILE, path: "docs/other.png" }, token, now), null); |
| 50 | assert.equal(await verifyRaw("secret", { ...FILE, repo: "api" }, token, now), null); |
| 51 | assert.equal(await verifyRaw("secret", { ...FILE, ref: "main" }, token, now), null); |
| 52 | assert.equal(await verifyRaw("other", FILE, token, now), null); |
| 53 | assert.equal(await verifyRaw("secret", FILE, token.replace("repo_1", "repo_2"), now), null); |
| 54 | assert.equal(await verifyRaw("secret", FILE, token, now + 2 * 3600_000), null); |
| 55 | assert.equal(await verifyRaw("secret", FILE, "nonsense", now), null); |
| 56 | }); |
| 57 | |
| 58 | test("files are served as data that cannot run", () => { |
| 59 | const text = new TextEncoder().encode("<script>alert(1)</script>"); |
| 60 | for (const name of ["index.html", "page.xhtml", "data.xml", "app.js", "README.md"]) { |
| 61 | const headers = rawHeaders(name, text); |
| 62 | assert.equal(headers.get("content-type"), "text/plain; charset=utf-8", name); |
| 63 | assert.equal(headers.get("x-content-type-options"), "nosniff"); |
| 64 | assert.equal(headers.get("content-security-policy"), USERCONTENT_POLICY); |
| 65 | } |
| 66 | assert.equal(rawHeaders("logo.png", new Uint8Array([137, 80, 78, 71])).get("content-type"), "image/png"); |
| 67 | // An SVG shows as an image; opened on its own, its scripts are sandboxed. |
| 68 | const svg = rawHeaders("logo.svg", text); |
| 69 | assert.equal(svg.get("content-type"), "image/svg+xml"); |
| 70 | assert.match(svg.get("content-security-policy")!, /sandbox/); |
| 71 | assert.equal(rawHeaders("paper.pdf", new Uint8Array([37, 80])).get("content-security-policy"), PDF_POLICY); |
| 72 | const binary = rawHeaders("tool.bin", new Uint8Array([0, 1, 2])); |
| 73 | assert.equal(binary.get("content-type"), "application/octet-stream"); |
| 74 | assert.match(binary.get("content-disposition")!, /^attachment; filename="tool.bin"/); |
| 75 | }); |
| 76 | |
| 77 | test("a README's relative pictures are the repository's files at the same commit", () => { |
| 78 | const root = "/acme/web/raw/abc123"; |
| 79 | const docs = `${root}/docs`; |
| 80 | assert.equal(imageSource("logo.png", root), `${root}/logo.png`); |
| 81 | assert.equal(imageSource("./img/a b.png", docs), `${root}/docs/img/a%20b.png`); |
| 82 | assert.equal(imageSource("../logo.png?raw=true", docs), `${root}/logo.png`); |
| 83 | // From the repository's root, as people write them. |
| 84 | assert.equal(imageSource("/assets/x.svg", docs), `${root}/assets/x.svg`); |
| 85 | // Never out of the repository. |
| 86 | assert.equal(imageSource("../../../../other/repo/raw/main/x.png", docs), undefined); |
| 87 | // External pictures, and those with no repository, are as written. |
| 88 | assert.equal(imageSource("https://example.com/x.png", docs), "https://example.com/x.png"); |
| 89 | assert.equal(imageSource("data:image/png;base64,AA", docs), "data:image/png;base64,AA"); |
| 90 | assert.equal(imageSource("logo.png", undefined), "logo.png"); |
| 91 | }); |
| 92 | |
| 93 | test("commits are full hashes", () => { |
| 94 | assert.equal(isCommit("a".repeat(40)), true); |
| 95 | assert.equal(isCommit("main"), false); |
| 96 | }); |