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