diff options
context:
space:
mode:
Diffstat (limited to 'tests/extensions')
-rw-r--r--tests/extensions/fake-lexers/lexer.lua77
-rw-r--r--tests/extensions/harness.lua268
-rw-r--r--tests/extensions/lib.sh70
-rw-r--r--tests/extensions/test-about-render.lua248
-rw-r--r--tests/extensions/test-auth.lua302
-rw-r--r--tests/extensions/test-email-avatar.lua77
-rw-r--r--tests/extensions/test-link-commits.lua77
-rw-r--r--tests/extensions/test-syntax-highlight.lua94
8 files changed, 1213 insertions, 0 deletions
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 = { ["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;" }
+local attr_map = {
+ ["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;",
+ ["'"] = "&#x27;", ['"'] = "&quot;",
+}
+
+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 <<EOF
+virtual-root=/
+cache-size=0
+enable-filter-overrides=1
+repo.url=lua-probe
+repo.path=$cgit_lua_probe_gitdir
+repo.commit-filter=lua:$PWD/lua-probe.lua
+EOF
+ CGIT_CONFIG="$PWD/lua-probe-cgitrc" QUERY_STRING="url=lua-probe/commit/" \
+ cgit | grep -q LUA_PROBE_YES
+}
diff --git a/tests/extensions/test-about-render.lua b/tests/extensions/test-about-render.lua
new file mode 100644
index 0000000..aa7e6ad
--- /dev/null
+++ b/tests/extensions/test-about-render.lua
@@ -0,0 +1,248 @@
+-- Unit checks for custom/extensions/about-render.lua, run under a standalone
+-- Lua by t0501-about-render.sh with the script path as the first argument.
+-- The second argument may be nolpeg, which makes the lpeg module fail to
+-- load so the escaped plain text fallback is proven even on a machine where
+-- lpeg is installed. Without it the markdown and man checks run when lpeg is
+-- present and are skipped with a note when it is not, the same way the
+-- filter itself degrades.
+
+local test_directory = arg[0]:match("^(.*)/") or "."
+local h = dofile(test_directory .. "/harness.lua")
+
+local script = arg[1]
+local mode = arg[2] or ""
+
+local has_lpeg = false
+if mode == "nolpeg" then
+ h.preload_failure("lpeg")
+else
+ has_lpeg = pcall(require, "lpeg")
+ if not has_lpeg then
+ io.write("lpeg not installed, markdown and man checks skipped\n")
+ end
+end
+
+h.load(script)
+
+local function render(filename, text)
+ return h.run({ filename }, { text })
+end
+
+-- Dispatch and the plain text rendering need nothing but the script itself,
+-- so they run whatever became of lpeg.
+
+local out, ret = render("README", "plain <text> & more")
+h.contains("no extension renders as plain text", out,
+ "<pre class='plaintext'>plain &lt;text&gt; &amp; more</pre>")
+h.equals("close answers zero", ret, 0)
+
+out = render("notes.txt", "just text")
+h.contains("an unknown extension renders as plain text", out,
+ "<pre class='plaintext'>just text</pre>")
+
+out = render(nil, "no name at all")
+h.contains("a missing filename renders as plain text", out,
+ "<pre class='plaintext'>no name at all</pre>")
+
+-- 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, "</pre>")
+
+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,
+ "<pre class='plaintext'># Title</pre>")
+ out = render("page.1", ".SH NAME")
+ h.contains("man without lpeg falls back to plain text", out,
+ "<pre class='plaintext'>.SH NAME</pre>")
+ 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, "<div class='markdown'><h1>Title</h1></div>")
+
+out = render("README.md", "## Sub ##")
+h.contains("trailing hashes are stripped from a heading", out,
+ "<h2>Sub</h2>")
+
+out = render("README.MD", "# Upper")
+h.contains("the extension matches whatever its case", out, "<h1>Upper</h1>")
+
+out = render("readme.markdown", "# Long")
+h.contains("the long markdown extension dispatches too", out,
+ "<h1>Long</h1>")
+
+out = render("README.md", "line one\nline two")
+h.contains("a paragraph keeps its line break", out,
+ "<p>line one<br>line two</p>")
+
+out = render("README.md", "first para\r\nsecond line")
+h.contains("crlf line endings are normalised", out,
+ "<p>first para<br>second line</p>")
+
+out = render("README.md", "a **bold** and *leaning* word")
+h.contains("double stars embolden", out, "<strong>bold</strong>")
+h.contains("single stars lean", out, "<em>leaning</em>")
+
+out = render("README.md", "an __up__ and _down_ word")
+h.contains("double underscores embolden", out, "<strong>up</strong>")
+h.contains("single underscores lean", out, "<em>down</em>")
+
+out = render("README.md", "run `x < y` here")
+h.contains("inline code is escaped", out, "<code>x &lt; y</code>")
+
+out = render("README.md", "raw <script>alert(1)</script> here")
+h.contains("markup in text reaches the page escaped", out,
+ "&lt;script&gt;alert(1)&lt;/script&gt;")
+h.excludes("no live tag slips through", out, "<script>")
+
+out = render("README.md", "[docs](https://example.com/docs)")
+h.contains("an https link renders as an anchor", out,
+ "<a href='https://example.com/docs'>docs</a>")
+
+out = render("README.md", "[docs](docs/page.html)")
+h.contains("a relative link renders as an anchor", out,
+ "<a href='docs/page.html'>docs</a>")
+
+out = render("README.md", "[mail](mailto:a@example.com)")
+h.contains("a mailto link renders as an anchor", out,
+ "<a href='mailto:a@example.com'>mail</a>")
+
+out = render("README.md", "[bad](javascript:alert(1))")
+h.excludes("a javascript link renders no anchor", out, "<a ")
+h.contains("its text still reaches the page", out, "bad")
+
+out = render("README.md", "[bad](java\nscript:alert(1))")
+h.excludes("a split scheme is still caught", out, "<a ")
+
+out = render("README.md", "[bad](//evil.example/x)")
+h.excludes("a scheme relative link renders no anchor", out, "<a ")
+
+out = render("README.md", "[q](https://example.com/a'b)")
+h.contains("a quote in a link target is escaped", out,
+ "href='https://example.com/a&#x27;b'")
+
+out = render("README.md", "![a & b](https://example.com/i.png)")
+h.contains("an image renders with its alt text escaped", out,
+ "<img src='https://example.com/i.png' alt='a &amp; b'>")
+
+out = render("README.md", "![alt](data:image/png;base64,x)")
+h.excludes("an unsafe image renders no tag", out, "<img")
+h.contains("its alt text is kept as text", out, "![alt]")
+
+out = render("README.md", "```lua\nlocal x = 1 < 2\n```\nafter")
+h.contains("a fenced block carries its language", out,
+ "<pre><code data-lang='lua'>local x = 1 &lt; 2\n</code></pre>")
+h.contains("text after the fence renders on", out, "<p>after</p>")
+
+out = render("README.md", "~~~\n**not bold**\n~~~")
+h.contains("no inline parsing happens inside a fence", out,
+ "<code>**not bold**\n</code>")
+
+out = render("README.md", "---")
+h.contains("a rule of dashes renders a break", out, "<hr>")
+
+out = render("README.md", "- - -")
+h.contains("a spaced rule renders a break too", out, "<hr>")
+
+out = render("README.md", "> quoted words")
+h.contains("a quote renders as a blockquote", out,
+ "<blockquote><p>quoted words</p></blockquote>")
+
+out = render("README.md", "> > deep")
+h.contains("quotes nest", out,
+ "<blockquote><blockquote><p>deep</p></blockquote></blockquote>")
+
+-- A hostile readme of stacked markers has to hit the depth ceiling instead
+-- of the interpreter's stack.
+out = render("README.md", string.rep("> ", 30) .. "x")
+local _, quote_count = out:gsub("<blockquote>", "")
+h.equals("quote nesting stops at the ceiling", quote_count, 24)
+h.contains("the innermost text still renders", out, "x")
+
+out = render("README.md", string.rep("*a", 200))
+h.check("an emphasis bomb still renders something", #out > 0)
+
+out = render("README.md", "| a | b |\n| --- | --- |\n| 1 | 2 |")
+h.contains("a pipe table renders its head",
+ out, "<thead><tr><th>a</th><th>b</th></tr></thead>")
+h.contains("a pipe table renders its body",
+ out, "<tbody><tr><td>1</td><td>2</td></tr></tbody>")
+
+out = render("README.md", "a | b in prose")
+h.excludes("a pipe without a delimiter row stays prose", out, "<table")
+
+out = render("README.md", "- first\n- second")
+h.contains("dashes render an unordered list", out,
+ "<ul><li>first</li><li>second</li></ul>")
+
+out = render("README.md", "1. first\n2. second")
+h.contains("numbers render an ordered list", out,
+ "<ol><li>first</li><li>second</li></ol>")
+
+out = render("README.md", "- plain\n1. numbered")
+h.contains("a change of marker splits the list", out, "</ul><ol>")
+
+out = render("README.md", "- a **bold** item")
+h.contains("list items parse their text", out,
+ "<li>a <strong>bold</strong> item</li>")
+
+out = render("README.md", string.rep("a", 512 * 1024 + 1))
+h.contains("an oversized readme renders as plain text", out,
+ "<pre class='plaintext'>")
+
+-- Man pages.
+
+out = render("page.1", ".SH NAME")
+h.contains("a section heading renders", out,
+ "<div class='markdown manpage'><h2>NAME</h2></div>")
+
+out = render("page.man", '.SH "TWO WORDS"')
+h.contains("a quoted heading argument is unquoted", out,
+ "<h2>TWO WORDS</h2>")
+
+out = render("page.3", ".SS Details")
+h.contains("a subsection renders one level down", out, "<h3>Details</h3>")
+
+out = render("page.1", ".B bold words\nplain after")
+h.contains("the bold macro renders strong", out,
+ "<p><strong>bold words</strong><br>plain after</p>")
+
+out = render("page.1", "a \\fBbold\\fR c")
+h.contains("font escapes carry across a run", out,
+ "<p>a <strong>bold</strong> c</p>")
+
+out = render("page.1", "x \\(em y \\- z")
+h.contains("character escapes are translated", out, "<p>x - y - z</p>")
+
+out = render("page.1", ".nf\ncode <x>\n.fi\nafter")
+h.contains("a no fill block renders as code", out,
+ "<pre><code>code &lt;x&gt;\n</code></pre>")
+h.contains("filling resumes after it", out, "<p>after</p>")
+
+out = render("page.1", '.\\" a comment\nvisible')
+h.excludes("a roff comment is dropped", out, "comment")
+h.contains("the line after it renders", out, "visible")
+
+out = render("page.1", "first\n\nsecond")
+h.contains("a blank line splits paragraphs", out,
+ "<p>first</p><p>second</p>")
+
+out = render("page.1", ".TH title 1\nbody")
+h.excludes("the title macro renders nothing itself", out, "title 1")
+h.contains("the body after it renders", out, "<p>body</p>")
+
+h.finish()
diff --git a/tests/extensions/test-auth.lua b/tests/extensions/test-auth.lua
new file mode 100644
index 0000000..359f52e
--- /dev/null
+++ b/tests/extensions/test-auth.lua
@@ -0,0 +1,302 @@
+-- Unit checks for the two auth filters, run under a standalone Lua by
+-- t0505-auth.sh with the script path as the first argument and the variant,
+-- inline or file, as the second. The scripts take luaossl and luaposix at
+-- load, so the harness meets both with deterministic stand-ins, which keeps
+-- every check about this script's own logic, the cookie layout, the expiry
+-- and field rules, the redirect vetting and the action flows, rather than
+-- about OpenSSL. The signing secret is pinned by replacing get_secret, and
+-- the account and access lookups are populated through each variant's own
+-- channel, the documented account_hash and repo_userset swap points for the
+-- inline script and fixture files reached through a redirected io.open for
+-- the file one. Both variants carry the same data so the flow checks read
+-- identically for the two.
+
+local test_directory = arg[0]:match("^(.*)/") or "."
+local h = dofile(test_directory .. "/harness.lua")
+
+local script = arg[1]
+local variant = arg[2]
+
+h.stub_auth_modules()
+
+-- The password behind every test account, stored as what the harness crypt
+-- stand-in returns for it so a login with it verifies and any other fails.
+local password = "open sesame"
+local stored_hash = "$6$rounds=300000$testsalt$" .. password
+
+if variant == "file" then
+ local users_file = io.open("auth-users", "w")
+ users_file:write("Alice:" .. stored_hash .. "\r\n")
+ users_file:write("not a parsable line\n")
+ users_file:close()
+ local groups_file = io.open("auth-groups", "w")
+ groups_file:write("devs:Alice, bob\n")
+ groups_file:write("others:carol\n")
+ groups_file:close()
+ local repos_file = io.open("auth-repos", "w")
+ repos_file:write("secret-repo:devs\n")
+ repos_file:write("empty-repo:ghosts\n")
+ repos_file:close()
+ h.redirect_file("/etc/cgit-auth/users", "auth-users")
+ h.redirect_file("/etc/cgit-auth/groups", "auth-groups")
+ h.redirect_file("/etc/cgit-auth/repos", "auth-repos")
+end
+
+h.load(script)
+
+function get_secret()
+ return string.rep("0123456789abcdef", 4)
+end
+
+if variant == "inline" then
+ local accounts = { alice = stored_hash }
+ local access = {
+ ["secret-repo"] = { alice = true, bob = true },
+ ["empty-repo"] = {},
+ }
+ function account_hash(user)
+ if user == nil then
+ return nil
+ end
+ return accounts[user:lower()]
+ end
+ function repo_userset(repo)
+ if repo == nil then
+ return nil
+ end
+ return access[repo]
+ end
+end
+
+-- The lookups themselves, which are the part the two variants do not share.
+
+local hash = account_hash("ALICE")
+h.equals("an account is found whatever its case", hash, stored_hash)
+h.equals("an unknown account is not", account_hash("nobody"), nil)
+h.equals("a nil user is not", account_hash(nil), nil)
+
+local userset = repo_userset("secret-repo")
+h.check("a protected repository lists its users",
+ userset ~= nil and userset.alice and userset.bob)
+h.equals("an unlisted repository is public", repo_userset("unlisted"), nil)
+h.equals("the repository name matches case exactly",
+ repo_userset("Secret-Repo"), nil)
+local empty = repo_userset("empty-repo")
+h.check("a protected repository with no members denies as an empty set",
+ empty ~= nil and next(empty) == nil)
+
+if variant == "file" then
+ h.redirect_file("/etc/cgit-auth/users", "auth-users-missing")
+ h.equals("a missing users file turns every login down",
+ account_hash("alice"), nil)
+ h.redirect_file("/etc/cgit-auth/users", "auth-users")
+end
+
+-- The url and cookie helpers.
+
+h.equals("decode handles escapes and plus", url_decode("%41+b"), "A b")
+h.equals("encode escapes what is not a word", url_encode("a b|c"), "a+b%7Cc")
+local tricky = "x&=?|"
+h.equals("decode inverts encode", url_decode(url_encode(tricky)), tricky)
+
+local params = parse_query("u=a&p=b=c&x=%41")
+h.equals("a query splits on ampersands", params.u, "a")
+h.equals("a value keeps its own equals signs", params.p, "b=c")
+h.equals("a value is decoded", params.x, "A")
+
+h.equals("a cookie is found among others",
+ get_cookie("foo=1; cgitauth=abc; bar=2", "cgitauth"), "abc")
+h.equals("a lone cookie is found", get_cookie("cgitauth=abc", "cgitauth"),
+ "abc")
+h.equals("no header yields no cookie", get_cookie(nil, "cgitauth"), nil)
+h.equals("a longer name does not match",
+ get_cookie("xcgitauth=z", "cgitauth"), nil)
+h.equals("a magic character in the name is taken literally",
+ get_cookie("a-b=z", "a-b"), "z")
+
+h.check("equal strings compare equal", constant_equals("abc", "abc"))
+h.check("differing strings do not", not constant_equals("abc", "abd"))
+h.check("differing lengths do not", not constant_equals("a", "ab"))
+h.check("non strings do not", not constant_equals(nil, "a"))
+
+h.check("a local path is a safe redirect", is_safe_redirect("/x"))
+h.check("a scheme relative target is not", not is_safe_redirect("//evil"))
+h.check("a backslash variant is not", not is_safe_redirect("/\\evil"))
+h.check("a missing target is not", not is_safe_redirect(nil))
+
+h.equals("control characters are stripped from header values",
+ strip_controls("a\r\nb"), "ab")
+
+-- Signing and verification.
+
+local now = os.time()
+
+local cookie = secure_value("username", "alice", now + 3600)
+h.equals("a signed value verifies and comes back",
+ validate_value("username", cookie), "alice")
+
+cookie = secure_value("username", "a|b", now + 3600)
+h.equals("a value holding the separator survives the round trip",
+ validate_value("username", cookie), "a|b")
+
+cookie = secure_value("username", "a\nb", now + 3600)
+h.equals("a signed control character is still rejected on the way out",
+ validate_value("username", cookie), nil)
+
+cookie = secure_value("redirect", "/repo/?a=b", 0)
+h.equals("an expiration of zero never expires",
+ validate_value("redirect", cookie), "/repo/?a=b")
+
+cookie = secure_value("username", "alice", now - 10)
+h.equals("an expired value is rejected",
+ validate_value("username", cookie), nil)
+
+cookie = secure_value("username", "alice", now + 3600)
+local flipped = cookie:sub(1, -2) ..
+ (cookie:sub(-1) == "0" and "1" or "0")
+h.equals("a tampered signature is rejected",
+ validate_value("username", flipped), nil)
+
+h.equals("a value signed for one field does not serve another",
+ validate_value("redirect", cookie), nil)
+
+h.equals("no cookie does not verify", validate_value("username", nil), nil)
+h.equals("a tiny cookie does not verify", validate_value("username", "ab"),
+ nil)
+h.equals("a leading separator does not verify",
+ validate_value("username", "|x|1|s|sig"), nil)
+h.equals("an unsigned cookie does not verify",
+ validate_value("username", "username|alice|123|salt"), nil)
+h.equals("an exponent spelling of the expiry does not verify",
+ validate_value("username", "username|alice|1e9|salt|beef"), nil)
+h.equals("an empty value signs to nothing", secure_value("username", "", 1),
+ "")
+
+-- The headers the filter writes itself.
+
+h.reset()
+set_cookie("cgitauth", "value")
+local out = h.output()
+h.contains("a session cookie carries the hardening attributes", out,
+ "Set-Cookie: cgitauth=value; HttpOnly; SameSite=Lax; Path=/; Secure; " ..
+ "Max-Age=604800\n")
+
+h.reset()
+set_cookie("cgitauth", "")
+h.contains("an empty value clears the cookie instead", h.output(),
+ "Set-Cookie: cgitauth=; HttpOnly; SameSite=Lax; Path=/; Secure; " ..
+ "Max-Age=0\n")
+
+h.reset()
+redirect_to("/x\r\nX-Evil: 1")
+out = h.output()
+h.contains("a newline cannot split the location header", out,
+ "Location: /xX-Evil: 1\n")
+h.excludes("no carriage return slips through", out, "\r")
+
+-- The three actions, driven the way cgit drives them, an open carrying the
+-- request, a write only for the posted form and the answer read off the
+-- close.
+
+local function run_action(action, opts)
+ opts = opts or {}
+ h.reset()
+ filter_open(action, opts.cookie or "", opts.method or "GET", "", "",
+ "/", "example.org", "on", opts.repo or "", "summary",
+ opts.url or "/repo/", "/?p=login")
+ if opts.body then
+ filter_write(opts.body)
+ end
+ local ret = filter_close()
+ return h.output(), ret
+end
+
+local ret
+
+out, ret = run_action("authenticate-cookie", { repo = "unlisted" })
+h.equals("a public repository needs no cookie", ret, 1)
+
+out, ret = run_action("authenticate-cookie", { repo = "secret-repo" })
+h.equals("a protected repository turns a bare request away", ret, 0)
+
+local session = secure_value("username", "Alice", now + 3600)
+out, ret = run_action("authenticate-cookie",
+ { repo = "secret-repo", cookie = "cgitauth=" .. session })
+h.equals("a signed session for a member is let through", ret, 1)
+
+out, ret = run_action("authenticate-cookie",
+ { repo = "empty-repo", cookie = "cgitauth=" .. session })
+h.equals("a memberless repository denies even a valid session", ret, 0)
+
+local outsider = secure_value("username", "carol", now + 3600)
+out, ret = run_action("authenticate-cookie",
+ { repo = "secret-repo", cookie = "cgitauth=" .. outsider })
+h.equals("a signed session for an outsider is turned away", ret, 0)
+
+local forged = session:sub(1, -2) ..
+ (session:sub(-1) == "0" and "1" or "0")
+out, ret = run_action("authenticate-cookie",
+ { repo = "secret-repo", cookie = "cgitauth=" .. forged })
+h.equals("a forged session is turned away", ret, 0)
+
+out, ret = run_action("no-such-action", {})
+h.equals("an unknown action denies rather than raising", ret, 0)
+
+out, ret = run_action("body", { url = "/repo/log/?q=x" })
+h.contains("the login form posts to the login url", out,
+ "<form method='post' action='/?p=login'>")
+local token = out:match("name='redirect' value='([^']*)'")
+h.equals("the form carries a signed way back",
+ token and validate_value("redirect", token), "/repo/log/?q=x")
+
+out, ret = run_action("body", { url = "//evil.example/x" })
+token = out:match("name='redirect' value='([^']*)'")
+h.equals("an unsafe destination is swapped for the login page",
+ token and validate_value("redirect", token), "/?p=login")
+
+local way_back = secure_value("redirect", "/repo/", 0)
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=" .. url_encode(password) ..
+ "&redirect=" .. url_encode(way_back),
+})
+h.contains("a good login redirects back", out, "Status: 302")
+h.contains("to where the form said", out, "Location: /repo/\n")
+local granted = out:match("Set%-Cookie: cgitauth=([^;]*);")
+h.equals("and grants a session that verifies",
+ granted and validate_value("username", granted), "Alice")
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=wrong&redirect=" ..
+ url_encode(way_back),
+})
+h.contains("a bad password redirects the same way", out, "Status: 302")
+h.contains("but clears the session cookie", out,
+ "Set-Cookie: cgitauth=; ")
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=nobody&password=x&redirect=" .. url_encode(way_back),
+})
+h.contains("an unknown user is told nothing different", out,
+ "Set-Cookie: cgitauth=; ")
+
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=" .. url_encode(password),
+})
+h.contains("a post without the signed token is not served", out,
+ "Status: 404")
+
+local hijack = secure_value("redirect", "//evil.example/", 0)
+out, ret = run_action("authenticate-post", {
+ method = "POST",
+ body = "username=Alice&password=" .. url_encode(password) ..
+ "&redirect=" .. url_encode(hijack),
+})
+h.contains("even a signed unsafe destination is not followed", out,
+ "Status: 404")
+
+h.finish()
diff --git a/tests/extensions/test-email-avatar.lua b/tests/extensions/test-email-avatar.lua
new file mode 100644
index 0000000..d6bd957
--- /dev/null
+++ b/tests/extensions/test-email-avatar.lua
@@ -0,0 +1,77 @@
+-- Unit checks for the two avatar email filters, run under a standalone Lua
+-- by t0504-email-avatar.sh with the script path as the first argument. The
+-- two scripts differ only in the service they point at, so the expected host
+-- is picked from the script's name. The second argument chooses how the
+-- openssl.digest dependency is met, stub swaps in the deterministic fake
+-- from the harness, and real uses an installed luaossl and adds the one
+-- check only a genuine MD5 can pass.
+
+local test_directory = arg[0]:match("^(.*)/") or "."
+local h = dofile(test_directory .. "/harness.lua")
+
+local script = arg[1]
+local mode = arg[2] or "stub"
+
+local base_url = "https://www.gravatar.com/avatar/"
+local alt_text = "Gravatar"
+if script:find("libravatar", 1, true) then
+ base_url = "https://seccdn.libravatar.org/avatar/"
+ alt_text = "Libravatar"
+end
+
+if mode == "stub" then
+ h.stub_digest()
+end
+
+h.load(script)
+
+local function render(email, name)
+ return h.run({ email, "commit" }, { name })
+end
+
+local function hash_of(output)
+ local prefix = base_url:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
+ return output:match("src='" .. prefix .. "(%x+)%?")
+end
+
+local out, ret = render("<author@example.com>", "A U Thor")
+h.contains("an icon is drawn for an address", out, "<img src='" .. base_url)
+h.contains("the size lands in the query", out, "?s=13&amp;d=retro'")
+h.contains("the alt text names the service", out, "alt='" .. alt_text .. "'")
+h.contains("width matches the size", out, "width='13'")
+h.contains("height matches the size", out, "height='13'")
+h.contains("the fetch is deferred", out, "loading='lazy' decoding='async'")
+h.contains("the name follows the icon", out, "> A U Thor")
+h.equals("close answers zero", ret, 0)
+h.check("the hash is hex", hash_of(out) ~= nil)
+
+-- The service hashes the trimmed lowercase address, so every spelling of one
+-- address has to reach it as the same hash.
+local plain = hash_of(render("author@example.com", "x"))
+local wrapped = hash_of(render("<author@example.com>", "x"))
+local shouting = hash_of(render(" <AUTHOR@Example.COM> ", "x"))
+h.check("angle brackets do not change the hash", plain == wrapped)
+h.check("case and padding do not change the hash", plain == shouting)
+
+local other = hash_of(render("other@example.com", "x"))
+h.check("a different address hashes differently", plain ~= other)
+
+out = render(nil, "A U Thor")
+h.equals("no address draws no icon", out, "A U Thor")
+
+out = render("", "A U Thor")
+h.equals("an empty address draws no icon", out, "A U Thor")
+
+out = render("<>", "A U Thor")
+h.equals("empty angle brackets draw no icon", out, "A U Thor")
+
+out = h.run({ "<author@example.com>", "commit" }, { "A U ", "Thor" })
+h.contains("the name is buffered across writes", out, "> A U Thor")
+
+if mode == "real" then
+ out = render("test@example.com", "x")
+ h.equals("a known address hashes to its published md5",
+ hash_of(out), "55502f40dc8b7c769880b10874abc9d0")
+end
+
+h.finish()
diff --git a/tests/extensions/test-link-commits.lua b/tests/extensions/test-link-commits.lua
new file mode 100644
index 0000000..6c6d28d
--- /dev/null
+++ b/tests/extensions/test-link-commits.lua
@@ -0,0 +1,77 @@
+-- Unit checks for custom/extensions/link-commits.lua, run under a standalone
+-- Lua by t0503-link-commits.sh with the script path as the argument. The
+-- shipped configuration links bare object names to ./?id= and issue marks
+-- like #123 to bugs.example.com, and everything here checks that shape, the
+-- length bounds and frontier on hash recognition, and the rule that no run
+-- of text is ever wrapped twice.
+
+local test_directory = arg[0]:match("^(.*)/") or "."
+local h = dofile(test_directory .. "/harness.lua")
+
+h.load(arg[1])
+
+local function render(text)
+ return h.run({}, { text })
+end
+
+local out, ret = render("see #123 for details")
+h.contains("issue mark becomes a link", out,
+ "<a href='https://bugs.example.com/?bug=123'>#123</a>")
+h.contains("text around the link survives", out, "see ")
+h.contains("text after the link survives", out, " for details")
+h.equals("close answers zero", ret, 0)
+
+out = render("fixed in deadbee")
+h.contains("seven hex characters link", out,
+ "<a href='./?id=deadbee'>deadbee</a>")
+
+out = render("fixed in deadbe")
+h.excludes("six hex characters stay text", out, "<a")
+
+out = render("fixed in 1234567")
+h.contains("an all digit run still links", out,
+ "<a href='./?id=1234567'>1234567</a>")
+
+local sha1 = string.rep("0123456789abcdef", 2) .. "01234567"
+out = render("commit " .. sha1)
+h.contains("a full sha1 name links", out, "'./?id=" .. sha1 .. "'")
+
+local sha256 = string.rep("0123456789abcdef", 4)
+out = render("commit " .. sha256)
+h.contains("a full sha256 name links", out, "'./?id=" .. sha256 .. "'")
+
+out = render("commit " .. sha256 .. "0")
+h.excludes("sixty five hex characters stay text", out, "<a")
+
+out = render("prefixdeadbeef")
+h.excludes("a hex run inside a word stays text", out, "<a")
+
+out = render("deadbeefy")
+h.excludes("a hex run ending inside a word stays text", out, "<a")
+
+-- The issue rule wins the overlap because it starts first, and the digit run
+-- inside it must not be wrapped a second time.
+out = render("see #1234567 for details")
+h.contains("the issue rule wins an overlap", out,
+ "<a href='https://bugs.example.com/?bug=1234567'>#1234567</a>")
+h.excludes("the overlapped hash is dropped", out, "./?id=")
+
+out = render("#12 and cafebabe12")
+h.contains("both rules fire on one message", out, "?bug=12'>#12</a>")
+h.contains("the object link also fires", out,
+ "<a href='./?id=cafebabe12'>cafebabe12</a>")
+
+-- One request is delivered as several writes, and a match split across two of
+-- them has to be seen whole.
+out = h.run({}, { "see #45", "6 and be done" })
+h.contains("a match split across writes still links", out,
+ "?bug=456'>#456</a>")
+
+out, ret = render("")
+h.equals("an empty message stays empty", out, "")
+h.equals("an empty message still answers zero", ret, 0)
+
+out = render("plain words only")
+h.equals("a message with no matches passes through", out, "plain words only")
+
+h.finish()
diff --git a/tests/extensions/test-syntax-highlight.lua b/tests/extensions/test-syntax-highlight.lua
new file mode 100644
index 0000000..3b0eeb8
--- /dev/null
+++ b/tests/extensions/test-syntax-highlight.lua
@@ -0,0 +1,94 @@
+-- Unit checks for custom/extensions/syntax-highlight.lua, run under a
+-- standalone Lua by t0502-syntax-highlight.sh with the script path as the
+-- first argument. The filter remembers whether the lexers loaded for the
+-- life of the interpreter, so the two sides of that split are two runs of
+-- this file. In highlight mode the shell points CGIT_SCINTILLUA_PATH at the
+-- fake-lexers directory beside this file and every path through a working
+-- lexer is checked, and in nolexers mode it points at an empty directory so
+-- the escaped fallback is what everything renders through.
+
+local test_directory = arg[0]:match("^(.*)/") or "."
+local h = dofile(test_directory .. "/harness.lua")
+
+local script = arg[1]
+local mode = arg[2] or "highlight"
+
+h.load(script)
+
+local function render(filename, text)
+ return h.run({ filename }, { text })
+end
+
+if mode == "nolexers" then
+ local out, ret = render("hello.fake", "if 42")
+ h.equals("without lexers source is served escaped", out, "if 42")
+ h.equals("close answers zero", ret, 0)
+
+ out = render("hello.fake", "a <b> & c")
+ h.equals("the fallback escapes markup", out, "a &lt;b&gt; &amp; c")
+
+ -- Large output leaves in slices, and a byte lost at a boundary would
+ -- put the line numbers beside it out of step, so the whole escaped
+ -- blob has to come back intact across several slices.
+ out = render("big.fake", string.rep("x<y>", 30000))
+ h.equals("slicing loses nothing at the boundaries", out,
+ string.rep("x&lt;y&gt;", 30000))
+
+ h.finish()
+end
+
+local out, ret = render("hello.fake", "if 42 'hi' & zz")
+h.contains("keywords are wrapped in their class", out,
+ "<span class='hl-keyword'>if</span>")
+h.contains("numbers are wrapped in their class", out,
+ "<span class='hl-number'>42</span>")
+h.contains("a dotted tag maps by its first component", out,
+ "<span class='hl-string'>'hi'</span>")
+h.contains("bytes between tokens are escaped", out, " &amp; ")
+h.contains("later tokens still highlight", out,
+ "<span class='hl-keyword'>zz</span>")
+h.equals("close answers zero", ret, 0)
+
+out = h.run({ "split.fake" }, { "i", "f" })
+h.equals("writes are joined before lexing", out,
+ "<span class='hl-keyword'>if</span>")
+
+-- The short lexer tags four bytes and stops, and every byte after it still
+-- has to reach the page.
+out = render("cut.short", "abcd<ef>")
+h.equals("a lexer stopping early leaves an escaped tail", out,
+ "<span class='hl-keyword'>abcd</span>&lt;ef&gt;")
+
+out = render("boom.badlex", "some <code>")
+h.equals("a lexer that raises falls back to escaped text", out,
+ "some &lt;code&gt;")
+
+-- The viadetect lexer name only resolves through detect(), so highlighted
+-- output proves the detection path ran ahead of the extension path.
+out = render("script.viadetect", "if")
+h.equals("filename detection picks the lexer", out,
+ "<span class='hl-keyword'>if</span>")
+
+out = render("tool.py", "import <x>")
+h.equals("an extension with no lexer serves escaped text", out,
+ "import &lt;x&gt;")
+
+out = render("noextension", "words <here>")
+h.equals("a file with no extension serves escaped text", out,
+ "words &lt;here&gt;")
+
+out = render("big.fake", string.rep("a", 512 * 1024 + 1))
+h.excludes("an oversized blob skips the lexing pass", out, "<span")
+h.equals("and still arrives whole", #out, 512 * 1024 + 1)
+
+out, ret = render("empty.fake", "")
+h.equals("an empty blob renders empty", out, "")
+h.equals("an empty blob still answers zero", ret, 0)
+
+-- cgit's C sink stops at the first NUL byte, which the script documents for
+-- binaries that slip past the text detection.
+out = render("blob.bin", "ab\0cd")
+h.contains("bytes before a nul survive", out, "ab")
+h.excludes("bytes after a nul are dropped", out, "cd")
+
+h.finish()