aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rwxr-xr-xtest320
1 files changed, 320 insertions, 0 deletions
diff --git a/test b/test
new file mode 100755
index 0000000..bd4c2bc
--- /dev/null
+++ b/test
@@ -0,0 +1,320 @@
+#!/bin/sh
+# smoke: build the image, run it against a temporary volume, and check what
+# only breaks in the real thing.
+#
+# Needs docker, git, curl, lynx, openssl, python3, and timeout here.
+set -eu
+
+IMAGE=${IMAGE:-git-forge:smoke}
+PORT=${PORT:-18080}
+BASE=http://127.0.0.1:$PORT
+NAME=git-forge-smoke
+
+WORK=$(mktemp -d)
+VOLUME=""
+cleanup() {
+ docker rm -f "$NAME" >/dev/null 2>&1 || true
+ if [ -n "$VOLUME" ]; then
+ docker volume rm "$VOLUME" >/dev/null 2>&1 || true
+ fi
+ rm -rf "$WORK"
+}
+trap cleanup EXIT
+
+ok() { printf 'ok %s\n' "$1"; }
+no() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
+
+# want <description> <pattern> <file>
+want() { grep -q -- "$2" "$3" || no "$1"; ok "$1"; }
+# lack <description> <pattern> <file>
+lack() { grep -q -- "$2" "$3" && no "$1"; ok "$1"; }
+# code <description> <expected> <curl args...>
+code() {
+ description=$1 expected=$2
+ shift 2
+ got=$(curl -s -o "$WORK/body" -w '%{http_code}' "$@")
+ [ "$got" = "$expected" ] || no "$description: got $got, wanted $expected"
+ ok "$description"
+}
+# csrf <file> is the form token on a fetched page
+csrf() { grep -o 'name="csrf" value="[^"]*"' "$1" | head -1 | cut -d'"' -f4; }
+# field <file> <name> is a hidden field on a fetched page
+field() { grep -o "name=\"$2\" value=\"[^\"]*\"" "$1" | head -1 | cut -d'"' -f4; }
+
+# The authenticator secret is the base32 of the ASCII "12345678901234567890",
+# so a code can be computed with openssl alone and no base32 decoder. The
+# counter goes in as eight bytes, spelled in octal because POSIX printf has
+# \ooo and not \xhh. A code works once, so a second call waits for the next
+# step; the step is kept in a file because $(totp) runs in a subshell.
+SECRET=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
+KEYHEX=3132333435363738393031323334353637383930
+totp() {
+ last=$(cat "$WORK/step" 2>/dev/null || echo 0)
+ step=$(( $(date -u +%s) / 30 ))
+ while [ "$step" -le "$last" ]; do
+ sleep 1
+ step=$(( $(date -u +%s) / 30 ))
+ done
+ echo "$step" > "$WORK/step"
+ msg=$(printf '\\%03o' \
+ $(( (step >> 56) & 255 )) $(( (step >> 48) & 255 )) \
+ $(( (step >> 40) & 255 )) $(( (step >> 32) & 255 )) \
+ $(( (step >> 24) & 255 )) $(( (step >> 16) & 255 )) \
+ $(( (step >> 8) & 255 )) $(( step & 255 )))
+ digest=$(printf "$msg" | openssl dgst -sha1 -mac HMAC -macopt hexkey:$KEYHEX | sed 's/.*= *//')
+ offset=$(( 0x$(printf %s "$digest" | cut -c40) * 2 + 1 ))
+ chunk=$(printf %s "$digest" | cut -c${offset}-$((offset + 7)))
+ printf '%06d' $(( (0x$chunk & 0x7fffffff) % 1000000 ))
+}
+
+export GIT_TERMINAL_PROMPT=0 GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
+export GIT_AUTHOR_NAME=Smoke GIT_AUTHOR_EMAIL=smoke@example.com
+export GIT_COMMITTER_NAME=Smoke GIT_COMMITTER_EMAIL=smoke@example.com
+
+alice="-c $WORK/alice -b $WORK/alice"
+bob="-c $WORK/bob -b $WORK/bob"
+
+echo "== build and start"
+docker build -q -f Containerfile -t "$IMAGE" . >/dev/null
+VOLUME=$(docker volume create)
+docker run -d --name "$NAME" -p "127.0.0.1:$PORT:8080" \
+ -v "$VOLUME:/var/lib/git-forge" -e GIT_FORGE_BASE_URL="$BASE" "$IMAGE" >/dev/null
+
+for _ in 1 2 3 4 5 6 7 8 9 10; do
+ sleep 1
+ curl -sf "$BASE/healthz" >/dev/null 2>&1 && break
+done
+curl -sf "$BASE/healthz" >/dev/null || no "the container never answered /healthz"
+ok "/healthz answers"
+
+echo "== the first admin"
+docker logs "$NAME" > "$WORK/log" 2>&1
+TOKEN=$(grep -o 'token: .*' "$WORK/log" | cut -d' ' -f2)
+[ -n "$TOKEN" ] || no "no setup token in the log"
+curl -s $alice "$BASE/setup" > "$WORK/p"
+want "the setup page is served" "Setup token" "$WORK/p"
+code "the setup form makes the first admin" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&token=$TOKEN&name=alice&password=hunter2hunter2&again=hunter2hunter2" "$BASE/setup"
+code "and then /setup is gone" 404 "$BASE/setup"
+
+echo "== the admin has to hold a second factor"
+code "an admin with no authenticator is sent to enrol" 303 $alice -o /dev/null "$BASE/admin"
+curl -s $alice "$BASE/settings/totp" > "$WORK/p"
+# Enrolling costs the password too.
+curl -s $alice -o /dev/null -w '%{http_code}' \
+ -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)" "$BASE/settings/totp" > "$WORK/nopass"
+grep -qx 401 "$WORK/nopass" || no "enrolling a second factor did not ask for the password"
+ok "enrolling a second factor asks for the password"
+curl -s $alice -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)&current=hunter2hunter2" \
+ "$BASE/settings/totp" > "$WORK/enrolled"
+want "enrolling shows recovery codes once" "recovery codes" "$WORK/enrolled"
+code "and now the admin pages open" 200 $alice "$BASE/admin"
+
+echo "== repositories"
+curl -s $alice "$BASE/admin" > "$WORK/p"
+code "a repository in a section" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&section=infra&name=dns&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members" \
+ "$BASE/admin/repo/create"
+code "a public repository" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&name=notes&desc=Notes&visibility=public&issues=disabled&wiki=disabled" \
+ "$BASE/admin/repo/create"
+
+# Not an exact list: gc changes the shape over a repository's life. What must
+# never appear is a file the forge wrote.
+docker exec "$NAME" ls -A /var/lib/git-forge/repos/infra/dns.git | sort > "$WORK/bare"
+grep -qvE '^(HEAD|ORIG_HEAD|branches|config|description|hooks|info|logs|objects|packed-refs|refs|shallow|worktrees)$' "$WORK/bare" &&
+ no "the repository holds something that is not git's: $(tr '\n' ' ' < "$WORK/bare")"
+ok "the repository holds nothing but git's own files"
+docker exec "$NAME" cat /var/lib/git-forge/repos/infra/dns.git/config > "$WORK/repoconfig"
+lack "and nothing of the forge's in its config" "unpackLimit" "$WORK/repoconfig"
+
+docker exec "$NAME" git -C /var/lib/git-forge/config log --format='%an %s' > "$WORK/audit"
+want "the change is in the audit log, authored by the admin" "alice repos: create infra/dns" "$WORK/audit"
+
+echo "== a second account, through the one time link"
+curl -s $alice "$BASE/admin" > "$WORK/p"
+curl -s $alice -d "csrf=$(csrf "$WORK/p")&name=bob&email=bob@example.com&code=$(totp)" "$BASE/admin/user/create" > "$WORK/made"
+# Off the page as the template wrote it, so the separators are escaped.
+LINK=$(sed -n 's|.*<pre>\('"$BASE"'/set-password?[^<]*\)</pre>.*|\1|p' "$WORK/made" | sed 's/&amp;/\&/g')
+[ -n "$LINK" ] || no "no set-password link on the admin page"
+ok "creating an account shows a set-password link"
+sleep 1
+docker logs "$NAME" > "$WORK/log" 2>&1
+want "mail is off, so the log names the message it dropped" 'dropped "\[git-forge\] set a password for bob"' "$WORK/log"
+lack "and never carries the link" "set-password?" "$WORK/log"
+curl -s $bob "$LINK" > "$WORK/p"
+code "following it sets the password and confirms the address" 303 $bob -o /dev/null \
+ --data-urlencode "csrf=$(csrf "$WORK/p")" \
+ --data-urlencode "name=$(field "$WORK/p" name)" \
+ --data-urlencode "expires=$(field "$WORK/p" expires)" \
+ --data-urlencode "t=$(field "$WORK/p" t)" \
+ --data-urlencode "new=bobbobbob1" --data-urlencode "again=bobbobbob1" "$BASE/set-password"
+code "and the link does not work twice" 404 "$LINK"
+
+echo "== grants and push tokens"
+curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p"
+code "bob is granted write" 303 $alice -o /dev/null \
+ -d "csrf=$(csrf "$WORK/p")&account=bob&level=write&code=$(totp)" "$BASE/infra/dns/settings/grant"
+sleep 1
+docker logs "$NAME" > "$WORK/log" 2>&1
+want "the grant change is a message, dropped by name" "access to infra/dns changed" "$WORK/log"
+
+curl -s $bob "$BASE/settings" > "$WORK/p"
+curl -s $bob -d "csrf=$(csrf "$WORK/p")&repo=infra/dns" "$BASE/settings/tokens" > "$WORK/minted"
+PUSH=$(sed -n 's/.*<pre id="token">\(.*\)<\/pre>.*/\1/p' "$WORK/minted")
+[ -n "$PUSH" ] || no "no push token was minted"
+ok "bob mints a push token"
+
+echo "== git over http"
+cd "$WORK"
+# 2>/dev/null only silences git's note about the repository being empty; a
+# clone that actually fails still stops the script.
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.git" dns 2>/dev/null
+printf 'example.com. IN A 192.0.2.1\n' > dns/zone.txt
+printf 'package main\n\n// the zone loader\nfunc main() { n := 42; _ = n }\n' > dns/load.go
+printf '# infra/dns\n\nThe *zones*.\n' > dns/README.md
+# A page that would like to run on this origin must come back as text.
+printf '<html><body><script>alert(document.domain)</script></body></html>\n' > dns/page.html
+git -C dns add -A
+git -C dns commit -qm "dns: add a zone"
+git -C dns push -q origin master
+ok "push over http with the token"
+
+git clone -q "$BASE/notes.git" notes 2>/dev/null
+ok "a public repository clones anonymously"
+git clone -q "$BASE/infra/dns.git" nope 2>"$WORK/refused" && no "a private repository cloned anonymously"
+code "a private repository refuses anonymous access with 401" 401 "$BASE/infra/dns.git/info/refs?service=git-upload-pack"
+
+echo "== issues"
+curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p"
+curl -s $alice -o /dev/null -d "csrf=$(csrf "$WORK/p")&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members&labels=bug
+docs" "$BASE/infra/dns/settings"
+curl -s $alice "$BASE/infra/dns/issues/new" > "$WORK/p"
+code "alice opens an issue" 303 $alice -o /dev/null \
+ --data-urlencode "csrf=$(csrf "$WORK/p")" \
+ --data-urlencode "title=Zone transfer fails on the secondary" \
+ --data-urlencode "body=The **secondary** does not answer AXFR." \
+ --data-urlencode "label=bug" "$BASE/infra/dns/issues"
+curl -s $alice "$BASE/infra/dns/issues/1" > "$WORK/issue"
+want "the issue renders its markdown" "<strong>secondary</strong>" "$WORK/issue"
+want "and its label" "bug" "$WORK/issue"
+code "alice comments" 303 $alice -o /dev/null \
+ --data-urlencode "csrf=$(csrf "$WORK/issue")" \
+ --data-urlencode "body=Confirmed on ns2 as well." "$BASE/infra/dns/issues/1/comment"
+curl -s $alice "$BASE/infra/dns/issues?q=ns2" > "$WORK/search"
+want "search finds it through the comment" "Zone transfer fails" "$WORK/search"
+
+sleep 1
+docker logs "$NAME" > "$WORK/log" 2>&1
+want "the new issue is a message to the grantee" "infra/dns#1 opened" "$WORK/log"
+
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.issues.git" issues
+git -C issues show master:issues/1.json | python3 -m json.tool > /dev/null || no "the tracker's json does not parse"
+ok "the tracker clones and its json parses"
+git -C issues commit -q --allow-empty -m "issues: hand written"
+git -C issues push origin master > "$WORK/pushfail" 2>&1 && no "the tracker accepted a push"
+want "the tracker refuses a push, with a reason" "written by the forge" "$WORK/pushfail"
+
+echo "== wiki"
+curl -s $alice "$BASE/infra/dns/wiki?edit" > "$WORK/p"
+curl -s $alice -o /dev/null --data-urlencode "csrf=$(csrf "$WORK/p")" --data-urlencode "base=" \
+ --data-urlencode "body=# Zones
+
+Written in the browser." --data-urlencode "action=save" "$BASE/infra/dns/wiki/"
+curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p"
+want "a wiki page written in the browser renders" "Written in the browser." "$WORK/p"
+
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.wiki.git" wiki
+printf '# Zones\n\nEdited by push.\n' > wiki/index.md
+git -C wiki add -A
+git -C wiki commit -qm "wiki: edit by push"
+git -C wiki push -q origin master
+curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p"
+want "and a push to the wiki changes the rendered page" "Edited by push." "$WORK/p"
+
+echo "== browsing"
+curl -s "$BASE/cgit.css" > "$WORK/style"
+curl -s $alice "$BASE/infra/dns/" > "$WORK/summary"
+want "the forge's header is injected into a cgit page" 'id="header"' "$WORK/summary"
+want "with the description from repos.json" "Authoritative DNS zones" "$WORK/summary"
+want "and cgit's own tabs under it" "class='tabs'" "$WORK/summary"
+curl -s $alice "$BASE/infra/dns/issues" > "$WORK/forgepage"
+want "the same header is on the forge's own pages" 'id="header"' "$WORK/forgepage"
+
+# Without cgit's own stylesheet, at the path cgit links to, every page is
+# unstyled.
+code "the one stylesheet is served where cgit looks for it" 200 "$BASE/cgit.css"
+want "and carries the forge's as well as cgit's" "highlight's classes" "$WORK/style"
+want "and cgit's pages ask for it rather than for a second one" "/cgit.css" "$WORK/summary"
+lack "which is the only stylesheet there is" "static/forge.css" "$WORK/summary"
+# cgit has no tab called issues, so finding one means the forge's were injected
+# into cgit's own row rather than sitting in a second menu.
+want "the forge's sections are inside cgit's own tab row" ">issues</a>" "$WORK/summary"
+lack "and there is only one tab row" "<table class=\"tabs\"" "$WORK/summary"
+lack "and one element with cgit's id on the page" "id=\"cgit\"" "$WORK/summary"
+
+# The forge's pages copy cgit's tab list, and a copy that is not checked
+# drifts.
+tablist() {
+ sed -n 's/.*<table class=.tabs.><tr><td>//p' "$1" |
+ grep -o '>[a-z]*</a>' | sed 's/>\(.*\)<\/a>/\1/' | tr '\n' ' '
+}
+if [ "$(tablist "$WORK/summary")" != "$(tablist "$WORK/forgepage")" ]; then
+ printf 'FAIL the forge'"'"'s tab row has drifted from cgit'"'"'s\n cgit: %s\n forge: %s\n' \
+ "$(tablist "$WORK/summary")" "$(tablist "$WORK/forgepage")" >&2
+ exit 1
+fi
+ok "the forge's copy of cgit's tab row still matches cgit's"
+want "with the decorations that make a log line readable" "a.deco" "$WORK/style"
+
+curl -s $alice "$BASE/infra/dns/tree/load.go" > "$WORK/blob"
+want "a source blob comes back with highlight's markup" 'class="hl ' "$WORK/blob"
+curl -s $alice "$BASE/infra/dns/about/" > "$WORK/about"
+want "and the about page through the markdown filter" "<em>zones</em>" "$WORK/about"
+curl -s -D "$WORK/head" -o "$WORK/blobbody" $alice "$BASE/infra/dns/plain/page.html"
+want "a blob comes back as text, never as something a browser runs" "text/plain" "$WORK/head"
+
+curl -s "$BASE/" > "$WORK/index"
+want "the index shows a public repository to anonymous visitors" "notes" "$WORK/index"
+want "with the same header on the index" 'id="header"' "$WORK/index"
+want "and the columns it has" "Description" "$WORK/index"
+lack "and hides a private one" "infra/dns" "$WORK/index"
+
+echo "== no javascript, and readable in lynx"
+ROUTES="/ /login /reset /settings /admin /infra/dns/ /infra/dns/log/ \
+/infra/dns/tree/ /infra/dns/tree/load.go /infra/dns/refs/ /infra/dns/issues \
+/infra/dns/issues/1 /infra/dns/issues/new /infra/dns/wiki /infra/dns/wiki?edit \
+/infra/dns/settings /infra/dns.wiki/ /notes/ /nothing-here"
+for route in $ROUTES; do
+ curl -s $alice "$BASE$route" > "$WORK/page"
+ grep -q '<script' "$WORK/page" && no "a script tag on $route"
+ lynx -dump -stdin -nolist < "$WORK/page" > "$WORK/text" 2>/dev/null || no "lynx could not render $route"
+ [ -s "$WORK/text" ] || no "$route renders empty in lynx"
+done
+ok "every route is script free and renders in lynx"
+
+lynx -dump -stdin -nolist < "$WORK/issue" | grep -q "Zone transfer fails on the secondary" ||
+ no "the issue title is not readable in lynx"
+ok "the issue reads correctly through lynx -dump"
+
+# The header says so to the browser too, on cgit's pages as much as the
+# forge's.
+curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/"
+want "cgit's pages carry the script policy" "script-src 'none'" "$WORK/head"
+curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/issues"
+want "and so do the forge's" "script-src 'none'" "$WORK/head"
+
+echo "== one process to a datadir"
+# timeout, so that a lock that is not taken fails this check rather than
+# hanging it: without the lock the second process serves happily for ever.
+timeout 30 docker run --rm -v "$VOLUME:/var/lib/git-forge" "$IMAGE" > "$WORK/second" 2>&1 &&
+ no "a second process served a datadir that was already being served"
+want "a second process refuses to start, and says why" "already being served" "$WORK/second"
+
+echo "== shutdown"
+docker stop -t 10 "$NAME" >/dev/null
+docker inspect -f '{{.State.ExitCode}}' "$NAME" | grep -qx 0 || no "SIGTERM did not exit zero"
+ok "SIGTERM drains and exits zero"
+
+echo
+echo "smoke: all checks passed"