carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env bash
2# The app's own tests: start the compiled binary, drive it with curl, and
3# check the contract's wire-level rules (status codes, redirects, cookies,
4# escaping, Content-Length, static files, isolation, Origin, the unix
5# socket and a clean SIGTERM exit). The browser-level behaviour is the
6# shared suite's job (scripts/test-app.sh at the repo root).
7#
8# test/run.sh dist/app
9set -uo pipefail
10
11bin=$(cd "$(dirname "${1:?usage: test/run.sh <binary>}")" && pwd)/$(basename "$1")
12here=$(cd "$(dirname "$0")/.." && pwd)
13work=$(mktemp -d -p "${TMPDIR:-/tmp}")
14pid=""
15cleanup() {
16 [ -n "$pid" ] && kill "$pid" 2>/dev/null
17 rm -rf "$work"
18}
19trap cleanup EXIT
20
21pass=0
22fail=0
23ok() { pass=$((pass + 1)); }
24bad() { fail=$((fail + 1)); echo "FAIL: $*"; }
25check() { # check <description> <expected> <actual>
26 if [ "$2" == "$3" ]; then ok; else bad "$1: expected [$2], got [$3]"; fi
27}
28contains() { # contains <description> <needle> <haystack>
29 if [[ "$3" == *"$2"* ]]; then ok; else bad "$1: [$2] not found"; fi
30}
31lacks() {
32 if [[ "$3" != *"$2"* ]]; then ok; else bad "$1: [$2] found"; fi
33}
34
35port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')
36base="http://127.0.0.1:$port"
37"$bin" --listen "127.0.0.1:$port" --db "$work/app.db" >"$work/app.log" 2>&1 &
38pid=$!
39for _ in $(seq 50); do curl -sf "$base/healthz" >/dev/null && break; sleep 0.1; done
40
41status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
42jar="$work/jar"
43other="$work/other"
44
45# --- platform routes
46check "healthz" 200 "$(status "$base/healthz")"
47check "version" 200 "$(status "$base/api/version")"
48check "version body" "$(cat "$here/version.rb" | sed -n 's/^APP_VERSION = "\(.*\)"$/\1/p')" "$(curl -s "$base/api/version")"
49check "unknown path" 404 "$(status "$base/nope")"
50
51# --- listens only where it was told to
52listeners=$(ss -ltnH "sport = :$port" | awk '{print $4}')
53check "bound to 127.0.0.1 only" "127.0.0.1:$port" "$listeners"
54
55# --- first visit: cookie, empty list, no main/footer
56headers=$(curl -s -D - -o "$work/page" -c "$jar" "$base/")
57contains "cookie set" "Set-Cookie: tid=" "$headers"
58contains "cookie HttpOnly" "HttpOnly" "$headers"
59contains "cookie SameSite" "SameSite=Lax" "$headers"
60contains "cookie Path" "Path=/" "$headers"
61lacks "no Secure over http" "Secure" "$headers"
62token=$(awk '$6 == "tid" {print $7}' "$jar")
63check "token is 22 base64url chars" 1 "$([[ "$token" =~ ^[A-Za-z0-9_-]{22}$ ]] && echo 1 || echo 0)"
64page=$(cat "$work/page")
65lacks "empty list hides main" 'class="main"' "$page"
66lacks "empty list hides footer" 'class="footer"' "$page"
67contains "language in footer" "the CARLOS bakeoff</a> · Spinel" "$page"
68
69# --- add: trimmed, blank ignored, 303 to the filter
70post() { curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$jar" -c "$jar" "$@"; }
71check "add" "303 $base/" "$(post --data-urlencode 'title= buy milk ' -d filter=all "$base/todos")"
72check "add from active" "303 $base/active" "$(post --data-urlencode 'title=walk the dog' -d filter=active "$base/todos")"
73check "blank add" "303 $base/completed" "$(post --data-urlencode 'title= ' -d filter=completed "$base/todos")"
74check "unknown filter" "303 $base/" "$(post --data-urlencode 'title=write' -d filter=bogus "$base/todos")"
75page=$(curl -s -b "$jar" "$base/")
76contains "trimmed title" "<label>buy milk</label>" "$page"
77check "three items" 3 "$(grep -c '<li data-id=' <<<"$page")"
78contains "counter" "<strong>3</strong> items left" "$page"
79lacks "no clear-completed yet" "clear-completed" "$page"
80
81id1=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | head -1 | grep -o '[0-9]*')
82id2=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | sed -n 2p | grep -o '[0-9]*')
83
84# --- toggle, filters, toggle-all, clear-completed
85check "toggle" "303 $base/active" "$(post -d filter=active "$base/todos/$id1/toggle")"
86page=$(curl -s -b "$jar" "$base/")
87contains "completed class" "<li data-id=\"$id1\" class=\"completed\">" "$page"
88contains "singular counter" "<strong>2</strong> items left" "$page"
89contains "clear-completed shown" 'class="clear-completed"' "$page"
90active=$(curl -s -b "$jar" "$base/active")
91lacks "active filter hides completed" "data-id=\"$id1\"" "$active"
92contains "active link selected" '<a class="selected" href="/active">' "$active"
93completed=$(curl -s -b "$jar" "$base/completed")
94check "completed filter" 1 "$(grep -c '<li data-id=' <<<"$completed")"
95check "toggle-all" "303 $base/" "$(post -d filter=all "$base/todos/toggle-all")"
96check "all completed" 3 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')"
97contains "toggle-all checked" 'class="toggle-all" type="checkbox" form="toggle-all-form" checked' "$(curl -s -b "$jar" "$base/")"
98post -d filter=all "$base/todos/toggle-all" >/dev/null
99check "toggle-all again clears" 0 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')"
100post -d filter=all "$base/todos/$id1/toggle" >/dev/null
101check "clear-completed" "303 $base/" "$(post -d filter=all "$base/todos/clear-completed")"
102lacks "completed item gone" "data-id=\"$id1\"" "$(curl -s -b "$jar" "$base/")"
103
104# --- edit view, rename, empty rename deletes
105edit=$(curl -s -b "$jar" "$base/active?edit=$id2")
106contains "editing class" "<li data-id=\"$id2\" class=\"editing\">" "$edit"
107contains "edit form" "action=\"/todos/$id2/edit\"" "$edit"
108contains "edit link keeps filter" "href=\"/active?edit=$id2\"" "$edit"
109check "rename" "303 $base/active" "$(post --data-urlencode 'title= walk the cat ' -d filter=active "$base/todos/$id2/edit")"
110contains "renamed" "<label>walk the cat</label>" "$(curl -s -b "$jar" "$base/")"
111check "empty rename" "303 $base/" "$(post --data-urlencode 'title= ' -d filter=all "$base/todos/$id2/edit")"
112lacks "empty rename deletes" "data-id=\"$id2\"" "$(curl -s -b "$jar" "$base/")"
113
114# --- escaping, UTF-8, Content-Length, long titles
115post --data-urlencode 'title=<img src=x onerror=alert(1)> & "q" '"'"'s' -d filter=all "$base/todos" >/dev/null
116post --data-urlencode 'title=naïve 🚀 日本語' -d filter=all "$base/todos" >/dev/null
117headers=$(curl -s -D - -o "$work/page" -b "$jar" "$base/")
118page=$(cat "$work/page")
119contains "escaped title" "&lt;img src=x onerror=alert(1)&gt; &amp; &quot;q&quot; &#39;s" "$page"
120lacks "no raw img" "<img" "$page"
121contains "UTF-8 kept" "naïve 🚀 日本語" "$page"
122declared=$(tr -d '\r' <<<"$headers" | sed -n 's/^Content-Length: //p')
123check "Content-Length is bytes" "$(wc -c <"$work/page")" "$declared"
124long=$(printf 'é%.0s' $(seq 10000))
125post --data-urlencode "title=$long" -d filter=all "$base/todos" >/dev/null
126contains "10,000-char title" "<label>$long</label>" "$(curl -s -b "$jar" "$base/")"
127
128# --- isolation, foreign ids, garbled cookies, Origin
129check "foreign id from another visitor" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$id1/toggle")"
130own=$(curl -s -b "$jar" "$base/" | grep -o '<li data-id="[0-9]*"' | head -1 | grep -o '[0-9]*')
131check "someone else's toggle" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$own/toggle")"
132check "someone else's edit" 404 "$(status -b "$other" -d title=x -d filter=all "$base/todos/$own/edit")"
133check "someone else's destroy" 404 "$(status -b "$other" -d filter=all "$base/todos/$own/destroy")"
134lacks "other visitor sees nothing" "<li data-id" "$(curl -s -b "$other" "$base/")"
135check "non-numeric id" 404 "$(status -b "$jar" -d filter=all "$base/todos/abc/toggle")"
136check "garbled cookie" 200 "$(status -b 'tid=%%%' "$base/")"
137lacks "garbled cookie gets empty list" "<li data-id" "$(curl -s -b 'tid=%%%' "$base/")"
138contains "garbled cookie replaced" "Set-Cookie: tid=" "$(curl -s -D - -o /dev/null -b 'tid=%%%' "$base/")"
139check "cross-site POST" 403 "$(status -b "$jar" -H 'Origin: https://evil.example' --data-urlencode title=evil "$base/todos")"
140lacks "cross-site POST changed nothing" "evil" "$(curl -s -b "$jar" "$base/")"
141check "same-origin POST" 303 "$(status -b "$jar" -H "Origin: $base" --data-urlencode title=fine "$base/todos")"
142
143# --- the script's fragment answers
144frag=$(curl -s -b "$jar" -H 'X-Fragment: 1' -d filter=active --data-urlencode title=fragment "$base/todos")
145check "fragment starts with main" '<section class="main">' "$(head -1 <<<"$frag")"
146lacks "fragment is not a page" "<html" "$frag"
147contains "fragment keeps the filter" '<a class="selected" href="/active">' "$frag"
148
149# --- static files, byte for byte
150for f in base.css index.css; do
151 curl -s "$base/static/$f" -o "$work/$f"
152 if cmp -s "$work/$f" "$here/../../shared/$f"; then ok; else bad "/static/$f differs from shared/$f"; fi
153done
154contains "css type" "Content-Type: text/css" "$(curl -s -D - -o /dev/null "$base/static/index.css")"
155
156# --- cookies must be ones this server issued
157made_up="AAAAAAAAAAAAAAAAAAAAAA"
158hdr=$(curl -s -D - -o /dev/null -H "Cookie: tid=$made_up" -d filter=all --data-urlencode title=shared "$base/todos")
159contains "made-up token replaced" "Set-Cookie: tid=" "$hdr"
160lacks "made-up token not kept" "tid=$made_up" "$hdr"
161lacks "made-up token holds no list" "<label>shared</label>" "$(curl -s -H "Cookie: tid=$made_up" "$base/")"
162
163# --- long bodies arrive whole; short, chunked and oversized ones change nothing
164rockets=$(python3 -c 'print("\U0001F680" * 10000, end="")')
165check "10,000-emoji title" 303 "$(status -b "$jar" --data-urlencode "title=$rockets" -d filter=all "$base/todos")"
166contains "10,000 emoji kept whole" "<label>$rockets</label>" "$(curl -s -b "$jar" "$base/")"
167before=$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=')
168raw() { # raw <python body>: talk to the server over a raw socket, print the status line
169 python3 -I -c "
170import socket, sys, time
171s = socket.create_connection(('127.0.0.1', $port))
172h = 'POST /todos HTTP/1.1\\r\\nHost: x\\r\\nCookie: tid=$token\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\n'
173$1
174s.settimeout(30)
175print(s.recv(100).split(b'\\r\\n')[0].decode())
176"
177}
178contains "short body refused" " 400 " "$(raw "s.sendall((h + 'Content-Length: 5000\\r\\n\\r\\n').encode() + b'title=' + b'y' * 1000); s.shutdown(socket.SHUT_WR)")"
179contains "chunked body refused" " 400 " "$(raw "s.sendall((h + 'Transfer-Encoding: chunked\\r\\n\\r\\n5\\r\\ntitle\\r\\n0\\r\\n\\r\\n').encode())")"
180contains "oversized body refused" " 413 " "$(raw "s.sendall((h + 'Content-Length: 300000\\r\\n\\r\\n').encode())")"
181check "refused bodies changed nothing" "$before" "$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=')"
182
183# --- a client trickling its body is cut off at the 10-second request deadline
184start=$(date +%s)
185raw "s.sendall((h + 'Content-Length: 100\\r\\n\\r\\n').encode())
186for _ in range(30):
187 try: s.sendall(b'x'); time.sleep(1)
188 except OSError: break" >/dev/null 2>&1
189took=$(( $(date +%s) - start ))
190check "trickling client cut off within 12s" 1 "$([ "$took" -le 12 ] && echo 1 || echo 0)"
191check "server still answers after a trickler" 200 "$(status "$base/healthz")"
192
193# --- only loopback listeners, and no silent every-interface fallback
194timeout 5 "$bin" --listen "0.0.0.0:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1
195check "--listen 0.0.0.0 refused" 2 "$?"
196timeout 5 "$bin" --listen "10.1.2.3:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1
197check "--listen 10.1.2.3 refused" 2 "$?"
198timeout 5 "$bin" --db "$work/x.db" -p "$((port + 1))" >/dev/null 2>&1
199check "no listener refused" 2 "$?"
200
201# --- clean exit on SIGTERM, and the data survives a restart
202kill -TERM "$pid"
203code=0
204for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
205if kill -0 "$pid" 2>/dev/null; then bad "still running after SIGTERM"; else wait "$pid"; code=$?; check "SIGTERM exit status" 0 "$code"; fi
206pid=""
207
208# --- the unix-socket contract, with CARLOS_HOST
209sock="$work/app.sock"
210CARLOS_HOST=todo.example.test "$bin" --socket "$sock" --db "$work/app.db" >>"$work/app.log" 2>&1 &
211pid=$!
212for _ in $(seq 50); do [ -S "$sock" ] && break; sleep 0.1; done
213u=(curl -s --unix-socket "$sock")
214check "socket healthz" 200 "$("${u[@]}" -o /dev/null -w '%{http_code}' http://x/healthz)"
215contains "data survived restart" "<label>fragment</label>" "$("${u[@]}" -H "Cookie: tid=$token" http://x/)"
216contains "Secure cookie on Carloku" "Secure" "$("${u[@]}" -D - -o /dev/null http://x/)"
217check "Carloku origin accepted" 303 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H 'Origin: https://todo.example.test' -d title=x http://x/todos)"
218check "local origin refused on Carloku" 403 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H "Origin: $base" -d title=x http://x/todos)"
219kill -TERM "$pid"
220for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
221if kill -0 "$pid" 2>/dev/null; then bad "socket server still running after SIGTERM"; else wait "$pid"; check "socket SIGTERM exit status" 0 "$?"; fi
222pid=""
223
224echo "$pass passed, $fail failed"
225[ "$fail" -eq 0 ]
226