aboutsummaryrefslogtreecommitdiff
path: root/test
blob: bd4c2bc3ca8e1acf9415bf0fb6c54029db81e65c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
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"