#!/usr/bin/env bash # Update.V2 smoke test: demo facility, demo gates, soft-deactivated users, self profile, legal/marketing routes. set -u B=${BASE:-http://127.0.0.1:3111} # Refuses early, with the fix, when the server under test is in production mode with # Turnstile refusing every auth route — otherwise the first signup fails and every check # after it reports a security-check error instead of what it was testing. . "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B" T=${TMP:-/tmp}; J="$T/tc-v2-cj.txt"; D="$T/tc-v2-demo.txt"; rm -f "$J" "$D" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); echo " ✓ $1"; } fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; } mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; } dmut() { curl -s -b "$D" -c "$D" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; } check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 300)"; fi; } no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "found: $pat"; else ok "$name"; fi; } jget() { python3 -c "import sys,json; d=json.load(sys.stdin); print(eval('d$1'))"; } echo "== public pages" check "marketing hero" "$(curl -s "$B/")" 'Every garment out the door' check "marketing OG tags for link previews" "$(curl -s "$B/")" 'property="og:title"' check "canonical url" "$(curl -s "$B/")" 'rel="canonical"' check "robots.txt served" "$(curl -s "$B/robots.txt")" 'Sitemap:' check "robots keeps crawlers out of the app" "$(curl -s "$B/robots.txt")" 'Disallow: /app' check "sitemap served" "$(curl -s "$B/sitemap.xml")" '' check "og image declared" "$(curl -s "$B/")" 'og:image" content="[^"]*og.png' check "og image actually served" "$(curl -s -o /dev/null -w '%{http_code} %{content_type}' "$B/og.png")" '200 image/png' check "home states the price" "$(curl -s "$B/")" 'No licence, no per-device charge' check "pricing page says free" "$(curl -s "$B/pricing")" 'That is the whole page' check "features page" "$(curl -s "$B/features")" 'Features' check "how it works page" "$(curl -s "$B/how-it-works")" '<title>How it works' check "pricing page" "$(curl -s "$B/pricing")" '<title>Pricing' check "security page" "$(curl -s "$B/security")" '<title>Security' check "getting started page" "$(curl -s "$B/getting-started")" '<title>Getting started' check "support page" "$(curl -s "$B/support")" '<title>Support' check "roadmap page" "$(curl -s "$B/roadmap")" '<title>Roadmap' check "about page" "$(curl -s "$B/about")" '<title>Who built it' check "roadmap marks unbuilt items Planned" "$(curl -s "$B/roadmap")" 'Planned' no "no android beta claim anywhere" "$(curl -s "$B/roadmap"; curl -s "$B/faq"; curl -s "$B/features")" 'In beta' check "old /product redirects" "$(curl -s -o /dev/null -w '%{http_code}' "$B/product")" '30[78]' check "old /reports redirects" "$(curl -s -o /dev/null -w '%{http_code}' "$B/reports")" '30[78]' check "reporting page" "$(curl -s "$B/reporting")" '<title>Reporting' check "faq page" "$(curl -s "$B/faq")" '<title>Questions' check "contact page" "$(curl -s "$B/contact")" '<title>Contact' check "privacy is its own page" "$(curl -s "$B/privacy")" '<title>Privacy Policy' check "terms is its own page" "$(curl -s "$B/terms")" '<title>Terms of Service' check "data security is its own page" "$(curl -s "$B/data-security")" '<title>Data Security' check "acceptable use is its own page" "$(curl -s "$B/acceptable-use")" '<title>Acceptable Use' check "old /legal still resolves" "$(curl -s -o /dev/null -w '%{http_code}' "$B/legal")" '30[78]' # href="/features" sits in the footer's Product column too, so it came back on every page whether # the nav rendered or not. tcm-navlinks is the nav's own link row and nothing else carries it. for p in reporting pricing faq; do check "site nav on /$p" "$(curl -s "$B/$p")" 'tcm-navlinks'; done check "photos are served" "$(curl -s -o /dev/null -w '%{http_code}' "$B/photos/tc-photo-ward.jpg")" '200' check "sitemap lists the new pages" "$(curl -s "$B/sitemap.xml")" '/acceptable-use' check "demo has its own title" "$(curl -s "$B/demo")" '<title>Try the working demo' check "footer links privacy" "$(curl -s "$B/")" 'href="/privacy"' check "marketing links demo" "$(curl -s "$B/")" 'href="/demo"' check "privacy page content carried over" "$(curl -s "$B/privacy")" 'Information Privacy Act 2009' check "demo picker" "$(curl -s "$B/demo")" '/api/auth/demo?as=issuer' echo "== demo entry" H=$(curl -s -D - -o /dev/null -c "$D" "$B/api/auth/demo?as=admin") check "demo enter 303" "$H" 'HTTP/1.1 303' check "demo enter Location /app" "$(echo "$H" | tr A-Z a-z)" 'location: /app' check "demo enter sets cookie" "$H" 'tc_session=' BK=$(curl -s -b "$D" "$B/api/backup") check "demo facility seeded" "$BK" 'Riverside General Hospital' NSTAFF=$(echo "$BK" | jget '["staff"].__len__()'); [ "$NSTAFF" -ge 24 ] && ok "demo staff seeded ($NSTAFF)" || fail "demo staff" "$NSTAFF" NISS=$(echo "$BK" | jget '["issues"].__len__()'); [ "$NISS" -ge 20 ] && ok "demo issues seeded ($NISS)" || fail "demo issues" "$NISS" NPK=$(echo "$BK" | jget '["pickups"].__len__()'); [ "$NPK" -ge 3 ] && ok "demo pickups seeded ($NPK)" || fail "demo pickups" "$NPK" NAP=$(echo "$BK" | jget '["approvals"].__len__()'); [ "$NAP" -ge 6 ] && ok "demo approvals seeded ($NAP)" || fail "demo approvals" "$NAP" NBO=$(echo "$BK" | python3 -c 'import sys,json; d=json.load(sys.stdin); print([o["status"] for o in d["orders"]].count("Back Order"))'); [ "$NBO" -ge 1 ] && ok "demo back order exists" || fail "demo back order" "$NBO" check "demo app renders banner" "$(curl -s -b "$D" "$B/app")" 'Working demo' check "demo blocks users.add" "$(dmut users.add '{"email":"x@y.com","password":"password123","first":"A","last":"B"}')" 'Not available in the demo' check "demo blocks me.password" "$(dmut me.password '{"current":"a","next":"password123"}')" 'Not available in the demo' check "demo blocks wipe" "$(dmut data.wipeActivity '{"confirm":"WIPE"}')" 'Not available in the demo' check "demo blocks restore" "$(dmut backup.restore '{}')" 'Not available in the demo' # coordinator is one of the fields the demo strips before writing, so sending only that came back # ok:true having changed nothing. Send a field the sandbox is meant to allow, and read it back. check "demo allows settings.update" "$(dmut settings.update '{"defaultReorder":7}')" '"ok":true' check "demo settings.update actually wrote" "$(curl -s -b "$D" "$B/api/backup")" '"defaultReorder": *7' # resetDemo hashes random bytes for both demo accounts, so no password sent from here gets past the # compare — the "Demo accounts can't log in here" refusal further down the login route is out of # reach end to end, and an alternation on it passed on the wrong-password branch every time. What # this can honestly say is that the published demo address is not a way in. check "demo email is not a way in" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -d '{"email":"demo-admin@threadcount.tech","password":"whatever"}')" 'Email or password' H2=$(curl -s -D - -o /dev/null -c "$D" "$B/api/auth/demo?as=issuer") check "demo switch 303" "$H2" 'HTTP/1.1 303' # The facility name is the same facility under either role and is drawn on the app chrome as well, # so it said nothing about who is signed in — it even survived the switch being rate-limited. The # role in the snapshot names it, and the admin-only export refusing says it again from the server's # side: an issuer session that could still take the backup would be the two roles gone. check "demo switch to issuer" "$(curl -s -b "$D" "$B/app")" 'role\\*":\\*"Issuer' check "demo issuer can't take the backup" "$(curl -s -b "$D" -o /dev/null -w '%{http_code}' "$B/api/backup")" '^403$' check "demo reset needs token" "$(curl -s -X POST "$B/api/auth/demo/reset")" 'Forbidden' check "demo reset with token" "$(curl -s -X POST "$B/api/auth/demo/reset" -H 'x-demo-token: localtest')" '"ok":true' curl -s -o /dev/null -c "$D" "$B/api/auth/demo?as=admin" # The coordinator this used to read is one of the fields the demo strips, so it said "Alex Demo" # whether resetDemo ran or not. defaultReorder was dirtied to 7 above and is not stripped, so its # seeded default coming back is the rebuild. check "demo settings reverted after reset" "$(curl -s -b "$D" "$B/api/backup")" '"defaultReorder": *3' echo "== real facility: users" TS=$(date +%s); EMAIL="v2e2e$TS@example.com" check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Test\",\"last\":\"Admin\",\"facility\":\"V2 Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true' check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true' check "me.profile" "$(mut me.profile '{"first":"Tess","last":"Admin","title":"Coordinator"}')" '"ok":true' check "me.profile blank rejected" "$(mut me.profile '{"first":" "}')" 'required' check "profile stamped in snapshot" "$(curl -s -b "$J" "$B/app/settings")" 'Tess' R=$(mut users.add "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\",\"first\":\"Iss\",\"last\":\"Uer\",\"role\":\"ISSUER\"}"); check "add issuer" "$R" '"id"'; ISS=$(echo "$R" | jget '["result"]["id"]') R=$(mut users.add "{\"email\":\"adm$TS@example.com\",\"password\":\"password123\",\"first\":\"Second\",\"last\":\"Admin\",\"role\":\"ADMIN\"}"); ADM2=$(echo "$R" | jget '["result"]["id"]') # Both of the checks that turn on "this is me" need the coordinator's own id, and the snapshot on # the settings page is where it is written down. ME=$(curl -s -b "$J" "$B/app/settings" | grep -o 'userId\\*":\\*"[A-Za-z0-9_-]*' | head -1 | sed 's/.*"//') check "the signed-in admin's own id was found" "$ME" '^[A-Za-z0-9_-]\{8,\}$' # The id sent here used to be the facility's, which is no user at all: users.remove threw "Unknown # user" long before it looked at whose account it was, and the check took that for a pass. The # guard could have been deleted and nothing would have gone red. check "remove self refused" "$(mut users.remove "{\"id\":\"$ME\"}")" "can't remove yourself" check "soft remove issuer" "$(mut users.remove "{\"id\":\"$ISS\"}")" '"ok":true' check "deactivated login refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" 'deactivated' check "deactivated listed in snapshot" "$(curl -s -b "$J" "$B/app/settings")" 'inactive[^a-z]*true' check "reactivate" "$(mut users.update "{\"id\":\"$ISS\",\"inactive\":false}")" '"ok":true' check "reactivated login works" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true' check "remove second admin ok" "$(mut users.remove "{\"id\":\"$ADM2\"}")" '"ok":true' # The guard counts admins who are still active, so demoting one who has already been removed is # allowed and should be: the facility still has a live admin. This is the pass side of the clause # the next check trips, and it is all this line ever tested — it used to carry the other one's name. check "demote an already-removed admin allowed" "$(mut users.update "{\"id\":\"$ADM2\",\"role\":\"ISSUER\"}")" '"ok":true' # With that done the signed-in coordinator is the only active admin left, so the facility is one # demotion away from having nobody who can reach settings, users or the backup. Demoting yourself # out of the role is the only way to get there, and it has to be refused. check "demote last active admin refused" "$(mut users.update "{\"id\":\"$ME\",\"role\":\"ISSUER\"}")" 'Keep at least one active admin' # The export is admin-only, so it still answering is the account saying it kept the role: a guard # that threw after writing would leave the coordinator locked out with the refusal on screen. check "and they are still an admin afterwards" "$(curl -s -b "$J" -o /dev/null -w '%{http_code}' "$B/api/backup")" '^200$' echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]