#!/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"
