ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/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")" '<loc>'
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")" '<title>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 ]