From e974027848e4f969aa2b21d79b2276d13cf2bafb Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Tue, 1 Sep 2026 17:22:14 -1000 Subject: Add a test suite for the shipped extensions --- tests/extensions/fake-lexers/lexer.lua | 77 ++++++++ tests/extensions/harness.lua | 268 +++++++++++++++++++++++++ tests/extensions/lib.sh | 70 +++++++ tests/extensions/test-about-render.lua | 248 +++++++++++++++++++++++ tests/extensions/test-auth.lua | 302 +++++++++++++++++++++++++++++ tests/extensions/test-email-avatar.lua | 77 ++++++++ tests/extensions/test-link-commits.lua | 77 ++++++++ tests/extensions/test-syntax-highlight.lua | 94 +++++++++ tests/t0501-about-render.sh | 90 +++++++++ tests/t0502-syntax-highlight.sh | 74 +++++++ tests/t0503-link-commits.sh | 60 ++++++ tests/t0504-email-avatar.sh | 77 ++++++++ tests/t0505-auth.sh | 68 +++++++ 13 files changed, 1582 insertions(+) create mode 100644 tests/extensions/fake-lexers/lexer.lua create mode 100644 tests/extensions/harness.lua create mode 100644 tests/extensions/lib.sh create mode 100644 tests/extensions/test-about-render.lua create mode 100644 tests/extensions/test-auth.lua create mode 100644 tests/extensions/test-email-avatar.lua create mode 100644 tests/extensions/test-link-commits.lua create mode 100644 tests/extensions/test-syntax-highlight.lua create mode 100755 tests/t0501-about-render.sh create mode 100755 tests/t0502-syntax-highlight.sh create mode 100755 tests/t0503-link-commits.sh create mode 100755 tests/t0504-email-avatar.sh create mode 100755 tests/t0505-auth.sh diff --git a/tests/extensions/fake-lexers/lexer.lua b/tests/extensions/fake-lexers/lexer.lua new file mode 100644 index 0000000..adf7d5b --- /dev/null +++ b/tests/extensions/fake-lexers/lexer.lua @@ -0,0 +1,77 @@ +-- A miniature stand-in for the Scintillua lexer module, just enough for the +-- syntax-highlight checks to drive every path through the filter without the +-- real collection installed. load() hands out one of the toy lexers below by +-- name and detect() recognises a single filename suffix, so a check can tell +-- the detection path apart from the extension path by which lexer ends up +-- running. Anything the filter treats as optional, a lexer that will not +-- load or one that raises while lexing, has a name here too. + +local M = {} + +-- Tokens come back the way Scintillua returns them, one flat list of a tag +-- name and the position just past the token it names. Letter runs are +-- keywords, digit runs are numbers, a single quoted run carries a dotted tag +-- so the dotted first component lookup gets exercised, and any other byte is +-- whitespace, which maps to no css class. +local function lex_words(self, text) + local tokens = {} + local pos = 1 + while pos <= #text do + local start, stop = text:find("^%a+", pos) + if not start then + start, stop = text:find("^%d+", pos) + if start then + tokens[#tokens + 1] = "number" + else + start, stop = text:find("^'[^']*'", pos) + if start then + tokens[#tokens + 1] = "string.double" + else + stop = pos + tokens[#tokens + 1] = "whitespace" + end + end + else + tokens[#tokens + 1] = "keyword" + end + tokens[#tokens + 1] = stop + 1 + pos = stop + 1 + end + return tokens +end + +-- Tags only the first four bytes and stops, so the filter has to append the +-- untagged tail itself or the check for it fails. +local function lex_short(self, text) + if #text < 5 then + return {} + end + return { "keyword", 5 } +end + +local function lex_raise(self, text) + error("this lexer always raises") +end + +local lexers = { + fake = { lex = lex_words }, + short = { lex = lex_short }, + badlex = { lex = lex_raise }, +} + +function M.load(name) + local lexer = lexers[name] + if lexer == nil then + error("no fake lexer named " .. tostring(name)) + end + return lexer +end + +function M.detect(filename) + if filename:match("%.viadetect$") then + return "fake" + end + return nil +end + +return M diff --git a/tests/extensions/harness.lua b/tests/extensions/harness.lua new file mode 100644 index 0000000..9085010 --- /dev/null +++ b/tests/extensions/harness.lua @@ -0,0 +1,268 @@ +-- Stand-in for cgit's Lua filter host, dofiled by the test-*.lua files beside +-- it and returning a table of helpers. It provides the html output sinks with +-- the same escaping source/html.c performs, collects everything a filter +-- emits so a check can look at the finished piece of page, and carries the +-- check bookkeeping whose result the t05xx scripts read as the exit code. + +local harness = {} + +local pieces = {} + +-- cgit hands a sink's argument to lua_tostring, which turns a number into +-- digits and anything else into no output at all, and the C side then +-- measures the string with strlen, so everything from the first NUL byte on +-- is dropped. Both behaviours are kept here because the extensions document +-- the truncation and a test has to prove it rather than pass the bytes +-- through. +local function sink(escape) + return function(value) + if type(value) == "number" then + value = tostring(value) + elseif type(value) ~= "string" then + return + end + local nul = value:find("\0", 1, true) + if nul then + value = value:sub(1, nul - 1) + end + if escape then + value = escape(value) + end + pieces[#pieces + 1] = value + end +end + +local txt_map = { ["&"] = "&", ["<"] = "<", [">"] = ">" } +local attr_map = { + ["&"] = "&", ["<"] = "<", [">"] = ">", + ["'"] = "'", ['"'] = """, +} + +html = sink(nil) +html_txt = sink(function(s) + return (s:gsub("[&<>]", txt_map)) +end) +html_attr = sink(function(s) + return (s:gsub("[&<>'\"]", attr_map)) +end) + +-- No shipped extension calls the remaining sinks, so rather than risk an +-- unfaithful copy quietly passing a test, using one fails loudly until its +-- escaping is mirrored from source/html.c the way the three above are. +local function unmirrored(name) + return function() + error(name .. " is not mirrored by the test harness yet") + end +end + +html_url_path = unmirrored("html_url_path") +html_url_arg = unmirrored("html_url_arg") +html_include = unmirrored("html_include") + +function harness.reset() + pieces = {} +end + +function harness.output() + return table.concat(pieces) +end + +function harness.load(script) + dofile(script) +end + +local unpack_args = unpack or table.unpack + +-- One whole filter round trip, an open with the given arguments, a write per +-- string and the close, returning the collected output and the close's +-- answer. +function harness.run(args, writes) + harness.reset() + filter_open(unpack_args(args or {})) + for _, text in ipairs(writes or {}) do + filter_write(text) + end + local ret = filter_close() + return harness.output(), ret +end + +-- Loaders registered here win over any real module on the package path, so a +-- test can hand a script a deterministic stand-in, or with a failing loader +-- prove the script's fallback for a module that is not installed. +function harness.preload(name, module) + package.preload[name] = function() + return module + end +end + +function harness.preload_failure(name) + package.preload[name] = function() + error(name .. " deliberately unavailable in this test") + end +end + +-- Redirect chosen absolute paths to fixtures in the test directory, for the +-- scripts that read configuration from fixed locations like /etc. +local path_map = nil + +function harness.redirect_file(from, to) + if path_map == nil then + path_map = {} + local real_open = io.open + io.open = function(path, mode) + return real_open(path_map[path] or path, mode) + end + end + path_map[from] = to +end + +-- Deterministic bytes standing in for a digest, built from djb2 style lanes +-- in plain arithmetic so they compute the same on every Lua version. Not +-- remotely cryptographic, and enough for what the checks assert, that equal +-- input hashes equal, different input hashes different and a tampered +-- payload no longer verifies. +local function fake_digest_bytes(text) + local lanes = { 5381, 52711, 1313, 7919 } + for i = 1, #text do + local byte = text:byte(i) + for j = 1, 4 do + lanes[j] = (lanes[j] * 33 + byte + j) % 4294967296 + end + end + local bytes = {} + for j = 1, 4 do + local value = lanes[j] + for _ = 1, 4 do + bytes[#bytes + 1] = string.char(value % 256) + value = math.floor(value / 256) + end + end + return table.concat(bytes) +end + +harness.fake_digest_bytes = fake_digest_bytes + +-- The slice of the luaossl digest interface the avatar filters use. +function harness.stub_digest() + harness.preload("openssl.digest", { + new = function(algorithm) + return { + final = function(self, text) + return fake_digest_bytes(algorithm .. "\0" .. text) + end, + } + end, + }) +end + +-- crypt(3) reuses the salt fields of the setting it is handed and appends a +-- hash of the password, so this stand-in keeps the fields and appends the +-- password itself. A stored value of the salt fields plus the password then +-- verifies exactly when the password matches, which is all the login checks +-- need. +local function fake_crypt(password, setting) + local prefix = setting:match("^(%$[^$]+%$[^$]+%$[^$]*%$)") + if prefix == nil then + prefix = setting .. "$" + end + return prefix .. password +end + +harness.fake_crypt = fake_crypt + +-- The slices of luaossl and luaposix the auth filters use. The link and +-- unlink stubs serve the secret creation path, which the auth checks bypass +-- by replacing get_secret, so they only have to exist. +function harness.stub_auth_modules() + local rand_counter = 0 + harness.preload("posix.sys.stat", { + umask = function(mask) + return 18 + end, + }) + harness.preload("posix.unistd", { + crypt = fake_crypt, + link = function(from, to) + return nil + end, + unlink = function(path) + os.remove(path) + return 0 + end, + }) + harness.preload("openssl.rand", { + bytes = function(count) + local out = {} + for i = 1, count do + rand_counter = rand_counter + 1 + out[i] = string.char((rand_counter * 37 + 11) % 256) + end + return table.concat(out) + end, + }) + harness.preload("openssl.hmac", { + new = function(key, algorithm) + return { + final = function(self, payload) + return fake_digest_bytes(key .. "\1" .. + algorithm .. "\1" .. payload) + end, + } + end, + }) +end + +local checks = 0 +local failures = 0 + +local function fail(name, detail) + failures = failures + 1 + io.write("failed check ", name, "\n") + if detail then + io.write(detail, "\n") + end +end + +function harness.check(name, ok, detail) + checks = checks + 1 + if not ok then + fail(name, detail) + end +end + +function harness.equals(name, got, want) + checks = checks + 1 + if got ~= want then + fail(name, "wanted " .. tostring(want) .. + "\n got " .. tostring(got)) + end +end + +function harness.contains(name, haystack, needle) + checks = checks + 1 + if type(haystack) ~= "string" + or not haystack:find(needle, 1, true) then + fail(name, "wanted " .. needle .. + "\nwithin " .. tostring(haystack)) + end +end + +function harness.excludes(name, haystack, needle) + checks = checks + 1 + if type(haystack) ~= "string" + or haystack:find(needle, 1, true) then + fail(name, "did not want " .. needle .. + "\nwithin " .. tostring(haystack)) + end +end + +function harness.finish() + if failures > 0 then + io.write(failures, " of ", checks, " checks failed\n") + os.exit(1) + end + io.write("passed ", checks, " checks\n") + os.exit(0) +end + +return harness diff --git a/tests/extensions/lib.sh b/tests/extensions/lib.sh new file mode 100644 index 0000000..1b31a4b --- /dev/null +++ b/tests/extensions/lib.sh @@ -0,0 +1,70 @@ +# Shared groundwork for the t05xx scripts, sourced after setup.sh from inside +# the trash directory. It locates the shipped extensions and the unit checks +# beside this file, finds the standalone Lua interpreters the checks can run +# under, and offers a probe that asks the Lua inside the cgit binary itself +# whether a module is available to it, since that interpreter can differ from +# every standalone one on the path. + +EXTENSIONS_DIRECTORY=$(cd "$TEST_OUTPUT_DIRECTORY/../custom/extensions" && pwd) +EXT_TEST_DIRECTORY=$(cd "$TEST_OUTPUT_DIRECTORY/extensions" && pwd) + +# Prints the Lua version a binary reports, like 5.1, which is also what +# LuaJIT reports. +ext_lua_version() { + "$1" -e 'io.write(string.match(_VERSION, "%d+%.%d+"))' 2>/dev/null +} + +# Prints the interpreters found on the path whose version falls between 5.1 +# and the given 5.x ceiling, one per line, since each extension states the +# versions it runs on and a test must not fail a script on a version it never +# claimed. +ext_lua_interpreters() { + ext_lua_ceiling=$1 + for ext_lua_bin in luajit lua5.1 lua5.2 lua5.3 lua5.4 lua5.5 lua + do + command -v "$ext_lua_bin" >/dev/null 2>&1 || continue + case "$(ext_lua_version "$ext_lua_bin")" in + 5.[1-9]) + ext_lua_minor=$(ext_lua_version "$ext_lua_bin") + ext_lua_minor=${ext_lua_minor#5.} + test "$ext_lua_minor" -le "$ext_lua_ceiling" && + echo "$ext_lua_bin" + ;; + esac + done +} + +# Asks whether cgit's own Lua can require every module named, by pointing a +# throwaway commit-filter at the given repository and reading its answer off +# the rendered commit page. Filters need a page to run on, which is why a +# repository has to be handed in. +cgit_lua_probe() { + test "$CGIT_HAS_LUA" -eq 1 || return 1 + cgit_lua_probe_gitdir=$1 + shift + { + echo "function filter_open(...) end" + echo "function filter_write(str) end" + echo "function filter_close()" + echo " local ok = true" + for cgit_lua_probe_module in "$@" + do + echo " if not pcall(require, '$cgit_lua_probe_module') then" + echo " ok = false" + echo " end" + done + echo " if ok then html('LUA_PROBE_YES') else html('LUA_PROBE_NO') end" + echo " return 0" + echo "end" + } >lua-probe.lua + cat >lua-probe-cgitrc < & more") +h.contains("no extension renders as plain text", out, + "
plain <text> & more
") +h.equals("close answers zero", ret, 0) + +out = render("notes.txt", "just text") +h.contains("an unknown extension renders as plain text", out, + "
just text
") + +out = render(nil, "no name at all") +h.contains("a missing filename renders as plain text", out, + "
no name at all
") + +-- cgit's C sink stops at the first NUL byte, which the script documents, so +-- the text past one is lost while the wrapper written separately survives. +out = render("README", "before\0after") +h.contains("text before a nul byte survives", out, "before") +h.excludes("text after a nul byte is dropped", out, "after") +h.contains("the wrapper written after the text survives", out, "") + +out = h.run({ "README" }, { "two ", "writes" }) +h.contains("writes are joined before rendering", out, "two writes") + +if mode == "nolpeg" then + out = render("README.md", "# Title") + h.contains("markdown without lpeg falls back to plain text", out, + "
# Title
") + out = render("page.1", ".SH NAME") + h.contains("man without lpeg falls back to plain text", out, + "
.SH NAME
") + h.finish() +end + +if not has_lpeg then + h.finish() +end + +-- Markdown. + +out = render("README.md", "# Title") +h.contains("a heading renders inside the wrapper", + out, "

