| new file mode 100755 |
| index 0000000..cd2b931 |
| --- /dev/null |
| +++ b/apps/spinel/test/run.sh |
| @@ -0,0 +1,180 @@ |
| +#!/usr/bin/env bash |
| +# The app's own tests: start the compiled binary, drive it with curl, and |
| +# check the contract's wire-level rules (status codes, redirects, cookies, |
| +# escaping, Content-Length, static files, isolation, Origin, the unix |
| +# socket and a clean SIGTERM exit). The browser-level behaviour is the |
| +# shared suite's job (scripts/test-app.sh at the repo root). |
| +# |
| +# test/run.sh dist/app |
| +set -uo pipefail |
| + |
| +bin=$(cd "$(dirname "${1:?usage: test/run.sh <binary>}")" && pwd)/$(basename "$1") |
| +here=$(cd "$(dirname "$0")/.." && pwd) |
| +work=$(mktemp -d -p "${TMPDIR:-/tmp}") |
| +pid="" |
| +cleanup() { |
| + [ -n "$pid" ] && kill "$pid" 2>/dev/null |
| + rm -rf "$work" |
| +} |
| +trap cleanup EXIT |
| + |
| +pass=0 |
| +fail=0 |
| +ok() { pass=$((pass + 1)); } |
| +bad() { fail=$((fail + 1)); echo "FAIL: $*"; } |
| +check() { # check <description> <expected> <actual> |
| + if [ "$2" == "$3" ]; then ok; else bad "$1: expected [$2], got [$3]"; fi |
| +} |
| +contains() { # contains <description> <needle> <haystack> |
| + if [[ "$3" == *"$2"* ]]; then ok; else bad "$1: [$2] not found"; fi |
| +} |
| +lacks() { |
| + if [[ "$3" != *"$2"* ]]; then ok; else bad "$1: [$2] found"; fi |
| +} |
| + |
| +port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])') |
| +base="http://127.0.0.1:$port" |
| +"$bin" --listen "127.0.0.1:$port" --db "$work/app.db" >"$work/app.log" 2>&1 & |
| +pid=$! |
| +for _ in $(seq 50); do curl -sf "$base/healthz" >/dev/null && break; sleep 0.1; done |
| + |
| +status() { curl -s -o /dev/null -w '%{http_code}' "$@"; } |
| +jar="$work/jar" |
| +other="$work/other" |
| + |
| +# --- platform routes |
| +check "healthz" 200 "$(status "$base/healthz")" |
| +check "version" 200 "$(status "$base/api/version")" |
| +check "version body" "$(cat "$here/version.rb" | sed -n 's/^APP_VERSION = "\(.*\)"$/\1/p')" "$(curl -s "$base/api/version")" |
| +check "unknown path" 404 "$(status "$base/nope")" |
| + |
| +# --- listens only where it was told to |
| +listeners=$(ss -ltnH "sport = :$port" | awk '{print $4}') |
| +check "bound to 127.0.0.1 only" "127.0.0.1:$port" "$listeners" |
| + |
| +# --- first visit: cookie, empty list, no main/footer |
| +headers=$(curl -s -D - -o "$work/page" -c "$jar" "$base/") |
| +contains "cookie set" "Set-Cookie: tid=" "$headers" |
| +contains "cookie HttpOnly" "HttpOnly" "$headers" |
| +contains "cookie SameSite" "SameSite=Lax" "$headers" |
| +contains "cookie Path" "Path=/" "$headers" |
| +lacks "no Secure over http" "Secure" "$headers" |
| +token=$(awk '$6 == "tid" {print $7}' "$jar") |
| +check "token is 22 base64url chars" 1 "$([[ "$token" =~ ^[A-Za-z0-9_-]{22}$ ]] && echo 1 || echo 0)" |
| +page=$(cat "$work/page") |
| +lacks "empty list hides main" 'class="main"' "$page" |
| +lacks "empty list hides footer" 'class="footer"' "$page" |
| +contains "language in footer" "the CARLOS bakeoff</a> · Spinel" "$page" |
| + |
| +# --- add: trimmed, blank ignored, 303 to the filter |
| +post() { curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$jar" -c "$jar" "$@"; } |
| +check "add" "303 $base/" "$(post --data-urlencode 'title= buy milk ' -d filter=all "$base/todos")" |
| +check "add from active" "303 $base/active" "$(post --data-urlencode 'title=walk the dog' -d filter=active "$base/todos")" |
| +check "blank add" "303 $base/completed" "$(post --data-urlencode 'title= ' -d filter=completed "$base/todos")" |
| +check "unknown filter" "303 $base/" "$(post --data-urlencode 'title=write' -d filter=bogus "$base/todos")" |
| +page=$(curl -s -b "$jar" "$base/") |
| +contains "trimmed title" "<label>buy milk</label>" "$page" |
| +check "three items" 3 "$(grep -c '<li data-id=' <<<"$page")" |
| +contains "counter" "<strong>3</strong> items left" "$page" |
| +lacks "no clear-completed yet" "clear-completed" "$page" |
| + |
| +id1=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | head -1 | grep -o '[0-9]*') |
| +id2=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | sed -n 2p | grep -o '[0-9]*') |
| + |
| +# --- toggle, filters, toggle-all, clear-completed |
| +check "toggle" "303 $base/active" "$(post -d filter=active "$base/todos/$id1/toggle")" |
| +page=$(curl -s -b "$jar" "$base/") |
| +contains "completed class" "<li data-id=\"$id1\" class=\"completed\">" "$page" |
| +contains "singular counter" "<strong>2</strong> items left" "$page" |
| +contains "clear-completed shown" 'class="clear-completed"' "$page" |
| +active=$(curl -s -b "$jar" "$base/active") |
| +lacks "active filter hides completed" "data-id=\"$id1\"" "$active" |
| +contains "active link selected" '<a class="selected" href="/active">' "$active" |
| +completed=$(curl -s -b "$jar" "$base/completed") |
| +check "completed filter" 1 "$(grep -c '<li data-id=' <<<"$completed")" |
| +check "toggle-all" "303 $base/" "$(post -d filter=all "$base/todos/toggle-all")" |
| +check "all completed" 3 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')" |
| +contains "toggle-all checked" 'class="toggle-all" type="checkbox" form="toggle-all-form" checked' "$(curl -s -b "$jar" "$base/")" |
| +post -d filter=all "$base/todos/toggle-all" >/dev/null |
| +check "toggle-all again clears" 0 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')" |
| +post -d filter=all "$base/todos/$id1/toggle" >/dev/null |
| +check "clear-completed" "303 $base/" "$(post -d filter=all "$base/todos/clear-completed")" |
| +lacks "completed item gone" "data-id=\"$id1\"" "$(curl -s -b "$jar" "$base/")" |
| + |
| +# --- edit view, rename, empty rename deletes |
| +edit=$(curl -s -b "$jar" "$base/active?edit=$id2") |
| +contains "editing class" "<li data-id=\"$id2\" class=\"editing\">" "$edit" |
| +contains "edit form" "action=\"/todos/$id2/edit\"" "$edit" |
| +contains "edit link keeps filter" "href=\"/active?edit=$id2\"" "$edit" |
| +check "rename" "303 $base/active" "$(post --data-urlencode 'title= walk the cat ' -d filter=active "$base/todos/$id2/edit")" |
| +contains "renamed" "<label>walk the cat</label>" "$(curl -s -b "$jar" "$base/")" |
| +check "empty rename" "303 $base/" "$(post --data-urlencode 'title= ' -d filter=all "$base/todos/$id2/edit")" |
| +lacks "empty rename deletes" "data-id=\"$id2\"" "$(curl -s -b "$jar" "$base/")" |
| + |
| +# --- escaping, UTF-8, Content-Length, long titles |
| +post --data-urlencode 'title=<img src=x onerror=alert(1)> & "q" '"'"'s' -d filter=all "$base/todos" >/dev/null |
| +post --data-urlencode 'title=naïve 🚀 日本語' -d filter=all "$base/todos" >/dev/null |
| +headers=$(curl -s -D - -o "$work/page" -b "$jar" "$base/") |
| +page=$(cat "$work/page") |
| +contains "escaped title" "<img src=x onerror=alert(1)> & "q" 's" "$page" |
| +lacks "no raw img" "<img" "$page" |
| +contains "UTF-8 kept" "naïve 🚀 日本語" "$page" |
| +declared=$(tr -d '\r' <<<"$headers" | sed -n 's/^Content-Length: //p') |
| +check "Content-Length is bytes" "$(wc -c <"$work/page")" "$declared" |
| +long=$(printf 'é%.0s' $(seq 10000)) |
| +post --data-urlencode "title=$long" -d filter=all "$base/todos" >/dev/null |
| +contains "10,000-char title" "<label>$long</label>" "$(curl -s -b "$jar" "$base/")" |
| + |
| +# --- isolation, foreign ids, garbled cookies, Origin |
| +check "foreign id from another visitor" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$id1/toggle")" |
| +own=$(curl -s -b "$jar" "$base/" | grep -o '<li data-id="[0-9]*"' | head -1 | grep -o '[0-9]*') |
| +check "someone else's toggle" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$own/toggle")" |
| +check "someone else's edit" 404 "$(status -b "$other" -d title=x -d filter=all "$base/todos/$own/edit")" |
| +check "someone else's destroy" 404 "$(status -b "$other" -d filter=all "$base/todos/$own/destroy")" |
| +lacks "other visitor sees nothing" "<li data-id" "$(curl -s -b "$other" "$base/")" |
| +check "non-numeric id" 404 "$(status -b "$jar" -d filter=all "$base/todos/abc/toggle")" |
| +check "garbled cookie" 200 "$(status -b 'tid=%%%' "$base/")" |
| +lacks "garbled cookie gets empty list" "<li data-id" "$(curl -s -b 'tid=%%%' "$base/")" |
| +contains "garbled cookie replaced" "Set-Cookie: tid=" "$(curl -s -D - -o /dev/null -b 'tid=%%%' "$base/")" |
| +check "cross-site POST" 403 "$(status -b "$jar" -H 'Origin: https://evil.example' --data-urlencode title=evil "$base/todos")" |
| +lacks "cross-site POST changed nothing" "evil" "$(curl -s -b "$jar" "$base/")" |
| +check "same-origin POST" 303 "$(status -b "$jar" -H "Origin: $base" --data-urlencode title=fine "$base/todos")" |
| + |
| +# --- the script's fragment answers |
| +frag=$(curl -s -b "$jar" -H 'X-Fragment: 1' -d filter=active --data-urlencode title=fragment "$base/todos") |
| +check "fragment starts with main" '<section class="main">' "$(head -1 <<<"$frag")" |
| +lacks "fragment is not a page" "<html" "$frag" |
| +contains "fragment keeps the filter" '<a class="selected" href="/active">' "$frag" |
| + |
| +# --- static files, byte for byte |
| +for f in base.css index.css; do |
| + curl -s "$base/static/$f" -o "$work/$f" |
| + if cmp -s "$work/$f" "$here/../../shared/$f"; then ok; else bad "/static/$f differs from shared/$f"; fi |
| +done |
| +contains "css type" "Content-Type: text/css" "$(curl -s -D - -o /dev/null "$base/static/index.css")" |
| + |
| +# --- clean exit on SIGTERM, and the data survives a restart |
| +kill -TERM "$pid" |
| +code=0 |
| +for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done |
| +if kill -0 "$pid" 2>/dev/null; then bad "still running after SIGTERM"; else wait "$pid"; code=$?; check "SIGTERM exit status" 0 "$code"; fi |
| +pid="" |
| + |
| +# --- the unix-socket contract, with CARLOS_HOST |
| +sock="$work/app.sock" |
| +CARLOS_HOST=todo.example.test "$bin" --socket "$sock" --db "$work/app.db" >>"$work/app.log" 2>&1 & |
| +pid=$! |
| +for _ in $(seq 50); do [ -S "$sock" ] && break; sleep 0.1; done |
| +u=(curl -s --unix-socket "$sock") |
| +check "socket healthz" 200 "$("${u[@]}" -o /dev/null -w '%{http_code}' http://x/healthz)" |
| +contains "data survived restart" "<label>fragment</label>" "$("${u[@]}" -H "Cookie: tid=$token" http://x/)" |
| +contains "Secure cookie on Carloku" "Secure" "$("${u[@]}" -D - -o /dev/null http://x/)" |
| +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)" |
| +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)" |
| +kill -TERM "$pid" |
| +for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done |
| +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 |
| +pid="" |
| + |
| +echo "$pass passed, $fail failed" |
| +[ "$fail" -eq 0 ] |