Files
threadcount-community/scripts/e2e-staff.sh
T
ThreadCount 46a1911c1c ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 5470a36 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:35:40 +10:00

219 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Staff self-service: claiming your own record, and the walls around it.
#
# Most of this file is about what a staff session must NOT be able to do. The whole design rests on
# a wearer's cookie being a different kind of thing from a coordinator's, so the checks that matter
# are the ones that try to use one as the other.
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}
C="$T/tc-ss-coord.txt" # coordinator jar
S="$T/tc-ss-staff.txt" # staff jar
S2="$T/tc-ss-staff2.txt" # a second staff member
rm -f "$C" "$S" "$S2"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $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 220)"; fi; }
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 220)"; else ok "$name"; fi; }
mut() { curl -s -b "$C" -c "$C" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -d "{\"op\":\"$1\",\"payload\":$2}"; }
spost(){ curl -s -b "$S" -c "$S" -X POST "$B$1" -H 'content-type: application/json' -H "origin: $B" -d "$2"; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
# Everything React sends the browser as data rides in <script> blocks, and that includes whole
# component trees the page never draws. Strip them and what is left is the markup a person sees,
# which is the only thing a "this must not be on the screen" check should be asking about.
markup(){ python3 -c 'import sys,re; sys.stdout.write(re.sub(r"(?is)<script\b[^>]*>.*?</script>","",sys.stdin.read()))'; }
TS=$(date +%s)
CO="ss$TS@example.com"
ME="wearer$TS@example.com"
echo "== setup"
check "coordinator signs up" "$(curl -s -c "$C" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.23.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Sam\",\"last\":\"Self\",\"facility\":\"Self Service Hospital $TS\",\"email\":\"$CO\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$C")" '"ok":true'
A=$(mut staff.save "{\"num\":\"SS1\",\"first\":\"Ada\",\"last\":\"Wearer\",\"group\":\"Support Services\",\"dept\":\"Theatres\",\"top\":\"M\",\"pants\":\"12\"}")
check "a staff member exists" "$A" '"id"'
AID=$(echo "$A" | py "print(d['result']['id'])")
Bx=$(mut staff.save "{\"num\":\"SS2\",\"first\":\"Bo\",\"last\":\"Other\",\"group\":\"Security\",\"dept\":\"Front of house\"}")
BID=$(echo "$Bx" | py "print(d['result']['id'])")
check "and a second one" "$Bx" '"id"'
# Give Ada something to look at, so the view has content and the isolation check has a needle.
ITEM=$(mut catalog.add '{"item":"Theatre scrub top","type":"Scrub top","sizes":["S","M","L"],"cost":24.5,"opening":[{"si":1,"qty":40}]}')
check "a garment is on the shelf" "$ITEM" '"id"'
IID=$(echo "$ITEM" | py "print(d['result']['id'])")
# The quantity is what is asserted, not just that it worked: every count in the handed-back
# section below is arithmetic over this two, so a change to it has to fail here rather than
# quietly rewrite what those later checks mean.
check "Ada is issued two" "$(mut issue.create "{\"staffId\":\"$AID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}]}")" '"stock":2'
echo "== the code"
GEN=$(mut staff.selfCode "{\"id\":\"$AID\"}")
check "a coordinator can generate one" "$GEN" '"code"'
CODE=$(echo "$GEN" | py "print(d['result']['code'])")
check "twelve characters in three groups" "$CODE" '^[2-9A-HJ-NP-Z]\{4\}-[2-9A-HJ-NP-Z]\{4\}-[2-9A-HJ-NP-Z]\{4\}$'
no "with no ambiguous characters" "$CODE" '[ILOU01]'
# The snapshot is server-rendered into the page rather than served from an endpoint, so the staff
# record itself is where to look for what the coordinator's browser was told.
REC=$(curl -s -b "$C" "$B/app/staff/$AID")
check "the record says a code is outstanding" "$REC" 'code is outstanding'
no "but the page never carries the code itself" "$REC" "$CODE"
echo "== activating"
check "a wrong code is refused" "$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"AAAA-BBBB-CCCC\",\"email\":\"x$ME\",\"password\":\"wearerpass1\"}")" "isn't right"
check "a short password is refused" "$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"$ME\",\"password\":\"short\"}")" 'at least 8'
check "a bad email is refused" "$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"notanemail\",\"password\":\"wearerpass1\"}")" 'email address'
ACT=$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"$ME\",\"password\":\"wearerpass1\"}")
check "the right code sets the account up" "$ACT" '"ok":true'
check "and greets them by name" "$ACT" 'Ada Wearer'
check "a staff cookie was set" "$(cat "$S")" 'tc_staff'
check "the same code can't be used twice" "$(curl -s -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"second$ME\",\"password\":\"wearerpass1\"}")" 'already been used'
echo "== what they see"
MY=$(curl -s -b "$S" "$B/my")
check "their own name on home" "$MY" 'Ada Wearer'
check "their ward and staff number" "$MY" 'Theatres · SS1'
no "and nobody else on the register" "$MY" 'Bo Other'
KIT=$(curl -s -b "$S" "$B/my/kit")
check "the kit screen lists what they hold" "$KIT" 'Theatre scrub top'
no "and still nobody else" "$KIT" 'Bo Other'
check "and nothing has come back off the record yet" "$KIT" 'Nothing handed back since'
# Their recorded sizes used to be checked on Home. They were taken off that screen — homeData does
# not return them any more — but they were not taken away from the person: the Kit screen carries
# them, so the check follows them here rather than disappearing. This is not a duplicate of
# anything above. Kit keeps the sizes behind its "My sizes" tab, and which tab is showing is
# decided in the browser, so the server sends them as the screen's data rather than as markup.
# That is what curl can see of them, and it is what the tab draws from: the two sizes Ada was
# saved with at the top of this file, spelled the way the screen was handed them.
check " and their recorded top and trouser sizes" "$KIT" '\\"sizes\\":{\\"top\\":\\"M\\",\\"pants\\":\\"12\\"}'
echo "== handed back counts both ways a garment leaves a person"
# Two things take a garment off somebody: a return over the counter, and a hand-in, which never
# stamps returnedDate because the garment joins the pre-loved pool instead of coming back to the
# shelf. Counting returns alone told somebody who had carried four garments in that morning that
# they had handed nothing back all year — on the one screen they would check before arguing about
# it. A write-off is neither: nobody handed that one back, and crediting them for it is the same
# untruth in the other direction.
check "Ada is issued three more" "$(mut issue.create "{\"staffId\":\"$AID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":3}]}")" '"stock":3'
ISS=$(curl -s -b "$C" "$B/api/backup" | py "print([i['id'] for i in d['issues'] if i['staffId']=='$AID'][0])")
check "one comes back over the counter" "$(mut issue.return "{\"id\":\"$ISS\",\"qty\":1,\"cond\":\"Returned - Good\"}")" '"ok":true'
check "the kit screen counts it" "$(curl -s -b "$S" "$B/my/kit")" '1 garment handed back since'
check "another is handed in to the pool" "$(mut handin.add "{\"staffId\":\"$AID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")" '"good":1'
check " and it counts the same" "$(curl -s -b "$S" "$B/my/kit")" '2 garments handed back since'
LOST=$(curl -s -b "$C" "$B/api/backup" | py "print([i['id'] for i in d['issues'] if i['staffId']=='$AID' and not i['returnedDate'] and not i['handedIn']][0])")
check "one more is written off" "$(mut issue.return "{\"id\":\"$LOST\",\"qty\":1,\"cond\":\"Written Off\"}")" '"ok":true'
check " which nobody handed back" "$(curl -s -b "$S" "$B/my/kit")" '2 garments handed back since'
echo "== leaving the app"
HOME_HTML=$(curl -s -b "$S" "$B/my")
check "Home offers a way out" "$HOME_HTML" 'Sign out'
# Asked of the markup, because the whole response is not the screen. The site's 404 page carries
# the marketing nav, "Log in — /auth" and all, and Next serialises that boundary into the payload
# of every page under the root layout, this one included. Nothing draws it unless a route calls
# notFound(), so grepping the raw HTML found the coordinator's door in a tree the wearer never
# sees. What is left after the scripts go is what is on the screen, and the bare path catches the
# link however it is written — absolute or not, an href or a form.
HOME_MARKUP=$(printf '%s' "$HOME_HTML" | markup)
no "and never sends a wearer to the desktop sign-in" "$HOME_MARKUP" '/auth'
LOGOUT_JAR="$T/tc-ss-out.txt"; cp "$S" "$LOGOUT_JAR"
check "signing out is accepted" "$(curl -s -b "$LOGOUT_JAR" -c "$LOGOUT_JAR" -X POST "$B/api/staff/logout" -H 'content-type: application/json' -H "origin: $B" -d '{}')" '"ok":true'
check "and the session is dead afterwards" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$LOGOUT_JAR" "$B/my")" '30[0-9] .*/my/signin'
# The "On the website" list — the coordinator's sign-in among it — was removed from this screen at
# Kyle's ask (2026-09-12): nothing on it was something a wearer could act on here. The terms and
# the policy now travel with the agreement tick instead.
no "the sign-in no longer points coordinators at the website" "$(curl -s "$B/my/signin")" 'href="https://threadcount.tech/auth"'
check " but the agreement tick links the terms" "$(curl -s "$B/my/signin")" '/terms'
echo "== a staff session is not a coordinator session"
check "/app redirects to the coordinator sign-in" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S" "$B/app")" '30[0-9] .*/auth'
check "/m redirects too" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S" "$B/m")" '30[0-9] .*/m/login'
check "the backup export is refused" "$(curl -s -b "$S" "$B/api/backup")" 'Not signed in'
check "so is any mutation" "$(curl -s -b "$S" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -d '{"op":"staff.delete","payload":{"id":"x"}}')" 'Not signed in'
check "and the audit log" "$(curl -s -b "$S" "$B/api/activity")" 'Not signed in'
echo "== the staff token is not a session token"
TOK=$(grep tc_staff "$S" | awk '{print $NF}')
check "a token was captured for the test" "$TOK" '.'
check "presented as tc_session it authenticates nothing" "$(curl -s -H "cookie: tc_session=$TOK" "$B/api/backup")" 'Not signed in'
echo "== a coordinator is not a staff member either"
check "/my sends a coordinator to the staff sign-in" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$C" "$B/my")" '30[0-9] .*/my/signin'
echo "== signing in again"
check "the password signs them in" "$(curl -s -c "$S" -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$ME\",\"password\":\"wearerpass1\"}")" '"ok":true'
check "a wrong password does not" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$ME\",\"password\":\"nope\"}")" 'doesnt match'
check "an unknown address does not" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d '{"email":"nobody@example.com","password":"whatever"}')" 'doesnt match'
check "a coordinator's own password does not open a staff account" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"password123\"}")" 'doesnt match'
echo "== one wearer cannot become another"
CODE2=$(mut staff.selfCode "{\"id\":\"$BID\"}" | py "print(d['result']['code'])")
check "Bo activates their own" "$(curl -s -c "$S2" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$CODE2\",\"email\":\"bo$ME\",\"password\":\"wearerpass2\"}")" '"ok":true'
MY2=$(curl -s -b "$S2" "$B/my")
check "and sees themselves" "$MY2" 'Bo Other'
no "not Ada" "$MY2" 'Ada Wearer'
no "and not Ada's garments" "$(curl -s -b "$S2" "$B/my/kit")" 'Theatre scrub top'
echo "== and cannot raise a request in anybody elses name"
# The one door into somebody else's name is being their manager — the ward desk had one too, and
# lost it. Ada manages nobody, so the answer she gets is the answer everybody else gets.
FOR_BO=$(spost /api/staff/mutate "{\"op\":\"request.create\",\"payload\":{\"subjectId\":\"$BID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}}")
check "raising for somebody else is refused" "$FOR_BO" "Only somebody.s own manager"
no " and nothing is created by it" "$FOR_BO" '"code"'
check "and with no approver they cannot raise for themselves either" "$(spost /api/staff/mutate "{\"op\":\"request.create\",\"payload\":{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}}")" "manager isn't set"
echo "== the coordinator side afterwards"
check "the record shows the linked address" "$(curl -s -b "$C" "$B/app/staff/$AID")" "$ME"
check "a second code is refused while they are linked" "$(mut staff.selfCode "{\"id\":\"$AID\"}")" 'already has an account'
echo "== taking access away"
check "unlink works" "$(mut staff.selfUnlink "{\"id\":\"$AID\"}")" '"ok":true'
check "their session dies with it" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S" "$B/my")" '30[0-9] .*/my/signin'
check "and the password no longer signs in" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$ME\",\"password\":\"wearerpass1\"}")" 'doesnt match'
echo "== leaving the register ends the view"
check "Bo is deactivated" "$(mut staff.patch "{\"id\":\"$BID\",\"inactive\":true}")" '"ok":true'
check "and can no longer see their record" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S2" "$B/my")" '30[0-9] .*/my/signin'
check "nor sign in again" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"bo$ME\",\"password\":\"wearerpass2\"}")" 'no longer on the register'
echo "== cross-site requests are refused"
# A gate can only be seen working on a request that would otherwise have gone through. These pairs
# send the very same body twice, and the Origin header is the only thing that differs between the
# two, so nothing but the origin check can account for one being refused and the other landing.
# Sent with a spent code or an unlinked account, as this section used to be, a refusal proves
# nothing: the request was going to fail whichever origin it came from.
CX=$(mut staff.save "{\"num\":\"SS3\",\"first\":\"Cass\",\"last\":\"Third\",\"group\":\"Security\",\"dept\":\"Front of house\"}")
check "a third wearer to try it on" "$CX" '"id"'
CXID=$(echo "$CX" | py "print(d['result']['id'])")
CODE3=$(mut staff.selfCode "{\"id\":\"$CXID\"}" | py "print(d['result']['code'])")
CXEMAIL="cass$ME"
CXBODY="{\"agreed\":true,\"code\":\"$CODE3\",\"email\":\"$CXEMAIL\",\"password\":\"wearerpass3\"}"
check "a good activation from another origin is refused" "$(curl -s -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: https://evil.example" -d "$CXBODY")" 'Cross-site request refused'
# Landing now also says the refused attempt never spent the code on its way out.
check " and the same one from our own origin is accepted" "$(curl -s -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "$CXBODY")" '"ok":true'
CXLOGIN="{\"email\":\"$CXEMAIL\",\"password\":\"wearerpass3\"}"
check "a good sign-in from another origin is refused" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: https://evil.example" -d "$CXLOGIN")" 'Cross-site request refused'
check " and the same one from our own origin signs them in" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "$CXLOGIN")" '"ok":true'
# Origin is only half the gate. A cross-site form post may carry no Origin at all, but the browser
# still stamps Sec-Fetch-Site on it, and a form can only ever send a non-JSON content type.
check "a cross-site post the browser labelled as such is refused" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H 'sec-fetch-site: cross-site' -d "$CXLOGIN")" 'Cross-site request refused'
check "and a form post from our own origin gets no further" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: text/plain' -H "origin: $B" -d "$CXLOGIN")" 'Expected JSON'
echo "== the sign-in page itself"
SI=$(curl -s "$B/my/signin")
# Being public is the status code. "Your uniform record" is the <title> the whole /my shell sets,
# so grepping the body for it proved nothing about this page — it would come back 200 with the
# Suspense fallback and no form at all and still look green.
check "is public" "$(curl -s -o /dev/null -w '%{http_code}' "$B/my/signin")" '^200$'
check "offers both ways in" "$SI" 'I have a code'
check "and points coordinators elsewhere" "$SI" '/auth'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]