822c0b7c0b
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
93 lines
8.7 KiB
Bash
Executable File
93 lines
8.7 KiB
Bash
Executable File
#!/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 ]
|