-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. -- Exercises the router directly against a fixture registry, calling the -- handler with the http.server argument convention: no server involved. local testimony = require("testimony") local fixture = require("tests.fixture") local repos = require("lilgit.repos") local render = require("lilgit.render") local handle = require("lilgit.handle") local http_git = require("http.git") local testify = testimony.new("== lilgit.handle ==") if not fixture.has_git() then print("git binary not available, skipping handle_spec") testify:conclude() return end local fx = fixture.make() assert(fx, "failed to build git fixture") local cfg = { site_title = "test forge", repos = {}, templates = {} } local registry = repos.discover({ repos_dir = fx.root, repos = {} }, nil) local git_handle = assert(http_git.serve(registry.prefix_map)) local handler = handle.new({ cfg = cfg, registry = registry, git_handle = git_handle, version = "test-version", templates = render.build_templates(cfg), }) local get = function(path, headers) return handler("GET", path, "", headers or {}, nil, {}) end local b = fx.branch testify:that("index lists repositories", function() local content, status, headers = get("/") testimony.assert_equal(200, status) testimony.assert_equal("text/html", headers["content-type"]) testimony.assert_match("/fix/", content) testimony.assert_match("/bare/", content) testimony.assert_equal("no-cache", headers["cache-control"]) end) testify:that("repo root redirects to the default branch tree", function() for _, path in ipairs({ "/fix", "/fix/" }) do local _, status, headers = get(path) testimony.assert_equal(302, status) testimony.assert_equal("/fix/tree/" .. b .. "/", headers.location) end end) testify:that("tree without trailing slash 301s to the canonical form", function() local _, status, headers = get("/fix/tree/" .. b) testimony.assert_equal(301, status) testimony.assert_equal("/fix/tree/" .. b .. "/", headers.location) end) testify:that("tree page lists entries and renders the README below", function() local content, status, headers = get("/fix/tree/" .. b .. "/") testimony.assert_equal(200, status) testimony.assert_match("src", content) testimony.assert_match("Fixture Repo", content) testimony.assert_match("Some intro text", content) testimony.assert_equal('"' .. fx.head .. '"', headers.etag) testimony.assert_equal("no-cache", headers["cache-control"]) end) testify:that("slashed branch resolves via longest-ref-first", function() local content, status = get("/fix/tree/feature/foo/") testimony.assert_equal(200, status) testimony.assert_match("feature%.txt", content) end) testify:that("tree URL naming a blob 302s to the blob route", function() local _, status, headers = get("/fix/tree/" .. b .. "/README.md") testimony.assert_equal(302, status) testimony.assert_equal("/fix/blob/" .. b .. "/README.md", headers.location) end) testify:that("blob URL naming a tree 302s to the tree route", function() local _, status, headers = get("/fix/blob/" .. b .. "/assets") testimony.assert_equal(302, status) testimony.assert_equal("/fix/tree/" .. b .. "/assets/", headers.location) end) testify:that("markdown blob renders as an HTML page", function() local content, status, headers = get("/fix/blob/" .. b .. "/README.md") testimony.assert_equal(200, status) testimony.assert_equal("text/html", headers["content-type"]) testimony.assert_match("Fixture Repo", content) testimony.assert_match('href="/fix/raw/' .. b .. '/README%.md"', content) testimony.assert_equal('"' .. fx.head .. '"', headers.etag) end) testify:that("source blob is forced to text/plain", function() local content, status, headers = get("/fix/blob/" .. b .. "/src/main.lua") testimony.assert_equal(200, status) testimony.assert_equal("text/plain; charset=utf-8", headers["content-type"]) testimony.assert_match("hello from fixture", content) end) testify:that("image blob passes through raw and byte-identical to /raw/", function() local blob_content, blob_status, blob_headers = get("/fix/blob/" .. b .. "/assets/logo.png") local raw_content, raw_status, raw_headers = get("/fix/raw/" .. b .. "/assets/logo.png") testimony.assert_equal(200, blob_status) testimony.assert_equal(200, raw_status) testimony.assert_equal("image/png", blob_headers["content-type"]) testimony.assert_equal("image/png", raw_headers["content-type"]) testimony.assert_true(blob_content == raw_content) end) testify:that("unknown extension without NUL bytes sniffs to text/plain on blob", function() local _, _, headers = get("/fix/blob/" .. b .. "/plain.xyz") testimony.assert_equal("text/plain; charset=utf-8", headers["content-type"]) local _, _, raw_headers = get("/fix/raw/" .. b .. "/plain.xyz") testimony.assert_equal("application/octet-stream", raw_headers["content-type"]) end) testify:that("symlink blob is served as its target path text, not rendered", function() local content, _, headers = get("/fix/blob/" .. b .. "/link.md") testimony.assert_equal("text/plain; charset=utf-8", headers["content-type"]) testimony.assert_equal("README.md", content) end) testify:that("If-None-Match yields 304 with the same ETag", function() local _, _, headers = get("/fix/blob/" .. b .. "/src/main.lua") local etag = headers.etag for _, inm in ipairs({ etag, "W/" .. etag, '"other", ' .. etag, "*" }) do local content, status, h2 = get("/fix/blob/" .. b .. "/src/main.lua", { ["if-none-match"] = inm }) testimony.assert_equal(304, status) testimony.assert_equal("", content) testimony.assert_equal(etag, h2.etag) end local _, fresh_status = get("/fix/blob/" .. b .. "/src/main.lua", { ["if-none-match"] = '"stale"' }) testimony.assert_equal(200, fresh_status) end) testify:that("HEAD returns headers with an empty body", function() local content, status, headers = handler("HEAD", "/fix/tree/" .. b .. "/", "", {}, nil, {}) testimony.assert_equal(200, status) testimony.assert_equal("", content) testimony.assert_equal('"' .. fx.head .. '"', headers.etag) end) testify:that("sha-pinned URLs are cached as immutable", function() local _, status, headers = get("/fix/tree/" .. fx.head .. "/") testimony.assert_equal(200, status) testimony.assert_equal("public, max-age=31536000, immutable", headers["cache-control"]) end) testify:that("tag ref resolves and stays revalidatable", function() local _, status, headers = get("/fix/tree/v1.0/") testimony.assert_equal(200, status) testimony.assert_equal("no-cache", headers["cache-control"]) testimony.assert_equal('"' .. fx.tag_commit .. '"', headers.etag) end) testify:that(".git-suffixed repo segment browses the same repo", function() local _, status = get("/fix.git/tree/" .. b .. "/") testimony.assert_equal(200, status) end) testify:that("assets are served with long-lived caching", function() local content, status, headers = get("/_/style.css") testimony.assert_equal(200, status) testimony.assert_equal("text/css", headers["content-type"]) testimony.assert_equal("public, max-age=86400", headers["cache-control"]) testimony.assert_equal('"test-version"', headers.etag) testimony.assert_match("font%-family", content) local _, missing = get("/_/nope.js") testimony.assert_equal(404, missing) end) testify:that("smart-HTTP advertisement is delegated", function() local content, status, headers = handler("GET", "/fix/info/refs", "service=git-upload-pack", {}, nil, {}) testimony.assert_equal(200, status) testimony.assert_equal("application/x-git-upload-pack-advertisement", headers["content-type"]) testimony.assert_equal("001e# service=git-upload-pack\n", content:sub(1, 30)) local _, dumb_status = handler("GET", "/fix/info/refs", "", {}, nil, {}) testimony.assert_equal(403, dumb_status) local _, push_status = handler("POST", "/fix/git-receive-pack", "", {}, "", {}) testimony.assert_equal(403, push_status) local _, wrong_method = handler("GET", "/fix/git-upload-pack", "", {}, nil, {}) testimony.assert_equal(405, wrong_method) end) testify:that("a file path ending in info/refs is served by the UI, not swallowed", function() -- No such file in the fixture, but the route must fall through to -- ref resolution (404), not to the git handler's 403/404 behavior. local content, status = get("/fix/blob/" .. b .. "/info/refs") testimony.assert_equal(404, status) testimony.assert_equal("Not Found", content) end) testify:that("refs page lists branches and tags, slash form redirects", function() local content, status, headers = get("/fix/refs") testimony.assert_equal(200, status) testimony.assert_equal("text/html", headers["content-type"]) testimony.assert_match("feature/foo", content) testimony.assert_match("v1%.0", content) testimony.assert_match(fx.tag_commit:sub(1, 12), content) local _, slash_status, slash_headers = get("/fix/refs/") testimony.assert_equal(301, slash_status) testimony.assert_equal("/fix/refs", slash_headers.location) end) testify:that("tree and blob pages carry the ref switcher", function() local tree_html = get("/fix/tree/" .. b .. "/") testimony.assert_match('
guide%.md", html) end) testify:that("show_sizes adds a sizes column to listings", function() local sized_cfg = { site_title = "sized", repos = {}, templates = {}, show_sizes = true } local sized = handle.new({ cfg = sized_cfg, registry = registry, git_handle = git_handle, version = "test-version", templates = render.build_templates(sized_cfg), }) local html = sized("GET", "/fix/tree/" .. b .. "/", "", {}, nil, {}) testimony.assert_match('class="size"', html) local plain_html = get("/fix/tree/" .. b .. "/") testimony.assert_nil(plain_html:match('class="size"')) end) testify:that("log page walks history, slashed refs and paging work", function() local content, status, headers = get("/fix/log/" .. b) testimony.assert_equal(200, status) testimony.assert_match("second", content) testimony.assert_match("first", content) testimony.assert_match('href="/fix/commit/' .. fx.head .. '"', content) testimony.assert_equal('"' .. fx.head .. '"', headers.etag) testimony.assert_equal("no-cache", headers["cache-control"]) local slashed, slashed_status = get("/fix/log/feature/foo") testimony.assert_equal(200, slashed_status) testimony.assert_match("feature", slashed) local _, bare_status, bare_headers = get("/fix/log") testimony.assert_equal(302, bare_status) testimony.assert_equal("/fix/log/" .. b, bare_headers.location) local _, slash_status, slash_headers = get("/fix/log/" .. b .. "/") testimony.assert_equal(301, slash_status) testimony.assert_equal("/fix/log/" .. b, slash_headers.location) local _, junk_status = get("/fix/log/" .. b .. "/junk") testimony.assert_equal(404, junk_status) end) testify:that("log paging via ?after= is sha-anchored and immutable", function() local content, status, headers = handler("GET", "/fix/log/" .. b, "after=" .. fx.first, {}, nil, {}) testimony.assert_equal(200, status) testimony.assert_match("first", content) testimony.assert_nil(content:match('href="/fix/commit/' .. fx.head .. '"')) testimony.assert_equal('"' .. fx.first .. '"', headers.etag) testimony.assert_equal("public, max-age=31536000, immutable", headers["cache-control"]) local _, bad_status = handler("GET", "/fix/log/" .. b, "after=abc", {}, nil, {}) testimony.assert_equal(404, bad_status) end) testify:that("commit view shows metadata, message and changed files", function() local content, status, headers = get("/fix/commit/" .. fx.head) testimony.assert_equal(200, status) testimony.assert_match("second", content) testimony.assert_match('href="/fix/commit/' .. fx.first .. '"', content) testimony.assert_match("plain%.xyz", content) testimony.assert_match('class="status modified"', content) testimony.assert_match('href="/fix/tree/' .. fx.head .. '/"', content) testimony.assert_equal('"' .. fx.head .. '"', headers.etag) testimony.assert_equal("public, max-age=31536000, immutable", headers["cache-control"]) end) testify:that("commit view peels tag shas, rejects non-shas", function() local _, status, headers = get("/fix/commit/" .. fx.tag) testimony.assert_equal(302, status) testimony.assert_equal("/fix/commit/" .. fx.tag_commit, headers.location) local _, short_status = get("/fix/commit/" .. fx.head:sub(1, 12)) testimony.assert_equal(404, short_status) local _, ref_status = get("/fix/commit/" .. b) testimony.assert_equal(404, ref_status) local _, blob_status = get("/fix/commit/" .. fx.readme_blob) testimony.assert_equal(404, blob_status) end) testify:that("tree page links to the log", function() local html = get("/fix/tree/" .. b .. "/") testimony.assert_match('class="log%-link" href="/fix/log/' .. b .. '"', html) end) testify:that("bad methods get 405 with an allow header", function() local _, status, headers = handler("POST", "/fix/tree/" .. b .. "/", "", {}, "", {}) testimony.assert_equal(405, status) testimony.assert_equal("GET, HEAD", headers.allow) local _, index_status = handler("DELETE", "/", "", {}, nil, {}) testimony.assert_equal(405, index_status) end) testify:that("nested repo browse routes work", function() for _, path in ipairs({ "/cat1/repo1", "/cat1/repo1/" }) do local _, status, headers = get(path) testimony.assert_equal(302, status) testimony.assert_equal("/cat1/repo1/tree/" .. b .. "/", headers.location) end local content, status = get("/cat1/repo1/tree/" .. b .. "/") testimony.assert_equal(200, status) testimony.assert_match('href="/cat1/repo1/blob/' .. b .. "/", content) testimony.assert_match('href="/cat1/repo1/refs"', content) -- The nav crumb keeps breadcrumb spacing: category as a plain -- segment, only the repo name is the link. testimony.assert_match('cat1 / repo1', content) local _, git_status = get("/cat1/repo1.git/tree/" .. b .. "/") testimony.assert_equal(200, git_status) local _, cat2_status = get("/cat2/repo1/tree/" .. b .. "/") testimony.assert_equal(200, cat2_status) local _, log_status = get("/cat1/repo1/log/" .. b) testimony.assert_equal(200, log_status) local _, commit_status = get("/cat1/repo1/commit/" .. fx.head) testimony.assert_equal(200, commit_status) end) testify:that("nested smart-HTTP advertisement is delegated", function() for _, path in ipairs({ "/cat1/repo1/info/refs", "/cat1/repo1.git/info/refs" }) do local _, status, headers = handler("GET", path, "service=git-upload-pack", {}, nil, {}) testimony.assert_equal(200, status, "advertisement failed for " .. path) testimony.assert_equal("application/x-git-upload-pack-advertisement", headers["content-type"]) end end) testify:that("nested repos appear on the index with qualified links", function() local content = get("/") testimony.assert_match('href="/cat1/repo1/tree/', content) testimony.assert_match('href="/cat2/repo1/tree/', content) testimony.assert_match("localhost/cat1/repo1", content) testimony.assert_match("localhost/cat2/repo1", content) end) testify:that("encoded slash and bare category segments miss", function() local _, enc_status = get("/cat1%2Frepo1") testimony.assert_equal(404, enc_status) local _, cat_status = get("/cat1") testimony.assert_equal(404, cat_status) local _, cat_slash_status = get("/cat1/") testimony.assert_equal(404, cat_slash_status) end) testify:that("markdown renders are cached by blob sha", function() local cache = require("lilgit.cache") local markdown = require("markdown") local real_render = markdown.render_html local count = 0 markdown.render_html = function(...) count = count + 1 return real_render(...) end local cached = handle.new({ cfg = cfg, registry = registry, git_handle = git_handle, version = "test-version", templates = render.build_templates(cfg), md_cache = cache.new(8), }) local first_html, first_status = cached("GET", "/fix/blob/" .. b .. "/README.md", "", {}, nil, {}) local second_html = cached("GET", "/fix/blob/" .. b .. "/README.md", "", {}, nil, {}) testimony.assert_equal(200, first_status) testimony.assert_equal(1, count) testimony.assert_true(first_html == second_html) -- The filename-fallback title (no h1 in the document) is applied per -- request, never cached: the cached second page must be identical. local notes_html = cached("GET", "/fix/blob/" .. b .. "/notes.md", "", {}, nil, {}) local notes_again = cached("GET", "/fix/blob/" .. b .. "/notes.md", "", {}, nil, {}) testimony.assert_equal(2, count) testimony.assert_match("notes%.md", notes_again) testimony.assert_true(notes_html == notes_again) markdown.render_html = real_render end) testify:that("traversal and unknown targets 404", function() local paths = { "/nope/tree/" .. b .. "/", "/fix/tree/no-such-ref/", "/fix/blob/" .. b .. "/../README.md", "/fix/blob/" .. b .. "/no-such-file", "/fix/zzz/" .. b .. "/", } for _, path in ipairs(paths) do local _, status = get(path) testimony.assert_equal(404, status, "expected 404 for " .. path) end end) fixture.destroy(fx) testify:conclude()