ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -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 ]
|
||||
Reference in New Issue
Block a user