Title

") + +out = render("README.md", "## Sub ##") +h.contains("trailing hashes are stripped from a heading", out, + "

Sub

") + +out = render("README.MD", "# Upper") +h.contains("the extension matches whatever its case", out, "

Upper

") + +out = render("readme.markdown", "# Long") +h.contains("the long markdown extension dispatches too", out, + "

Long

") + +out = render("README.md", "line one\nline two") +h.contains("a paragraph keeps its line break", out, + "

line one
line two

") + +out = render("README.md", "first para\r\nsecond line") +h.contains("crlf line endings are normalised", out, + "

first para
second line

") + +out = render("README.md", "a **bold** and *leaning* word") +h.contains("double stars embolden", out, "bold") +h.contains("single stars lean", out, "leaning") + +out = render("README.md", "an __up__ and _down_ word") +h.contains("double underscores embolden", out, "up") +h.contains("single underscores lean", out, "down") + +out = render("README.md", "run `x < y` here") +h.contains("inline code is escaped", out, "x < y") + +out = render("README.md", "raw here") +h.contains("markup in text reaches the page escaped", out, + "<script>alert(1)</script>") +h.excludes("no live tag slips through", out, " attempt. + EOF + git add README.md && + git commit -m "add readme" + ) +' + +test_expect_success 'point cgit at it through the about filter' ' + cat >cgitrc <<-EOF + virtual-root=/ + cache-size=0 + enable-filter-overrides=1 + repo.url=md + repo.path=$PWD/repos/md/.git + repo.readme=master:README.md + repo.about-filter=lua:$EXTENSIONS_DIRECTORY/about-render.lua + EOF +' + +if test "$CGIT_HAS_LUA" -eq 1 +then + if cgit_lua_probe "$PWD/repos/md/.git" lpeg + then + test_set_prereq CGIT_LUA_LPEG + else + test_set_prereq CGIT_LUA_NOLPEG + say 'cgit lua has no lpeg, expecting the plain text fallback' + fi +else + say 'cgit built without lua, filter checks through cgit skipped' +fi + +test_expect_success CGIT_LUA_LPEG 'the about page renders the markdown' ' + cgit_url "md/about/" >tmp && + grep "

The Title

" tmp && + grep "bold" tmp +' + +test_expect_success CGIT_LUA_LPEG 'readme markup reaches the page escaped' ' + cgit_url "md/about/" >tmp && + grep "<script>alert(1)</script>" tmp && + ! grep "