#!/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 ]