diff options
| author | Bryce Kwon <bryce@brycekwon.com> | |
|---|---|---|
| committer | Bryce Kwon <bryce@brycekwon.com> | |
| commit | ||
| parent | ||
| tree | ||
| download | ||
Add a test suite for the shipped extensions
| -rw-r--r-- | tests/extensions/fake-lexers/lexer.lua | 77 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | tests/extensions/harness.lua | 268 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | tests/extensions/lib.sh | 70 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | tests/extensions/test-about-render.lua | 248 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | tests/extensions/test-auth.lua | 302 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | tests/extensions/test-email-avatar.lua | 77 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | tests/extensions/test-link-commits.lua | 77 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | tests/extensions/test-syntax-highlight.lua | 94 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rwxr-xr-x | tests/t0501-about-render.sh | 90 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rwxr-xr-x | tests/t0502-syntax-highlight.sh | 74 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rwxr-xr-x | tests/t0503-link-commits.sh | 60 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rwxr-xr-x | tests/t0504-email-avatar.sh | 77 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rwxr-xr-x | tests/t0505-auth.sh | 68 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
13 files changed, 1582 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 = { ["&"] = "&", ["<"] = "<", [">"] = ">" } +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 <<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 <text> & 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 < y</code>") + +out = render("README.md", "raw <script>alert(1)</script> here") +h.contains("markup in text reaches the page escaped", out, + "<script>alert(1)</script>") +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'b'") + +out = render("README.md", "") +h.contains("an image renders with its alt text escaped", out, + "<img src='https://example.com/i.png' alt='a & b'>") + +out = render("README.md", "") +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 < 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 <x>\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&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 <b> & 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<y>", 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, " & ") +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><ef>") + +out = render("boom.badlex", "some <code>") +h.equals("a lexer that raises falls back to escaped text", out, + "some <code>") + +-- 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 <x>") + +out = render("noextension", "words <here>") +h.equals("a file with no extension serves escaped text", out, + "words <here>") + +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() diff --git a/tests/t0501-about-render.sh b/tests/t0501-about-render.sh new file mode 100755 index 0000000..eff2a61 --- /dev/null +++ b/tests/t0501-about-render.sh @@ -0,0 +1,90 @@ +#!/bin/sh + +# Checks about-render.lua, the shipped about-filter. The unit checks drive +# the script under every suitable standalone Lua through the harness in +# extensions/, once as installed and once with lpeg forced away so the +# plain text fallback is proven everywhere. A repository with a markdown +# readme is then rendered through cgit itself, where the outcome depends on +# whether the Lua inside the binary has lpeg, so a probe decides which of +# the two right answers to expect. + +test_description='Check the shipped about-render extension' +CGIT_TEST_NO_CREATE_REPOS=YesPlease +. ./setup.sh +. "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" + +interpreters=$(ext_lua_interpreters 4) +test -z "$interpreters" && + say 'no standalone lua on the path, unit checks skipped' + +for lua in $interpreters +do + test_expect_success "unit checks under $lua" " + '$lua' '$EXT_TEST_DIRECTORY/test-about-render.lua' \ + '$EXTENSIONS_DIRECTORY/about-render.lua' + " + + test_expect_success "fallback checks without lpeg under $lua" " + '$lua' '$EXT_TEST_DIRECTORY/test-about-render.lua' \ + '$EXTENSIONS_DIRECTORY/about-render.lua' nolpeg + " +done + +test_expect_success 'create a repository with a markdown readme' ' + test_create_repo repos/md && + ( + cd repos/md && + cat >README.md <<-\EOF && + # The Title + + A **bold** claim and a <script>alert(1)</script> 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 "<h1>The Title</h1>" tmp && + grep "<strong>bold</strong>" 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 "<script>alert(1)" tmp +' + +test_expect_success CGIT_LUA_NOLPEG 'without lpeg the readme is served escaped' ' + cgit_url "md/about/" >tmp && + grep "<pre class=.plaintext.>" tmp && + grep "# The Title" tmp +' + +test_done diff --git a/tests/t0502-syntax-highlight.sh b/tests/t0502-syntax-highlight.sh new file mode 100755 index 0000000..c7cbf89 --- /dev/null +++ b/tests/t0502-syntax-highlight.sh @@ -0,0 +1,74 @@ +#!/bin/sh + +# Checks syntax-highlight.lua, the shipped source-filter. The real +# Scintillua collection is not assumed anywhere, because the fake lexer +# module under extensions/fake-lexers drives every path through the +# filter deterministically. The unit checks run twice per interpreter, once +# with the fake lexers found and once with an empty directory so the escaped +# fallback is what everything renders through. The run through cgit itself +# places the fake lexers beside the config file, which is the probe the +# filter documents for a scintillua directory next to cgitrc. + +test_description='Check the shipped syntax-highlight extension' +CGIT_TEST_NO_CREATE_REPOS=YesPlease +. ./setup.sh +. "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" + +interpreters=$(ext_lua_interpreters 5) +test -z "$interpreters" && + say 'no standalone lua on the path, unit checks skipped' + +for lua in $interpreters +do + test_expect_success "highlighting checks under $lua" " + CGIT_SCINTILLUA_PATH='$EXT_TEST_DIRECTORY/fake-lexers' \ + '$lua' '$EXT_TEST_DIRECTORY/test-syntax-highlight.lua' \ + '$EXTENSIONS_DIRECTORY/syntax-highlight.lua' highlight + " + + test_expect_success "fallback checks without lexers under $lua" " + mkdir -p empty-lexers && + CGIT_SCINTILLUA_PATH=\"\$PWD/empty-lexers\" \ + '$lua' '$EXT_TEST_DIRECTORY/test-syntax-highlight.lua' \ + '$EXTENSIONS_DIRECTORY/syntax-highlight.lua' nolexers + " +done + +test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA +test "$CGIT_HAS_LUA" -eq 1 || + say 'cgit built without lua, filter checks through cgit skipped' + +test_expect_success 'create a repository holding a lexable file' ' + test_create_repo repos/fake && + ( + cd repos/fake && + echo "if 42 & zz" >hello.fake && + git add hello.fake && + git commit -m "add hello" + ) +' + +test_expect_success 'place the fake lexers beside a config naming the filter' ' + mkdir -p scintillua && + cp -R "$EXT_TEST_DIRECTORY/fake-lexers" scintillua/lexers && + cat >cgitrc <<-EOF + virtual-root=/ + cache-size=0 + enable-filter-overrides=1 + repo.url=fake + repo.path=$PWD/repos/fake/.git + repo.source-filter=lua:$EXTENSIONS_DIRECTORY/syntax-highlight.lua + EOF +' + +test_expect_success CGIT_LUA 'the tree view is highlighted' ' + cgit_url "fake/tree/hello.fake" >tmp && + grep "<span class=.hl-keyword.>if</span>" tmp && + grep "<span class=.hl-number.>42</span>" tmp +' + +test_expect_success CGIT_LUA 'bytes between tokens reach the page escaped' ' + grep "<span class=.hl-number.>42</span> & " tmp +' + +test_done diff --git a/tests/t0503-link-commits.sh b/tests/t0503-link-commits.sh new file mode 100755 index 0000000..11b1b54 --- /dev/null +++ b/tests/t0503-link-commits.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +# Checks link-commits.lua, the shipped commit-filter, whose configuration as +# shipped links bare object names to ./?id= and issue marks like #123 to +# bugs.example.com. The unit checks cover the matching rules under every +# suitable standalone Lua, and a commit whose message carries one of each is +# then rendered through cgit itself. + +test_description='Check the shipped link-commits extension' +CGIT_TEST_NO_CREATE_REPOS=YesPlease +. ./setup.sh +. "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" + +interpreters=$(ext_lua_interpreters 4) +test -z "$interpreters" && + say 'no standalone lua on the path, unit checks skipped' + +for lua in $interpreters +do + test_expect_success "unit checks under $lua" " + '$lua' '$EXT_TEST_DIRECTORY/test-link-commits.lua' \ + '$EXTENSIONS_DIRECTORY/link-commits.lua' + " +done + +test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA +test "$CGIT_HAS_LUA" -eq 1 || + say 'cgit built without lua, filter checks through cgit skipped' + +test_expect_success 'create a repository whose commit message holds matches' ' + test_create_repo repos/linky && + ( + cd repos/linky && + echo content >file && + git add file && + git commit -m "fix #123 near deadbeefcafe1234" + ) +' + +test_expect_success 'point cgit at it through the commit filter' ' + cat >cgitrc <<-EOF + virtual-root=/ + cache-size=0 + enable-filter-overrides=1 + repo.url=linky + repo.path=$PWD/repos/linky/.git + repo.commit-filter=lua:$EXTENSIONS_DIRECTORY/link-commits.lua + EOF +' + +test_expect_success CGIT_LUA 'the issue mark links out on the commit page' ' + cgit_url "linky/commit/" >tmp && + grep "bugs.example.com/?bug=123.>#123</a>" tmp +' + +test_expect_success CGIT_LUA 'the object name links to its own page' ' + grep "id=deadbeefcafe1234.>deadbeefcafe1234</a>" tmp +' + +test_done diff --git a/tests/t0504-email-avatar.sh b/tests/t0504-email-avatar.sh new file mode 100755 index 0000000..0a4b74f --- /dev/null +++ b/tests/t0504-email-avatar.sh @@ -0,0 +1,77 @@ +#!/bin/sh + +# Checks email-gravatar.lua and email-libravatar.lua, the shipped email +# filters, which differ only in the service the icon is fetched from. The +# unit checks run each under every suitable standalone Lua with a +# deterministic digest standing in for luaossl, and once more against the +# real module wherever it is installed, which adds the check that a known +# address hashes to its published md5. The run through cgit itself needs +# luaossl inside the binary's own Lua, so it is probed for rather than +# assumed. + +test_description='Check the shipped email avatar extensions' +CGIT_TEST_NO_CREATE_REPOS=YesPlease +. ./setup.sh +. "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" + +interpreters=$(ext_lua_interpreters 4) +test -z "$interpreters" && + say 'no standalone lua on the path, unit checks skipped' + +for lua in $interpreters +do + for script in email-gravatar email-libravatar + do + test_expect_success "$script checks under $lua" " + '$lua' '$EXT_TEST_DIRECTORY/test-email-avatar.lua' \ + '$EXTENSIONS_DIRECTORY/$script.lua' stub + " + + if "$lua" -e "require([[openssl.digest]])" >/dev/null 2>&1 + then + test_expect_success "$script checks with real luaossl under $lua" " + '$lua' '$EXT_TEST_DIRECTORY/test-email-avatar.lua' \ + '$EXTENSIONS_DIRECTORY/$script.lua' real + " + fi + done +done + +test_expect_success 'create a repository with one commit' ' + test_create_repo repos/avatar && + ( + cd repos/avatar && + echo content >file && + git add file && + git commit -m "add file" + ) +' + +if test "$CGIT_HAS_LUA" -eq 1 && + cgit_lua_probe "$PWD/repos/avatar/.git" openssl.digest +then + test_set_prereq CGIT_LUA_OSSL +else + say 'cgit lua has no luaossl, filter checks through cgit skipped' +fi + +test_expect_success CGIT_LUA_OSSL 'point cgit at it through the email filter' ' + cat >cgitrc <<-EOF + virtual-root=/ + cache-size=0 + enable-filter-overrides=1 + repo.url=avatar + repo.path=$PWD/repos/avatar/.git + repo.email-filter=lua:$EXTENSIONS_DIRECTORY/email-gravatar.lua + EOF +' + +# The address behind the hash is author@example.com, the identity the test +# library commits under. +test_expect_success CGIT_LUA_OSSL 'the commit page draws the author icon' ' + cgit_url "avatar/commit/" >tmp && + grep "www.gravatar.com/avatar/5c1e6d6e64e12aca17657581a48005d1" tmp && + grep "loading=.lazy." tmp +' + +test_done diff --git a/tests/t0505-auth.sh b/tests/t0505-auth.sh new file mode 100755 index 0000000..d4dcd52 --- /dev/null +++ b/tests/t0505-auth.sh @@ -0,0 +1,68 @@ +#!/bin/sh + +# Checks auth-file.lua and auth-inline.lua, the shipped auth filters, which +# share their cookie signing, redirect vetting and action flows and differ +# only in where accounts live. The unit checks under a standalone Lua meet +# luaossl and luaposix with deterministic stand-ins from the harness, so +# they prove this script's own logic on any machine, tampered and expired +# cookies turned away, header injection stripped, unsafe redirects refused +# and the login flows answering as documented. The run through cgit itself +# needs the real modules inside the binary's own Lua, so it is probed for, +# and an unedited copy protects nothing, which is itself the behaviour worth +# proving end to end. + +test_description='Check the shipped auth extensions' +CGIT_TEST_NO_CREATE_REPOS=YesPlease +. ./setup.sh +. "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh" + +interpreters=$(ext_lua_interpreters 4) +test -z "$interpreters" && + say 'no standalone lua on the path, unit checks skipped' + +for lua in $interpreters +do + for variant in inline file + do + test_expect_success "auth-$variant checks under $lua" " + '$lua' '$EXT_TEST_DIRECTORY/test-auth.lua' \ + '$EXTENSIONS_DIRECTORY/auth-$variant.lua' $variant + " + done +done + +test_expect_success 'create a repository with one commit' ' + test_create_repo repos/authy && + ( + cd repos/authy && + echo content >file && + git add file && + git commit -m "guarded commit" + ) +' + +if test "$CGIT_HAS_LUA" -eq 1 && + cgit_lua_probe "$PWD/repos/authy/.git" \ + openssl.rand openssl.hmac posix.sys.stat posix.unistd +then + test_set_prereq CGIT_LUA_AUTH +else + say 'cgit lua lacks luaossl or luaposix, checks through cgit skipped' +fi + +test_expect_success CGIT_LUA_AUTH 'point cgit at the unedited auth filter' ' + cat >cgitrc <<-EOF + virtual-root=/ + cache-size=0 + auth-filter=lua:$EXTENSIONS_DIRECTORY/auth-inline.lua + repo.url=authy + repo.path=$PWD/repos/authy/.git + EOF +' + +test_expect_success CGIT_LUA_AUTH 'an unedited copy protects nothing' ' + cgit_url "authy/log/" >tmp && + grep ">guarded commit</a>" tmp +' + +test_done |
