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
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Account deletion — the path Google Play requires. Checks that it is gated on the password,
# that removing a colleague leaves the facility alone, that the last admin takes the whole
# facility with them, and that afterwards there is genuinely nothing left to sign in to.
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}; A="$T/tc-del-a.txt"; C="$T/tc-del-b.txt"; rm -f "$A" "$C"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1 :: $2"; }
mutA() { curl -s -b "$A" -c "$A" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
mutB() { curl -s -b "$C" -c "$C" -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; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
TS=$(date +%s)
FAC="Deletion Hospital $TS"
echo "== a facility with two people and some history"
check "signup" "$(curl -s -c "$A" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.4.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Ada\",\"last\":\"Admin\",\"facility\":\"$FAC\",\"email\":\"del-a$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$A")" '"ok":true'
check "supplier" "$(mutA supplier.add '{"name":"Alpha Supply"}')" '"id"'
check "catalogue" "$(mutA import.rows '{"kind":"catalog","rows":[{"item":"Scrub Top","sku":"T1","supplier":"Alpha Supply","cost":"30","group":"Registered Nurse","sizes":"S|M"}]}')" '"created":1'
check "opening stock accepted" "$(mutA import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"10"}]}')" '"created":1'
# created:1 only says the row found an item and a size — importRows counts it either way, so a
# quantity that never reached the shelf reads as success here and only shows up two sections later
# as "Not enough on the shelf", under a label that names something else. Read the shelf back.
check "opening stock" "$(curl -s -b "$A" "$B/api/backup" | py 'print(sum(s["opening"] for s in d["stock"]))')" '^10$'
check "staff" "$(mutA import.rows '{"kind":"staff","rows":[{"num":"1","first":"Nina","last":"Nurse","group":"Registered Nurse","dept":"Willow Ward","top":"M","pants":"M"}]}')" '"created":1'
check "a second admin" "$(mutA users.add "{\"email\":\"del-b$TS@example.com\",\"first\":\"Ben\",\"last\":\"Buddy\",\"role\":\"ADMIN\",\"password\":\"password123\"}")" '"id"'
check "second admin signs in" "$(curl -s -c "$C" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"del-b$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
BK=$(curl -s -b "$A" "$B/api/backup")
T1=$(echo "$BK" | py 'print(d["items"][0]["id"])'); NINA=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
# Recorded by Ben, the admin who deletes himself below. The trail's promise is that it keeps the
# name it was stamped with, and nothing exercises that unless the departing person did the work.
check "an issue on the record" "$(mutB issue.create "{\"staffId\":\"$NINA\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
echo "== the password gate"
check "wrong password refused" "$(mutB me.deleteAccount '{"password":"nope"}')" "password doesn't match"
check "empty password refused" "$(mutB me.deleteAccount '{"password":""}')" "password doesn't match"
check "still signed in afterwards" "$(curl -s -b "$C" "$B/api/backup" | py 'print(d["facility"]["name"])')" "$FAC"
echo "== deleting one of two people"
check "second admin deletes themselves" "$(mutB me.deleteAccount '{"password":"password123"}')" '"deleted":"user"'
check "their session is dead" "$(curl -s -b "$C" -o /dev/null -w '%{http_code}' "$B/api/backup")" '401'
# The one refusal /api/auth/login actually has, status included: a 429 off a shared throttle or a
# 403 for a deactivated account must not read as "the account is gone". The forged client address
# keeps this run's failed attempts out of every other run's bucket on 127.0.0.1.
check "they cannot sign in again" "$(curl -s -w '|%{http_code}' -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "x-forwarded-for: 10.4.$((RANDOM%250)).$((RANDOM%250))" -d "{\"email\":\"del-b$TS@example.com\",\"password\":\"password123\"}")" 'Email or password.*|401$'
check "the facility is untouched" "$(curl -s -b "$A" "$B/api/backup" | py 'print(d["facility"]["name"])')" "$FAC"
check "the catalogue survived" "$(curl -s -b "$A" "$B/api/backup" | py 'print(len(d["items"]))')" '^1$'
check "the issue survived" "$(curl -s -b "$A" "$B/api/backup" | py 'print(len(d["issues"]))')" '^1$'
# Issue has no relation to User, so that row surviving is guaranteed by the schema. The promise
# deletion actually has to keep is on the audit trail, which stores the name as plain text — no
# foreign key — precisely so it outlives the account.
check "and it is still stamped with the departed admin" "$(curl -s -b "$A" "$B/api/activity" | py 'print(sum(1 for e in d["events"] if e["op"]=="issue.create" and e["who"]=="Ben Buddy"))')" '^1$'
# A backup carries no user list at all — see exportBackup — so the only surface that shows who can
# still sign in is the snapshot the /app layout serialises into the page. ADMIN there is the raw
# role enum, one per admin account in that list and nowhere else on the dashboard.
check "the departed admin is gone from the user list" "$(curl -s -b "$A" "$B/app" | grep -c "del-b$TS@example.com" || true)" '^0$'
check "only the remaining admin is listed" "$(curl -s -b "$A" "$B/app" | grep -o 'ADMIN' | wc -l | tr -d ' ')" '^1$'
echo "== the last admin takes the facility"
# Every refusal is checked before the one that actually works, or the later checks have no
# facility left to run against.
check "the typed name is required" "$(mutA me.deleteAccount '{"password":"password123"}')" 'Type the facility name'
check "a wrong name is refused" "$(mutA me.deleteAccount '{"password":"password123","confirm":"Some Other Hospital"}')" 'Type the facility name'
check "a partial name is refused" "$(mutA me.deleteAccount '{"password":"password123","confirm":"Deletion Hospital"}')" 'Type the facility name'
check "the wrong password is refused even with the right name" "$(mutA me.deleteAccount "{\"password\":\"wrong\",\"confirm\":\"$FAC\"}")" "password doesn't match"
check "the facility is still there after all that" "$(curl -s -b "$A" "$B/api/backup" | py 'print(d["facility"]["name"])')" "$FAC"
# Surrounding whitespace is trimmed on purpose — a name pasted with a trailing space is still the
# name the person meant, and refusing it just teaches people to fight the form.
check "password plus the exact name deletes it (trailing space tolerated)" "$(mutA me.deleteAccount "{\"password\":\"password123\",\"confirm\":\"$FAC \"}")" '"deleted":"facility"'
echo "== nothing left"
check "the session is dead" "$(curl -s -b "$A" -o /dev/null -w '%{http_code}' "$B/api/backup")" '401'
check "the admin cannot sign in" "$(curl -s -w '|%{http_code}' -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "x-forwarded-for: 10.4.$((RANDOM%250)).$((RANDOM%250))" -d "{\"email\":\"del-a$TS@example.com\",\"password\":\"password123\"}")" 'Email or password.*|401$'
check "the email is free to sign up again" "$(curl -s -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.4.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Ada\",\"last\":\"Admin\",\"facility\":\"Reborn Hospital $TS\",\"email\":\"del-a$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "and the new facility is empty" "$(curl -s -X POST "$B/api/auth/login" -c "$A" -H 'content-type: application/json' -d "{\"email\":\"del-a$TS@example.com\",\"password\":\"password123\"}" > /dev/null; curl -s -b "$A" "$B/api/backup" | py 'print(len(d["items"]), len(d["staff"]), len(d["issues"]))')" '^0 0 0$'
echo "== the public page Play links to"
check "/delete-account needs no session" "$(curl -s -o /dev/null -w '%{http_code}' "$B/delete-account")" '200'
check "it says what happens" "$(curl -s "$B/delete-account")" 'cannot be undone'
check "it names the two cases" "$(curl -s "$B/delete-account")" 'You are the last one'
check "it is in the sitemap" "$(curl -s "$B/sitemap.xml")" 'delete-account'
echo
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]