#!/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 want() { grep -q -- "$2" "$3" || no "$1"; ok "$1"; } # lack lack() { grep -q -- "$2" "$3" && no "$1"; ok "$1"; } # code 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 is the form token on a fetched page csrf() { grep -o 'name="csrf" value="[^"]*"' "$1" | head -1 | cut -d'"' -f4; } # field 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)¤t=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")§ion=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|.*
\('"$BASE"'/set-password?[^<]*\)
.*|\1|p' "$WORK/made" | sed 's/&/\&/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>.*/\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 '\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" "secondary" "$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" "$WORK/summary"
lack "and there is only one tab row" "
//p' "$1" | grep -o '>[a-z]*' | 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" "zones" "$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 ' "$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"