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 e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:16:36 +10:00
commit 057da00fd2
406 changed files with 47822 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
/* Mint an approval link token for the e2e suite and print it.
*
* The real token only ever exists inside an email to a ward manager, which the suite has no way to
* read. Rather than skip the emailed-link path — the one route into this product that works
* without a session, and therefore the one most worth testing — the suite mints its own using the
* same signing code the app uses.
*
* Refuses to run in production. It needs SESSION_SECRET, so anyone who could use it can already
* forge one — but a guard costs nothing and states the intent.
*
* node scripts/approval-mint.cjs <requestId> <managerStaffId> -> prints the token
*/
require("dotenv/config");
const { createHash, createHmac } = require("crypto");
if (process.env.NODE_ENV === "production") {
console.error("refusing to run in production");
process.exit(2);
}
const [rid, mid] = process.argv.slice(2);
if (!rid || !mid) { console.error("usage: approval-mint.cjs <requestId> <managerStaffId>"); process.exit(2); }
if (!process.env.SESSION_SECRET) { console.error("SESSION_SECRET not set"); process.exit(2); }
// Mirrors lib/approvallink.ts exactly — including the domain separation, which is the point of
// the test: a token signed with the plain session secret must NOT be accepted.
const key = createHash("sha256").update("threadcount:approval:v1:" + process.env.SESSION_SECRET).digest();
const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const ttl = Number(process.argv[4] || 14 * 24 * 60 * 60 * 1000);
const payload = b64url(Buffer.from(JSON.stringify({ rid, mid, exp: Date.now() + ttl })));
const sig = b64url(createHmac("sha256", key).update(payload).digest());
console.log(`${payload}.${sig}`);
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Build the ThreadCount counter app bundle.
#
# bash scripts/build-counter-aab.sh → android/app/build/outputs/bundle/release/*.aab
# bash scripts/build-counter-aab.sh debug → a debug APK, for putting on a phone quickly
#
# The sibling of scripts/build-staff-aab.sh, and deliberately not the same shape, because the two
# apps are not the same shape. This one KEEPS the barcode scanner: native libraries, a CAMERA
# permission and VIBRATE are expected here, and the staff script's refusal to ship any of them would
# be wrong. What this one has instead is the alignment check below, which the staff app cannot fail
# because it ships no native code at all.
#
# **The toolchain is user-space.** JDK 17 and the SDK live under ~/.cache/ca-android — no sudo, no
# system Java, nothing installed outside the home directory.
set -euo pipefail
cd "$(dirname "$0")/.."
MODE=${1:-release}
export JAVA_HOME="${JAVA_HOME:-$HOME/.cache/ca-android/jdk}"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/.cache/ca-android/sdk}"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
[ -x "$JAVA_HOME/bin/java" ] || { echo "FATAL: no JDK at $JAVA_HOME"; exit 1; }
[ -d "$ANDROID_HOME/platforms" ] || { echo "FATAL: no Android SDK at $ANDROID_HOME"; exit 1; }
echo "sdk.dir=$ANDROID_HOME" > android/local.properties
echo "==> syncing the counter shell into android"
# No TC_APP, so capacitor.config.ts resolves to the counter app and android/ is the target.
npx cap sync android
if [ "$MODE" = "debug" ]; then
echo "==> assembling a debug APK"
( cd android && ./gradlew --no-daemon assembleDebug )
find android/app/build/outputs/apk -name "*.apk" -print
exit 0
fi
KEYS="$HOME/threadcount-keys/keystore.properties"
[ -f "$KEYS" ] || echo "NOTE: $KEYS is missing — the bundle will be unsigned."
echo "==> building the release bundle"
( cd android && ./gradlew --no-daemon bundleRelease )
AAB=$(find android/app/build/outputs/bundle/release -name "*.aab" | head -1)
echo "==> $AAB"
echo "==> checks"
if unzip -l "$AAB" | grep -qiE "META-INF/.*\.(RSA|EC|DSA)"; then
echo " ✓ signed"
else
echo " ! UNSIGNED — Play will refuse it"
fi
if unzip -p "$AAB" BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map >/dev/null 2>&1; then
echo " ✓ mapping file bundled — Play can symbolicate a stack trace"
else
echo " ! no mapping file; Play will warn about deobfuscation"
fi
# 16 KB memory pages, the check that actually stops an upload.
#
# Devices from Android 15 can run 16 KB pages and Play rejects a bundle whose 64-bit native
# libraries are only 4 KB-aligned. It is a 64-bit feature: armeabi-v7a and x86 cannot use 16 KB
# pages, so a 32-bit library at 0x1000 is correct and must not be reported as a fault — a blunter
# version of this check cried wolf over the 32-bit libbarhopper_v3.so and would have sent somebody
# hunting a problem that was not there.
#
# The pins in android/variables.gradle are what make this pass; the check is here because a pin can
# be lost in a merge and the failure is otherwise invisible until Play says no.
echo " --- 16 KB alignment (64-bit only, which is what Play checks) ---"
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
BAD=0
for so in $(unzip -l "$AAB" | grep -oE "base/lib/[^ ]*\.so" | sort -u); do
arch=$(basename "$(dirname "$so")")
case "$arch" in arm64-v8a|x86_64) ;; *) continue ;; esac
unzip -o -q -j "$AAB" "$so" -d "$WORK"
al=$(readelf -lW "$WORK/$(basename "$so")" 2>/dev/null | awk '$1=="LOAD"{print $NF}' | sort -u | head -1)
case "$al" in
0x4000|0x10000) printf " ok %-12s %-40s %s\n" "$arch" "$(basename "$so")" "$al" ;;
*) printf " BAD %-12s %-40s %s\n" "$arch" "$(basename "$so")" "$al"; BAD=$((BAD + 1)) ;;
esac
done
if [ "$BAD" -gt 0 ]; then
echo " ! $BAD 64-bit library(ies) are not 16 KB aligned — Play would reject this upload."
echo " Check the CameraX and MLKit pins in android/variables.gradle; the MLKit plugin reads"
echo " all four androidxCamera* names and silently falls back to 1.1.0 if one is missing."
exit 1
fi
echo " ✓ every 64-bit native library is 16 KB aligned"
PERMS=$("$JAVA_HOME/bin/java" -jar "$HOME/.cache/ca-android/bundletool.jar" dump manifest --bundle "$AAB" 2>/dev/null \
| grep -oE '<uses-permission[^>]*android:name="[^"]+"' | grep -oE '"[^"]+"$' | tr -d '"' | sort -u)
echo " permissions: $(echo "$PERMS" | tr '\n' ' ')"
# CAMERA and VIBRATE belong to this app — it scans garments and buzzes on a good read. Anything
# beyond this list is a plugin that got in without anyone deciding it should.
if echo "$PERMS" | grep -qE 'RECORD_AUDIO|ACCESS_FINE_LOCATION|READ_CONTACTS|READ_EXTERNAL_STORAGE'; then
echo " ! this app asks for more than it needs"
exit 1
fi
echo " ✓ nothing beyond what the counter actually uses"
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Build the ThreadCount Staff app bundle.
#
# bash scripts/build-staff-aab.sh → android-staff/app/build/outputs/bundle/release/*.aab
# bash scripts/build-staff-aab.sh debug → a debug APK, for putting on a phone quickly
#
# Two things here are not boilerplate.
#
# **The barcode scanner is stripped after every sync.** Capacitor has no per-app plugin list: it
# scans package.json and wires every installed plugin into whichever native project it is syncing.
# The counter app needs @capacitor-mlkit/barcode-scanning; this one does not, and taking it means
# taking MLKit, CameraX, three native libraries and a CAMERA permission into an app that every
# clinical staff member in a hospital installs. Nothing a wearer does involves scanning, so the
# two generated gradle files and the generated plugin list get the plugin cut out of them again on
# the way past. All three are regenerated by `cap sync`, which is exactly why this lives in the
# build rather than in a one-off edit.
#
# **The toolchain is user-space.** JDK 17 and the SDK live under ~/.cache/ca-android — no sudo,
# no system Java, nothing installed outside the home directory.
set -euo pipefail
cd "$(dirname "$0")/.."
MODE=${1:-release}
export JAVA_HOME="${JAVA_HOME:-$HOME/.cache/ca-android/jdk}"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/.cache/ca-android/sdk}"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
[ -x "$JAVA_HOME/bin/java" ] || { echo "FATAL: no JDK at $JAVA_HOME"; exit 1; }
[ -d "$ANDROID_HOME/platforms" ] || { echo "FATAL: no Android SDK at $ANDROID_HOME"; exit 1; }
echo "sdk.dir=$ANDROID_HOME" > android-staff/local.properties
echo "==> syncing the staff shell into android-staff"
TC_APP=staff npx cap sync android
PLUGINS_JSON=android-staff/app/src/main/assets/capacitor.plugins.json
echo "==> removing the plugins Capacitor just put back"
# Both files carry a "DO NOT EDIT" banner because cap regenerates them; we edit the regenerated
# copy, every time, on purpose. Each plugin contributes two lines to settings.gradle (its include
# and its projectDir) and one to capacitor.build.gradle, and all three name the plugin, so a
# single match per file is enough.
for plugin in capacitor-mlkit-barcode-scanning capacitor-haptics; do
sed -i "/$plugin/d" android-staff/capacitor.settings.gradle
sed -i "/$plugin/d" android-staff/app/capacitor.build.gradle
done
# The third generated file is the list Capacitor reads at startup, and it names the stripped
# plugins by class. Leaving them in it is not harmless: PluginManager.loadPluginClasses() throws
# on the first class that is no longer in the bundle, BridgeActivity catches it and carries on
# with *no* plugins registered at all — so a plugin this app genuinely needs would silently never
# load, and it would look like a bug in the page.
#
# It used to be written out empty, which was correct only while every installed plugin was one
# this app strips. @capacitor/browser broke that: it is the thing that hands a tapped Privacy or
# Terms link to the phone's browser instead of loading it into a shell with no way back, and an
# empty list would have shipped it dead while package.json, the gradle files and the sync output
# all said it was there. So the array is filtered by package name rather than blanked, and a
# plugin added later survives unless it is named in the strip loop above.
python3 - "$PLUGINS_JSON" <<'PY'
import json, sys
p = sys.argv[1]
keep = [e for e in json.load(open(p)) if not any(k in e.get("pkg", "") for k in ("barcode-scanning", "haptics"))]
json.dump(keep, open(p, "w"), indent=2)
open(p, "a").write("\n")
print(" kept in the staff bundle: " + (", ".join(e["pkg"] for e in keep) or "(none)"))
PY
# The scanner brings MLKit, CameraX, three native libraries and a CAMERA permission; haptics
# brings VIBRATE. A wearer scans nothing and this app buzzes at nobody, so both go. The check is
# a hard failure rather than a warning: shipping an app to every nurse in a hospital that asks
# for the camera would be worth stopping a release over.
if grep -qE "mlkit|haptics" android-staff/capacitor.settings.gradle android-staff/app/capacitor.build.gradle \
"$PLUGINS_JSON"; then
echo "FATAL: a stripped plugin is still wired in — refusing to build."
exit 1
fi
if [ "$MODE" = "debug" ]; then
echo "==> assembling a debug APK"
( cd android-staff && ./gradlew --no-daemon assembleDebug )
find android-staff/app/build/outputs/apk -name "*.apk" -print
exit 0
fi
KEYS="$HOME/threadcount-keys/staff-keystore.properties"
[ -f "$KEYS" ] || echo "NOTE: $KEYS is missing — the bundle will be unsigned."
echo "==> building the release bundle"
( cd android-staff && ./gradlew --no-daemon bundleRelease )
AAB=$(find android-staff/app/build/outputs/bundle/release -name "*.aab" | head -1)
echo "==> $AAB"
# Prove the two things Play checks and the one thing this app promises.
echo "==> checks"
if unzip -l "$AAB" | grep -q "\.so$"; then
echo " ! native libraries present — the scanner strip did not work"
exit 1
else
echo " ✓ no native libraries (so no 16 KB alignment problem and no debug symbols to ship)"
fi
if unzip -p "$AAB" BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map >/dev/null 2>&1; then
echo " ✓ mapping file bundled — Play can symbolicate a stack trace"
else
echo " ! no mapping file; Play will warn about deobfuscation"
fi
BT="$HOME/.cache/ca-android/bundletool.jar"
if [ -f "$BT" ]; then
# Only <uses-permission> counts. A permission name also appears as the `android:permission`
# guard on a component — androidx.profileinstaller puts DUMP on its receiver so that only the
# shell can broadcast to it — and matching those made this read as if the app asked for things
# it does not.
PERMS=$("$JAVA_HOME/bin/java" -jar "$BT" dump manifest --bundle "$AAB" 2>/dev/null \
| grep -oE '<uses-permission[^>]*android:name="[^"]+"' \
| grep -oE '"[^"]+"$' | tr -d '"' | sort -u)
echo " permissions: $(echo "$PERMS" | tr '\n' ' ')"
if echo "$PERMS" | grep -qE 'CAMERA|VIBRATE|RECORD_AUDIO|ACCESS_FINE_LOCATION'; then
echo " ! this app asks for more than it needs"
exit 1
fi
# Anything beyond INTERNET and the signature-level permission Capacitor defines for its own
# dynamic receivers is a plugin that got in without anyone deciding it should.
if echo "$PERMS" | grep -vqE 'android.permission.INTERNET|DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'; then
echo " ! an unexpected permission is present"
exit 1
fi
echo " ✓ internet only"
fi
+40
View File
@@ -0,0 +1,40 @@
/* Encoder self-check. EAN-13 and Code 128 are both fully specified, so the right test is against
published reference values — a barcode that looks fine and scans wrong is the worst outcome. */
import { barcodeKind, barcodeSvg, code128Values, ean13CheckDigit, isEan13 } from "../lib/barcode";
let fail = 0;
const ok = (name: string, cond: boolean, got?: unknown) => {
if (cond) console.log(" ok " + name);
else { console.log(" FAIL " + name + (got === undefined ? "" : " got: " + String(got))); fail++; }
};
console.log("EAN-13 check digits");
ok("9312345678907", ean13CheckDigit("931234567890") === 7, ean13CheckDigit("931234567890"));
ok("5901234123457", ean13CheckDigit("590123412345") === 7, ean13CheckDigit("590123412345"));
ok("4006381333931", ean13CheckDigit("400638133393") === 1, ean13CheckDigit("400638133393"));
ok("9357732548036", ean13CheckDigit("935773254803") === 6, ean13CheckDigit("935773254803"));
ok("rejects a bad check digit", !isEan13("5901234123450"));
ok("rejects 12 digits", !isEan13("590123412345"));
ok("accepts a good one", isEan13("5901234123457"));
console.log("EAN-13 symbol");
const svg = barcodeSvg("5901234123457", { module: 1, quiet: 0, text: false });
// 95 modules: 3 guard + 6×7 + 5 centre + 6×7 + 3 guard
ok("95 modules wide", /width="95"/.test(svg), svg.slice(0, 90));
ok("names EAN-13", barcodeKind("5901234123457") === "EAN-13");
console.log("Code 128");
// "Wikipedia" is the standard worked example: START B + 9 chars + check = 11 symbols × 11 modules,
// plus the 13-module stop, and a published check symbol of 88.
const c = barcodeSvg("Wikipedia", { module: 1, quiet: 0, text: false });
ok("134 modules wide", /width="134"/.test(c), c.slice(0, 90));
const v = code128Values("Wikipedia");
ok("check symbol is 88", v[v.length - 1] === 88, v[v.length - 1]);
ok("starts with START B (104)", v[0] === 104, v[0]);
ok("names Code 128 for a short code", barcodeKind("ABC-123") === "Code 128");
ok("names Code 128 for 13 digits that fail the check", barcodeKind("5901234123450") === "Code 128");
ok("empty code makes no svg", barcodeSvg("") === "");
ok("starts and ends with a bar", /<rect x="0" y="0"/.test(c) && c.includes('fill="#201e1d"'));
console.log(fail ? `\n${fail} FAILED` : "\nall barcode checks passed");
process.exit(fail ? 1 : 0);
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Three identity rules, one sweep.
#
# 1. No employer identity in the product: ThreadCount is a SaaS, and a customer's own name must
# never be baked into shipped code, copy, placeholders, sample data or test fixtures.
# 2. No personal addresses: the product sends and receives on its own domain, so a personal one in
# source, config, listing copy or a sample record is a bug wherever it turns up.
# 3. No customer particulars in the demo or the tests, even where nothing spells out whose they
# are. A cost centre written as seven digits in a health service's real issuing range is
# indistinguishable from a genuine one, and a supplier a real customer actually buys from is
# that customer's information however ordinary the name looks. Both were in here: the demo and
# four e2e suites carried 215xxxx cost centres, and e2e.sh ordered from Qualico. Fictional
# substitutes have to be unmistakably fictional, which a bare number never is -- hence the
# demo's RGH- prefix, which cannot collide with a code any finance system would issue.
#
# The whole tree is swept with directory excludes rather than a list of directories to look in. The
# list was the bug: the two Android shells, the native projects, capacitor.config.ts and the Play
# listing notes under docs/ were all outside it, and every one of them ships user-visible copy.
#
# The patterns are assembled from parts and this script excludes itself from the sweep, so the
# guard can't match its own source and report a permanent (and therefore ignored) failure.
set -u
cd "$(dirname "$0")/.." || exit 2
SELF="scripts/$(basename "$0")"
PAT="$(printf 'health[.]qld|q%s|queensland health' 'eii')"
PERSONAL="$(printf 'kylel%s23|@kyle%s[.](id[.])?au' 'price' 'price')"
# A customer's own suppliers and finance codes. The cost-centre arm matches the SHAPE, not a list:
# any bare 215xxxx in source is either copied from a real ledger or looks exactly like it was.
CUSTOMER="$(printf 'q%s|metro south|(^|[^0-9])215[0-9]{4}([^0-9]|$)' 'ualico')"
HITS=$(grep -rniE --binary-files=without-match "$PAT|$PERSONAL|$CUSTOMER" . \
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.next \
--exclude-dir=.claude --exclude-dir=.gradle --exclude-dir=build \
--exclude-dir=coverage --exclude-dir=.photos \
--exclude=package-lock.json 2>/dev/null \
| grep -v "^\./$SELF:")
if [ -n "$HITS" ]; then
echo "Identity that must not ship was found:"
echo "$HITS"
echo
echo "Use a neutral substitute (e.g. \"Metro General Hospital\", \"you@yourhospital.org.au\", \"ACC-2201\")."
echo "Product addresses belong on the product's own domain — never a personal one."
echo "Demo and test cost centres take the fictional hospital's prefix (RGH-4010), never 7 bare digits."
echo "Demo and test suppliers are invented (Northline Workwear, Harbour Embroidery), never a real one."
exit 1
fi
echo "check-identity: clean"
+76
View File
@@ -0,0 +1,76 @@
/* Check the TOTP implementation against RFC 6238's published test vectors.
*
* This is the reason it was worth hand-rolling: the algorithm has an official answer sheet, so the
* code can be *proved* right rather than trusted. The seeds and expected codes below are taken
* from RFC 6238 Appendix B.
*
* npx tsx scripts/check-totp.ts
*/
import { base32Decode, base32Encode, hotp, totp, totpVerify, newTotpSecret, encryptSecret, decryptSecret, newRecoveryCodes, hashRecoveryCode } from "../lib/totp";
let pass = 0, fail = 0;
const ok = (n: string) => { pass++; console.log(" ✓ " + n); };
const bad = (n: string, got: unknown, want: unknown) => { fail++; console.log(`${n} :: got ${got}, want ${want}`); };
const eq = (n: string, got: unknown, want: unknown) => (String(got) === String(want) ? ok(n) : bad(n, got, want));
// RFC 6238 Appendix B. The ASCII seeds are the shared secrets; times are seconds since the epoch.
const SEED_SHA1 = Buffer.from("12345678901234567890", "ascii");
const SEED_SHA256 = Buffer.from("12345678901234567890123456789012", "ascii");
const SEED_SHA512 = Buffer.from("1234567890123456789012345678901234567890123456789012345678901234", "ascii");
const VECTORS: [number, string, string, Buffer, "sha1" | "sha256" | "sha512"][] = [
[59, "94287082", "SHA1", SEED_SHA1, "sha1"],
[59, "46119246", "SHA256", SEED_SHA256, "sha256"],
[59, "90693936", "SHA512", SEED_SHA512, "sha512"],
[1111111109, "07081804", "SHA1", SEED_SHA1, "sha1"],
[1111111111, "14050471", "SHA1", SEED_SHA1, "sha1"],
[1234567890, "89005924", "SHA1", SEED_SHA1, "sha1"],
[2000000000, "69279037", "SHA1", SEED_SHA1, "sha1"],
[20000000000, "65353130", "SHA1", SEED_SHA1, "sha1"],
];
console.log("== RFC 6238 test vectors (8 digits)");
for (const [t, expected, name, seed, algo] of VECTORS) {
eq(`t=${t} ${name}`, totp(seed, t * 1000, 8, algo), expected);
}
console.log("== base32 round-trips");
for (const s of ["", "A", "hello world", "12345678901234567890"]) {
const enc = base32Encode(Buffer.from(s, "utf8"));
eq(`"${s}"`, base32Decode(enc).toString("utf8"), s);
}
console.log("== the 8-byte counter survives past 2^32");
// t=20000000000 above already exercises this; assert the halves explicitly too.
eq("large counter differs from its low 32 bits", hotp(SEED_SHA1, 2 ** 32 + 7) !== hotp(SEED_SHA1, 7), true);
console.log("== verification window");
const secret = newTotpSecret();
const now = Date.now();
const raw = base32Decode(secret);
eq("the current code verifies", totpVerify(secret, totp(raw, now), now), true);
eq("the previous step verifies", totpVerify(secret, totp(raw, now - 30_000), now), true);
eq("the next step verifies", totpVerify(secret, totp(raw, now + 30_000), now), true);
eq("two steps back is refused", totpVerify(secret, totp(raw, now - 90_000), now), false);
eq("two steps forward is refused", totpVerify(secret, totp(raw, now + 90_000), now), false);
eq("a wrong code is refused", totpVerify(secret, "000000", now) && totp(raw, now) !== "000000", false);
eq("a short code is refused", totpVerify(secret, "123", now), false);
eq("an empty code is refused", totpVerify(secret, "", now), false);
console.log("== secret storage");
process.env.SESSION_SECRET ||= "test-secret-for-check-totp";
const enc = encryptSecret(secret);
eq("ciphertext is not the secret", enc.includes(secret), false);
eq("round-trips", decryptSecret(enc), secret);
eq("a tampered tag is rejected", decryptSecret(enc.slice(0, -4) + "AAAA"), null);
eq("rubbish is rejected", decryptSecret("nonsense"), null);
console.log("== recovery codes");
const codes = newRecoveryCodes();
eq("ten codes", codes.length, 10);
eq("all distinct", new Set(codes).size, 10);
eq("hash ignores case and dashes", hashRecoveryCode(codes[0].toLowerCase()), hashRecoveryCode(codes[0]));
eq("different codes hash differently", hashRecoveryCode(codes[0]) === hashRecoveryCode(codes[1]), false);
console.log(`\nPASS=${pass} FAIL=${fail}`);
process.exit(fail ? 1 : 0);
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# Counter app pack: locations (tree, placement, deletion), counting scoped to a location,
# the variance-reason gate, size exchange as one movement, /m auth, and the backup round-trip.
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-app-cj.txt"; K="$T/tc-app-cj2.txt"; rm -f "$J" "$K"
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}"; }
mut2() { curl -s -b "$K" -c "$K" -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" "$(echo "$out" | head -c 300)"; else ok "$name"; fi; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
bk() { curl -s -b "$J" "$B/api/backup"; }
# On hand for one variant, read the way the product reads it: what the shelf row says, less what is
# still out with staff. Takes an item id and a size index.
oh() { bk | py "print([x['opening']+x['adj'] for x in d['stock'] if x['itemId']=='$1' and x['sizeIndex']==$2][0] - sum(i['qty'] for i in d['issues'] if i['itemId']=='$1' and i['sizeIndex']==$2 and not i['returnedDate']))"; }
res() { py "print(d['result']$1)"; }
TS=$(date +%s)
echo "== setup"
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.7.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"App\",\"last\":\"Admin\",\"facility\":\"App Hospital $TS\",\"email\":\"app$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
check "dept" "$(mut dept.save '{"name":"Willow Ward","cc":"RGH-3010"}')" '"ok":true'
check "catalog top" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Scrub Top","sku":"T1","supplier":"Alpha Supply","cost":"30","type":"Scrub top","group":"Registered Nurse","sizes":"S|M|L"}]}')" '"created":1'
check "catalog pant" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Scrub Pant","sku":"P1","supplier":"Alpha Supply","cost":"25","type":"Pants","group":"Registered Nurse","sizes":"S|M|L"}]}')" '"created":1'
check "opening" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"20"},{"sku":"T1","size":"L","opening":"12"},{"sku":"P1","size":"M","opening":"15"}]}')" '"created":3'
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"7","first":"Nina","last":"Nurse","group":"Registered Nurse","dept":"Willow Ward","top":"M","pants":"M"}]}')" '"created":1'
BK=$(bk)
T1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="T1"][0])')
P1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="P1"][0])')
NINA=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
echo "== locations"
R=$(mut location.save '{"name":"Linen Room","kind":"Room"}'); check "room created" "$R" '"id"'; ROOM=$(echo "$R" | res "['id']")
R=$(mut location.save "{\"name\":\"Shelf B\",\"kind\":\"Shelf\",\"parentId\":\"$ROOM\"}"); check "shelf created" "$R" '"id"'; SHELF=$(echo "$R" | res "['id']")
R=$(mut location.save "{\"name\":\"Bay B3\",\"kind\":\"Bay\",\"parentId\":\"$SHELF\"}"); check "bay created" "$R" '"id"'; BAY=$(echo "$R" | res "['id']")
R=$(mut location.save '{"name":"Laundry","kind":"Laundry"}'); check "laundry created" "$R" '"id"'; LAUNDRY=$(echo "$R" | res "['id']")
check "duplicate name refused" "$(mut location.save '{"name":"Shelf B","kind":"Shelf"}')" 'already a location called'
check "unknown parent refused" "$(mut location.save '{"name":"Nowhere","parentId":"nope"}')" 'Unknown parent'
check "self-parent refused" "$(mut location.save "{\"id\":\"$SHELF\",\"name\":\"Shelf B\",\"parentId\":\"$SHELF\"}")" "can't sit inside itself"
check "loop refused (shelf inside its own bay)" "$(mut location.save "{\"id\":\"$SHELF\",\"name\":\"Shelf B\",\"parentId\":\"$BAY\"}")" "can't sit inside itself"
check "bad kind falls back to Shelf" "$(mut location.save '{"name":"Odd One","kind":"Nonsense"}')" '"id"'
check "kind is Shelf" "$(bk | py 'print([l["kind"] for l in d["locations"] if l["name"]=="Odd One"][0])')" 'Shelf'
echo "== placement"
check "place top M on the bay" "$(mut location.place "{\"itemId\":\"$T1\",\"si\":1,\"locationId\":\"$BAY\"}")" '"placed":1'
check "place top L on the shelf" "$(mut location.place "{\"itemId\":\"$T1\",\"si\":2,\"locationId\":\"$SHELF\"}")" '"placed":1'
check "bulk place" "$(mut location.place "{\"lines\":[{\"itemId\":\"$P1\",\"si\":1}],\"locationId\":\"$SHELF\"}")" '"placed":1'
check "unknown location refused" "$(mut location.place "{\"itemId\":\"$T1\",\"si\":0,\"locationId\":\"nope\"}")" 'Unknown location'
check "nothing to place refused" "$(mut location.place "{\"lines\":[],\"locationId\":\"$SHELF\"}")" 'Nothing to place'
check "placement lands in the backup" "$(bk | py "print(len([x for x in d['stock'] if x['locationId']=='$SHELF']))")" '2'
echo "== counting scoped to a location"
# Counting the shelf includes its bay: top M (bay), top L (shelf), pant M (shelf).
check "count the shelf" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"locationId\":\"$SHELF\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20},{\"itemId\":\"$T1\",\"si\":2,\"counted\":12},{\"itemId\":\"$P1\",\"si\":1,\"counted\":15}]}")" '"counted":3'
check "the count records its location" "$(bk | py 'print(d["stocktakes"][0]["locationId"] is not None)')" 'True'
check "a line off the shelf is refused" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"locationId\":\"$LAUNDRY\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20}]}")" "isn't on that shelf"
check "unknown location refused" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"locationId\":\"nope\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20}]}")" 'Unknown location'
check "an unscoped count still works" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20}]}")" '"counted":1'
echo "== the variance-reason gate"
check "default threshold is 5" "$(bk | py 'print(d["facility"]["varianceReason"])')" '^5$'
check "a gap of 5 with no reason is refused" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":15}]}")" 'needs a reason'
check "a gap of 4 goes through" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":16}]}")" '"variances":1'
check "a gap of 5 with a reason goes through" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":11,\"reason\":\"At laundry\"}]}")" '"variances":1'
check "the reason is filed" "$(bk | py 'print([l["reason"] for t in d["stocktakes"] for l in t["lines"] if l["reason"]][0])')" 'At laundry'
check "threshold is configurable" "$(mut settings.update '{"varianceReason":2}')" '"ok":true'
check "the new threshold bites" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":9}]}")" 'needs a reason'
check "threshold floors at 1" "$(mut settings.update '{"varianceReason":0}')" '"ok":true'
check "stored as 1" "$(bk | py 'print(d["facility"]["varianceReason"])')" '^1$'
# Deliberately not 5. restoreBackup falls back to 5 when the file carries no threshold, so a
# facility sitting on 5 reads 5 after a restore whether the field made the round trip or not.
check "off the default threshold" "$(mut settings.update '{"varianceReason":3}')" '"ok":true'
check "on hand is what was counted" "$(bk | py "print([x['opening']+x['adj'] for x in d['stock'] if x['itemId']=='$T1' and x['sizeIndex']==1][0])")" '^11$'
echo "== size exchange"
check "issue an M top" "$(mut issue.create "{\"staffId\":\"$NINA\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
ISSUE=$(bk | py "print([i['id'] for i in d['issues'] if i['itemId']=='$T1' and i['sizeIndex']==1][0])")
OH_L_BEFORE=$(oh "$T1" 2)
check "same size refused" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":1}")" 'Pick a different size'
check "unknown size refused" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":9}")" 'Unknown size'
check "unknown issue refused" "$(mut issue.exchange '{"id":"nope","si":2}')" 'Unknown issue'
check "exchange M for L" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":2}")" '"size":"L"'
check "the old issue came back" "$(bk | py "print([i['returnedCond'] for i in d['issues'] if i['id']=='$ISSUE'][0])")" 'Returned - Good'
check "a new issue went out in L" "$(bk | py "print(len([i for i in d['issues'] if i['itemId']=='$T1' and i['sizeIndex']==2 and not i['returnedDate']]))")" '^1$'
# An exchange is one movement: the L leaves the shelf and the M returns to it. Counting the issue
# rows alone would pass a swap that moved the wrong quantity, or moved the wrong size.
check "one L left the shelf" "$(oh "$T1" 2)" "^$((OH_L_BEFORE-1))$"
check "the M came back on the shelf" "$(oh "$T1" 1)" '^11$'
check "the staff record follows the size" "$(bk | py 'print(d["staff"][0]["top"])')" '^L$'
check "pants size untouched" "$(bk | py 'print(d["staff"][0]["pants"])')" '^M$'
check "exchanging again is refused" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":0}")" 'already been returned'
check "no stock in S refuses the swap" "$(mut issue.exchange "{\"id\":\"$(bk | py "print([i['id'] for i in d['issues'] if i['itemId']=='$T1' and i['sizeIndex']==2 and not i['returnedDate']][0])")\",\"si\":0}")" 'Not enough size S'
echo "== deleting a location"
check "delete the shelf" "$(mut location.delete "{\"id\":\"$SHELF\"}")" '"ok":true'
check "its bay moved up to the room" "$(bk | py "print([l['parentId'] for l in d['locations'] if l['name']=='Bay B3'][0]=='$ROOM')")" 'True'
# Both numbers matter: three stock rows still there, two of them now homeless. Counting only the
# rows still pointing at the dead shelf reads zero even if the delete took the garments with it.
check "what was on it is unplaced" "$(bk | py "print(len(d['stock']), len([x for x in d['stock'] if x['locationId'] is None]))")" '^3 2$'
check "the bay's placement survived" "$(bk | py "print(len([x for x in d['stock'] if x['locationId']=='$BAY']))")" '^1$'
check "unknown location refused" "$(mut location.delete '{"id":"nope"}')" 'Unknown location'
echo "== permissions"
check "invite an issuer" "$(mut users.add "{\"email\":\"iss$TS@example.com\",\"first\":\"Ivy\",\"last\":\"Issuer\",\"role\":\"ISSUER\",\"password\":\"password123\"}")" '"id"'
check "issuer signs in" "$(curl -s -c "$K" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "issuer cannot create a location" "$(mut2 location.save '{"name":"Sneaky Shelf"}')" 'Admin only'
check "issuer cannot delete a location" "$(mut2 location.delete "{\"id\":\"$BAY\"}")" 'Admin only'
check "issuer CAN place a garment" "$(mut2 location.place "{\"itemId\":\"$P1\",\"si\":1,\"locationId\":\"$BAY\"}")" '"placed":1'
check "issuer cannot raise the threshold" "$(mut2 settings.update '{"varianceReason":40}')" 'Admin only'
echo "== the app's own routes"
# /m sends people to the app's own sign-in, not the website's two-pane one.
check "/m needs a session" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$B/m")" '307.*/m/login'
check "/m/login is public" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/login")" '200'
check "/m/signup is public" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/signup")" '200'
check "/m/signed-in needs a session" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/signed-in")" '307'
check "sign-in names the numbered fields" "$(curl -s "$B/m/login")" 'WORK EMAIL\|Work email'
check "sign-in is honest about resets" "$(curl -s "$B/m/login")" 'Forgot password'
check "create account asks for the facility" "$(curl -s "$B/m/signup")" 'Hospital or facility'
no "no NHS wording survived from the handoff" "$(curl -s "$B/m/login"; curl -s "$B/m/signup")" 'nhs.uk\|Trust or hospital'
check "/m/count needs a session" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/count")" '307'
check "/m renders for a signed-in user" "$(curl -s -b "$J" "$B/m")" 'Start a count'
check "/m/count renders" "$(curl -s -b "$J" "$B/m/count")" 'Where are you counting'
check "/m/stock renders" "$(curl -s -b "$J" "$B/m/stock")" 'On hand / par'
check "/m/search renders" "$(curl -s -b "$J" "$B/m/search")" 'Name, staff number, garment or code'
check "/m/settings renders" "$(curl -s -b "$J" "$B/m/settings")" 'Reason required at'
check "/m/variance renders" "$(curl -s -b "$J" "$B/m/variance")" 'What keeps going missing'
check "/m/label renders" "$(curl -s -b "$J" "$B/m/label")" 'Barcode gone'
# The sign-in form is handed a sanitised `next`; Next's own route payload echoes the raw URL, so
# assert on the prop the form actually uses rather than on the page text.
nextprop() { curl -s "$B/auth?next=$1" | grep -o '\\"next\\":\\"[^\\]*' | head -1 | tr -d '\\' | cut -d'"' -f4; }
check "auth ?next=/m/count is honoured" "$(nextprop '/m/count')" '^/m/count$'
check "auth ?next=/app/stock is honoured" "$(nextprop '/app/stock')" '^/app/stock$'
check "auth ?next= off-site falls back to /app" "$(nextprop 'https%3A%2F%2Fevil.example')" '^/app$'
check "auth ?next= protocol-relative falls back to /app" "$(nextprop '%2F%2Fevil.example')" '^/app$'
check "auth ?next= to an unknown path falls back to /app" "$(nextprop '/mischief')" '^/app$'
echo "== labels print only real barcodes"
check "bind a supplier barcode" "$(mut barcode.bind "{\"itemId\":\"$T1\",\"si\":1,\"code\":\"9312345678907\"}")" '"ok":true'
# On a code that is really bound, the only thing left that can turn an anonymous visitor away is
# the session check — an unbound code goes to /app/stock whether the page is guarded or not.
check "/print/labels needs a session" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$B/print/labels?code=9312345678907")" '307.*/auth'
check "label sheet renders the bound code" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=2")" '9312345678907'
check "it is drawn as bars" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=2")" '<svg'
check "an unbound code has no sheet" "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" "$B/print/labels?code=9999999999999")" '307'
check "copies are capped at 24" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=999" | grep -o '<svg' | wc -l | tr -d ' ')" '^24$'
check "one copy means one label" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=1" | grep -o '<svg' | wc -l | tr -d ' ')" '^1$'
echo "== backup round-trip"
SNAP=$(bk)
echo "$SNAP" > "$T/tc-app-backup.json"
check "backup carries locations" "$(echo "$SNAP" | py 'print(len(d["locations"])>=4)')" 'True'
# The live facility is moved off the file's value first, so the restore has to write the threshold
# back rather than be credited for a row that already happened to read right.
check "threshold moved before the restore" "$(mut settings.update '{"varianceReason":7}')" '"ok":true'
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$(cat "$T/tc-app-backup.json")}")" '"ok":true'
NEW=$(bk)
check "locations survived" "$(echo "$NEW" | py 'print(len(d["locations"]))')" "$(echo "$SNAP" | py 'print(len(d["locations"]))')"
# Which parent, not merely that it has one: the restore re-mints every location id and wires the
# tree up in a second pass, so a mis-mapped parent hangs the bay off Laundry and still looks placed.
check "the tree survived" "$(echo "$NEW" | py 'byid={l["id"]:l["name"] for l in d["locations"]}; print(byid.get([l["parentId"] for l in d["locations"] if l["name"]=="Bay B3"][0]))')" '^Linen Room$'
# Which shelf, not how many rows have one. Both garments are on Bay B3, so a restore that pointed
# them at the wrong shelf keeps the count at two and loses the only thing a placement is for.
check "placements survived" "$(echo "$NEW" | py 'byid={l["id"]:l["name"] for l in d["locations"]}; print(", ".join(sorted(byid[x["locationId"]] for x in d["stock"] if x["locationId"])))')" '^Bay B3, Bay B3$'
check "the count's location survived" "$(echo "$NEW" | py 'print(len([t for t in d["stocktakes"] if t["locationId"]]))')" "$(echo "$SNAP" | py 'print(len([t for t in d["stocktakes"] if t["locationId"]]))')"
check "variance reasons survived" "$(echo "$NEW" | py 'print(len([l for t in d["stocktakes"] for l in t["lines"] if l["reason"]]))')" "$(echo "$SNAP" | py 'print(len([l for t in d["stocktakes"] for l in t["lines"] if l["reason"]]))')"
check "the threshold survived" "$(echo "$NEW" | py 'print(d["facility"]["varianceReason"])')" '^3$'
echo "== start fresh clears locations"
check "reset" "$(mut data.reset '{"confirm":"RESET"}')" '"ok":true'
check "no locations left" "$(bk | py 'print(len(d["locations"]))')" '^0$'
rm -f "$T/tc-app-backup.json"
echo
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# The audit trail: that it records, that it names the actor, that it refuses non-admins, that it
# is scoped to one facility, and — the point of the whole design — that it never stores values.
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-aud-cj.txt"; K="$T/tc-aud-cj2.txt"; L="$T/tc-aud-cj3.txt"; rm -f "$J" "$K" "$L"
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}"; }
mut2() { curl -s -b "$K" -c "$K" -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 250)"; fi; }
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 250)"; else ok "$name"; fi; }
log() { curl -s -b "$J" "$B/api/activity"; }
# Signing in and signing up write the actor's name into the same facility's log, so grepping the
# whole response for a person proves nothing about the row under test. These pull one op's row out.
who_of() { log | python3 -c "import sys,json; e=[x for x in json.load(sys.stdin)['events'] if x['op']=='$1']; print(e[0]['who'] if e else 'MISSING')"; }
target_of() { log | python3 -c "import sys,json; e=[x for x in json.load(sys.stdin)['events'] if x['op']=='$1']; print(e[0]['target'] if e else 'MISSING')"; }
TS=$(date +%s)
echo "== setup"
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.14.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Ada\",\"last\":\"Admin\",\"facility\":\"Audit Hospital $TS\",\"email\":\"aud$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
echo "== a change is recorded, with who did it"
check "make a change" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
check "the log has it" "$(log)" 'supplier.add'
check "and names the person" "$(who_of supplier.add)" '^Ada Admin$'
echo "== values are never stored, only identifiers"
check "add a garment with a distinctive name" "$(mut catalog.add '{"item":"Zzyzx Distinctive Gown","sku":"SECRETSKU1","supplier":"Alpha Supply","cost":99,"sizes":["S","M"]}')" '"id"'
no "the garment name is not in the log" "$(log)" 'Zzyzx'
no "the supplier code is not in the log" "$(log)" 'SECRETSKU1'
check "but the operation is" "$(log)" 'catalog.add'
echo "== a staff record's details never reach the log"
check "add a staff member" "$(mut import.rows '{"kind":"staff","rows":[{"num":"991","first":"Wilhelmina","last":"Quixotic","group":"Theatre","dept":"Ward 9Z","top":"M","pants":"M"}]}')" '"created":1'
no "no first name in the log" "$(log)" 'Wilhelmina'
no "no surname in the log" "$(log)" 'Quixotic'
# Both of those are satisfied just as well by an import that was never logged at all — and this is
# the bulk path, the op most likely to be quietly added to the skip list in lib/audit.ts one day.
check "but the operation is" "$(log)" 'import.rows'
echo "== identifiers are kept so a change can be traced"
ITEM=$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='SECRETSKU1'][0])")
# Without this the id is the empty string, every grep for it matches everything, and a broken
# backup or an expired session would be reported as a change that was successfully traced.
[ -n "$ITEM" ] || { fail "found the garment id" "backup returned no id for SECRETSKU1"; echo; echo "PASS=$PASS FAIL=$FAIL"; exit 1; }
check "edit the garment" "$(mut catalog.update "{\"id\":\"$ITEM\",\"cost\":101}")" '"ok":true'
check "the record id is in the log" "$(target_of catalog.update)" "\"id\":\"$ITEM\""
echo "== an issuer cannot read the log"
check "invite an issuer" "$(mut users.add "{\"email\":\"audiss$TS@example.com\",\"first\":\"Ivan\",\"last\":\"Issuer\",\"role\":\"ISSUER\",\"password\":\"password123\"}")" '"id"'
check "issuer signs in" "$(curl -s -c "$K" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"audiss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "issuer is refused the log" "$(curl -s -b "$K" "$B/api/activity")" 'Admin only'
# A title the account did not already have, so this is a real change rather than a write-back of the
# values users.add gave it — and a word worth grepping for afterwards, since it must not be kept.
check "an issuer's own change succeeds" "$(mut2 me.profile '{"title":"Store Issuer Zzq"}')" '"result":{"ok":true}'
OPS=$(log | python3 -c "import sys,json; print(' '.join(x['op'] for x in json.load(sys.stdin)['events']))")
check "an issuer's own change is still recorded" "$OPS" 'me.profile'
check "and attributed to them" "$(who_of me.profile)" '^Ivan Issuer$'
no "and the title they typed is not in the log" "$(log)" 'Zzq'
echo "== the log is scoped to one facility"
check "a second facility signs up" "$(curl -s -c "$L" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.15.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Bob\",\"last\":\"Other\",\"facility\":\"Other Hospital $TS\",\"email\":\"oth$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
# An error body, a cookie jar that was never written and an empty page all contain no "Ada Admin"
# either, so prove the second admin got a readable log of their own before reading anything into
# what is missing from it.
OTHER=$(curl -s -b "$L" "$B/api/activity")
check "the other facility can read its own log" "$OTHER" 'auth:signup'
check "and it names Bob" "$OTHER" 'Bob Other'
no "the other facility cannot see our events" "$OTHER" 'Ada Admin'
echo "== unauthenticated access"
check "signed out is refused" "$(curl -s "$B/api/activity")" 'Not signed in'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Inventory multi-select bulk actions (catalog.bulk): discontinue/reinstate/supplier/group/reorder/price/history-safe delete.
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-bulk-cj.txt"; rm -f "$J"
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}"; }
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)
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\":\"Bulk\",\"last\":\"Admin\",\"facility\":\"Bulk Hospital $TS\",\"email\":\"bulk$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
check "supplier 2" "$(mut supplier.add '{"name":"Beta Supply"}')" '"id"'
R=$(mut import.rows '{"kind":"catalog","rows":[{"item":"Used Top","sku":"U1","supplier":"Alpha Supply","cost":"10","group":"Registered Nurse","sizes":"S|M"},{"item":"Unused Top","sku":"U2","supplier":"Alpha Supply","cost":"20","group":"Registered Nurse","sizes":"S|M"},{"item":"Stocked Top","sku":"U3","supplier":"Alpha Supply","cost":"30","group":"Security","sizes":"S|M"}]}')
check "catalog import 3" "$R" '"created":3'
check "opening on U3" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"U3","size":"S","opening":"5"}]}')" '"created":1'
check "dept" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"A","last":"B","group":"Registered Nurse","dept":"Ward 1","top":"S","pants":"S"}]}')" '"created":1'
BK=$(curl -s -b "$J" "$B/api/backup")
U1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U1"][0])'); U2=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U2"][0])'); U3=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U3"][0])')
ST=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
# Two halves of the history the delete guard leans on. An order-in leaves an OrderLine and nothing
# else; only a shelf issue writes an Issue row, and until one existed here the leading issue.count
# term in catalog.bulk's history sum could have been struck out with this file still green.
check "issue U1 order-in (writes an OrderLine)" "$(mut issue.create "{\"staffId\":\"$ST\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$U1\",\"si\":0,\"qty\":1,\"src\":\"order\"}]}")" '"ordered":1'
check "opening on U1" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"U1","size":"S","opening":"2"}]}')" '"created":1'
check "issue U1 off the shelf" "$(mut issue.create "{\"staffId\":\"$ST\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$U1\",\"si\":0,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
check "the shelf issue is on the record" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["issues"]))')" '^1$'
echo "== bulk actions"
check "empty selection rejected" "$(mut catalog.bulk '{"ids":[],"action":"discontinue"}')" 'Select at least'
check "unknown action rejected" "$(mut catalog.bulk "{\"ids\":[\"$U1\"],\"action\":\"zap\"}")" 'Unknown bulk action'
check "discontinue 2" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\"],\"action\":\"discontinue\"}")" '2 products discontinued'
# Which two, not how many: a where clause that lost its id filter and archived the wrong pair of
# the three still counts to two.
check "archived flag set" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted((i["sku"], i["archived"]) for i in d["items"]))')" "\[('U1', True), ('U2', True), ('U3', False)\]"
check "reinstate 2" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\"],\"action\":\"reinstate\"}")" '2 products reinstated'
# That message is assembled from the selection count, so it comes back whether or not the write
# ran. Read the flag back, or the un-archiving half of the pair is never tested end to end.
check "reinstate cleared archived" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sum(1 for i in d["items"] if i["archived"]))')" '^0$'
check "change supplier" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\",\"$U3\"],\"action\":\"supplier\",\"value\":\"Beta Supply\"}")" 'moved to Beta Supply'
check "supplier applied" "$(curl -s -b "$J" "$B/api/backup" | py 'print(all(i["supplier"]=="Beta Supply" for i in d["items"]))')" 'True'
check "change group" "$(mut catalog.bulk "{\"ids\":[\"$U3\"],\"action\":\"group\",\"value\":\"Kitchen\"}")" 'moved to group Kitchen'
# Group is what scopes a garment on the Issue screen, so a group change that quietly didn't land is
# a real thing to lose — and nothing else in this file ever reads a group back.
check "group applied to U3 only" "$(curl -s -b "$J" "$B/api/backup" | py 'print([i["group"] for i in d["items"] if i["sku"]=="U3"][0], sorted(i["group"] for i in d["items"]))')" "^Kitchen \['Kitchen', 'Registered Nurse', 'Registered Nurse'\]$"
check "set reorder" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U3\"],\"action\":\"reorder\",\"value\":\"7\"}")" 'Reorder level set to 7 on every size of 2 products'
# Filtering the stock rows down to the two products that were selected hid the opposite bug: a
# reorder that ignored `ids` and wrote 7 across the whole catalogue looked identical. U2 is the one
# unselected product, so name every row that exists — U2 has to have none.
check "reorder on every size of the selected products only" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted((next(i["sku"] for i in d["items"] if i["id"]==s["itemId"]), s["reorder"]) for s in d["stock"]))')" "\[('U1', 7), ('U1', 7), ('U3', 7), ('U3', 7)\]"
check "bad price rejected" "$(mut catalog.bulk "{\"ids\":[\"$U1\"],\"action\":\"price\",\"value\":\"abc\"}")" 'Enter a price'
check "price +10%" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\"],\"action\":\"price\",\"value\":\"+10%\"}")" 'Prices updated on 2 products (+10%)'
check "price pct applied 11/22" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted(i["cost"] for i in d["items"]))')" '\[11.\?0\?, 22.\?0\?, 30.\?0\?\]'
check "price absolute" "$(mut catalog.bulk "{\"ids\":[\"$U3\"],\"action\":\"price\",\"value\":\"\$25.50\"}")" 'Prices updated on 1 product\.'
check "abs price applied" "$(curl -s -b "$J" "$B/api/backup" | py 'print([i["cost"] for i in d["items"] if i["sku"]=="U3"][0])')" '^25.5$'
R=$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\",\"$U3\"],\"action\":\"delete\"}")
check "delete: 1 deleted, 2 discontinued" "$R" '"deleted":1,"discontinued":2'
check "delete message" "$R" '1 product deleted · 2 products had history or stock on hand'
check "unused item gone, others archived" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted((i["sku"], i["archived"]) for i in d["items"]))')" "\[('U1', True), ('U3', True)\]"
echo "== a garment a ward has asked for"
# The kind of history the fixture above never covers, and the one the code warns about by name:
# RequestLine cascades from the garment, so bulk-deleting one a ward is waiting on strips the line
# out from under a live request and leaves the manager an ask with nothing on it. catalog.delete
# counts request lines for exactly that reason; catalog.bulk's `used` sum leaves the term out.
check "a fourth product" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Asked-for Top","sku":"U4","supplier":"Alpha Supply","cost":"40","group":"Registered Nurse","sizes":"S|M"}]}')" '"created":1'
check "somebody to approve for her" "$(mut import.rows '{"kind":"staff","rows":[{"num":"2","first":"M","last":"G","group":"Registered Nurse","dept":"Ward 1","top":"M","pants":"M"}]}')" '"created":1'
BK2=$(curl -s -b "$J" "$B/api/backup")
U4=$(echo "$BK2" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U4"][0])'); MG=$(echo "$BK2" | py 'print([s["id"] for s in d["staff"] if s["num"]=="2"][0])')
check "she reports to them" "$(mut staff.patch "{\"id\":\"$ST\",\"managerId\":\"$MG\"}")" '"ok":true'
check "the counter raises a request for U4" "$(mut request.raise "{\"staffId\":\"$ST\",\"lines\":[{\"itemId\":\"$U4\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\"}")" '"code"'
check "delete: a request line counts as history" "$(mut catalog.bulk "{\"ids\":[\"$U4\"],\"action\":\"delete\"}")" '"deleted":0,"discontinued":1'
check "the request still has its garment on it" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["requests"][0]["lines"]))')" '^1$'
echo "== issuer gate"
check "add issuer" "$(mut users.add "{\"email\":\"bi$TS@example.com\",\"password\":\"password123\",\"first\":\"I\",\"last\":\"S\",\"role\":\"ISSUER\"}")" '"id"'
J2="$T/tc-bulk-cj2.txt"; rm -f "$J2"
curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"bi$TS@example.com\",\"password\":\"password123\"}" >/dev/null
check "issuer cannot bulk" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"catalog.bulk\",\"payload\":{\"ids\":[\"$U1\"],\"action\":\"reinstate\"}}")" 'Admin only'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# The catalogue as the phone drives it: create a garment, edit its card, add a size, set par,
# bind a barcode, archive and reinstate — plus the guards that stop a non-admin doing any of it
# and stop size history being rewritten.
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-cat-cj.txt"; K="$T/tc-cat-cj2.txt"; rm -f "$J" "$K"
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}"; }
mut2() { curl -s -b "$K" -c "$K" -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"; }
bk() { curl -s -b "$J" "$B/api/backup"; }
res() { py "print(d['result']$1)"; }
TS=$(date +%s)
echo "== setup"
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.11.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Cat\",\"last\":\"Admin\",\"facility\":\"Cat Hospital $TS\",\"email\":\"cat$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
echo "== creating a garment from the phone"
check "a name is required" "$(mut catalog.add '{"item":"","sizes":["S"]}')" 'Item name required'
check "at least one size is required" "$(mut catalog.add '{"item":"Theatre Gown","sizes":[]}')" 'At least one size'
R=$(mut catalog.add '{"item":"Theatre Gown","type":"Gown","group":"Theatre","supplier":"Alpha Supply","sku":"TG1","cost":41.5,"sizes":["S","M","L"]}')
check "garment created" "$R" '"id"'
ID=$(echo "$R" | res "['id']")
# The order of the run is the assertion, not its length: every "si":N payload below addresses a
# size by its position, so a run that came back as M,S,L would still be three sizes and would still
# let every later check read back the index it just wrote.
check "the three sizes are in the order they were typed" "$(bk | py "print([i['sizes'] for i in d['items'] if i['id']=='$ID'][0])")" "^\['S', 'M', 'L'\]"
check "the supplier was created with it" "$(bk | py "print(any(s['name']=='Alpha Supply' for s in d['suppliers']))")" 'True'
echo "== editing the product card"
check "rename and reprice" "$(mut catalog.update "{\"id\":\"$ID\",\"item\":\"Theatre Gown (Blue)\",\"cost\":44}")" '"ok":true'
check "the name stuck" "$(bk | py "print([i['item'] for i in d['items'] if i['id']=='$ID'][0])")" 'Theatre Gown (Blue)'
check "the cost stuck" "$(bk | py "print([i['cost'] for i in d['items'] if i['id']=='$ID'][0])")" '^44'
check "a negative cost is refused" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":-1}")" 'Invalid cost'
echo "== sizes"
check "add a size" "$(mut catalog.update "{\"id\":\"$ID\",\"addSize\":\"XL\"}")" '"ok":true'
# addSize is the append path, and the item has no history yet, so nothing in the product stops it
# prepending or inserting. A count of four would not notice; the wrong run only surfaces later as a
# baffling failure in "but appending still works".
check "XL is appended to the end" "$(bk | py "print([i['sizes'] for i in d['items'] if i['id']=='$ID'][0])")" "^\['S', 'M', 'L', 'XL'\]"
check "a duplicate size is refused" "$(mut catalog.update "{\"id\":\"$ID\",\"addSize\":\"XL\"}")" 'already on the item'
echo "== par and barcode, the two things you set at a shelf"
check "set par on size M" "$(mut stock.reorder "{\"itemId\":\"$ID\",\"si\":1,\"reorder\":6}")" '"ok":true'
check "par stored" "$(bk | py "print([x['reorder'] for x in d['stock'] if x['itemId']=='$ID' and x['sizeIndex']==1][0])")" '^6$'
check "bind a barcode to size M" "$(mut barcode.bind "{\"code\":\"CAT$TS\",\"itemId\":\"$ID\",\"si\":1}")" '"ok":true'
check "barcode points at the size" "$(bk | py "print([b['sizeIndex'] for b in d['barcodes'] if b['code']=='CAT$TS'][0])")" '^1$'
check "the same code can't be bound twice" "$(mut barcode.bind "{\"code\":\"CAT$TS\",\"itemId\":\"$ID\",\"si\":2}")" 'is already on'
# bind ends in an upsert, so a refusal that never happened moves the label to size L rather than
# leaving it where it was. Reading it back is the half that proves the shelf is still right.
check "and it stayed on size M" "$(bk | py "print([b['sizeIndex'] for b in d['barcodes'] if b['code']=='CAT$TS'][0])")" '^1$'
echo "== history protects the size order"
# The import matches on sku, then resolves the size by name to a position. "created" is a
# processed-row counter — it says 1 whether the row landed on M or on L — so the quantity is read
# back off the position instead. (The history the next check leans on is already there: the par
# level and the barcode above both count towards it.)
check "an opening import finds the size by name" "$(mut import.rows "{\"kind\":\"opening\",\"rows\":[{\"sku\":\"TG1\",\"size\":\"M\",\"opening\":\"5\"}]}")" '"errors":\[\]'
check "opening stock lands on size M" "$(bk | py "print([x['opening'] for x in d['stock'] if x['itemId']=='$ID' and x['sizeIndex']==1][0])")" '^5$'
check "sizes can't be reordered once there is history" "$(mut catalog.update "{\"id\":\"$ID\",\"sizes\":[\"M\",\"S\",\"L\",\"XL\"]}")" "can't be removed or reordered"
check "but appending still works" "$(mut catalog.update "{\"id\":\"$ID\",\"sizes\":[\"S\",\"M\",\"L\",\"XL\",\"2XL\"]}")" '"ok":true'
echo "== archive and reinstate"
check "archive" "$(mut catalog.update "{\"id\":\"$ID\",\"archived\":true}")" '"ok":true'
check "it reads as archived" "$(bk | py "print([i['archived'] for i in d['items'] if i['id']=='$ID'][0])")" 'True'
check "reinstate" "$(mut catalog.update "{\"id\":\"$ID\",\"archived\":false}")" '"ok":true'
# A garment that archives but never comes back is the failure this section exists to catch, and
# "ok":true is what catalog.update says to any patch it accepted — including one that ignored this.
check "it reads as live again" "$(bk | py "print([i['archived'] for i in d['items'] if i['id']=='$ID'][0])")" '^False$'
echo "== an issuer can look but not touch"
check "invite an issuer" "$(mut users.add "{\"email\":\"catiss$TS@example.com\",\"first\":\"Ines\",\"last\":\"Issuer\",\"role\":\"ISSUER\",\"password\":\"password123\"}")" '"id"'
check "issuer signs in" "$(curl -s -c "$K" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"catiss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "issuer cannot create a garment" "$(mut2 catalog.add '{"item":"Sneaky Coat","sizes":["M"]}')" 'Admin only'
check "issuer cannot edit the card" "$(mut2 catalog.update "{\"id\":\"$ID\",\"item\":\"Renamed\"}")" 'Admin only'
check "issuer cannot archive" "$(mut2 catalog.update "{\"id\":\"$ID\",\"archived\":true}")" 'Admin only'
# Every field the issuer's three attempts reached for: the name, and the archive flag — a garment
# left discontinued by a broken guard is invisible on the catalogue screen, and the name alone
# would never see it.
check "the garment is untouched" "$(bk | py "print([[i['item'], i['archived']] for i in d['items'] if i['id']=='$ID'][0])")" "^\['Theatre Gown (Blue)', False\]"
check "and nothing was created" "$(bk | py "print(any(i['item']=='Sneaky Coat' for i in d['items']))")" '^False$'
echo "== the phone routes exist and need a session"
check "/m/catalogue needs a session" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$B/m/catalogue")" '307.*/m/login'
check "/m/catalogue renders for a signed-in admin" "$(curl -s -b "$J" "$B/m/catalogue")" 'Catalogue'
check "the product card renders" "$(curl -s -b "$J" "$B/m/catalogue/$ID")" 'Sizes'
check "the new-garment form renders" "$(curl -s -b "$J" "$B/m/catalogue/new")" 'New garment'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# The Community edition: the same server started with EDITION=community.
#
# EDITION=community npm run dev -- -p 3112 # in another shell
# BASE=http://127.0.0.1:3112 bash scripts/e2e-community.sh
#
# What it proves: the front door is the sign-in, not the website; sign-up works with no Turnstile;
# a facility has no staff ceiling and no plan tab (the snapshot says so); the demo is off; and
# nothing on a page loads the analytics tracker.
set -u
B=${BASE:-http://127.0.0.1:3112}
T=${TMP:-/tmp}
J="$T/tc-community.txt"; rm -f "$J"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1 :: $(echo "$2" | head -c 240)"; }
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$out"; fi; }
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$out"; else ok "$name"; fi; }
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
where(){ curl -s -o /dev/null -w '%{redirect_url}' "$@"; }
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
rows() { python3 -c "import json,sys; n=int(sys.argv[1]); print(json.dumps({'kind':'staff','rows':[{'num':str(7000+i),'first':'C%d'%i,'last':'Row','group':'Kitchen','dept':'Kitchen'} for i in range(1,n+1)]}))" "$1"; }
. "$(dirname "$0")/e2e-preflight.sh"
TS=$(date +%s)
echo "== the front door"
check "/ redirects" "$(where "$B/")" '/auth'
check "health answers" "$(code "$B/api/health")" '200'
R=$(curl -s "$B/api/app-info")
check "app-info names the product" "$R" '"product":"threadcount"'
check " and the edition" "$R" '"edition":"community"'
check "the demo is off" "$(code "$B/api/auth/demo?as=admin")" '404'
no "the sign-in page loads no tracker" "$(curl -s "$B/auth")" 'pulse.js'
no " and asks nothing about plans" "$(curl -s "$B/auth?mode=signup")" 'Hosted Facility'
echo "== a facility with no ceiling and no plan"
R=$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.4.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Com\",\"last\":\"Munity\",\"facility\":\"Community $TS\",\"email\":\"community-$TS@example.com\",\"password\":\"password123\",\"plan\":\"hosted_small\"}")
check "signup with no Turnstile token" "$R" '"ok":true'
check " groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check " 61 staff import with no ceiling" "$(mut import.rows "$(rows 61)")" '"created":61'
check " a write is never read-only" "$(mut dept.save '{"name":"Willow","cc":"CC-1"}')" '"ok":true'
R=$(curl -s -b "$J" "$B/app")
check " the app opens" "$(code -b "$J" "$B/app")" '200'
no " with no plan tab" "$R" '"live":true'
check " and the snapshot calls it Community" "$R" 'Community'
no " no tracker on an app screen either" "$R" 'pulse.js'
echo; echo "community: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Cost history: that a price is remembered, that it records what it moved from, and that a change
# which isn't a change doesn't produce a row.
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-cost-cj.txt"; rm -f "$J"
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}"; }
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 250)"; fi; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
bk() { curl -s -b "$J" "$B/api/backup"; }
costs(){ curl -s -b "$J" "$B/m/catalogue" > /dev/null; bk; }
TS=$(date +%s)
echo "== setup"
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.16.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Cora\",\"last\":\"Cost\",\"facility\":\"Cost Hospital $TS\",\"email\":\"cost$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
R=$(mut catalog.add '{"item":"Priced Gown","sku":"PG1","supplier":"Alpha Supply","cost":30,"sizes":["S","M"]}')
check "garment created" "$R" '"id"'
ID=$(echo "$R" | py "print(d['result']['id'])")
# The whole history for this garment, oldest first, each row as "cost<-previous". Counting rows can
# only ever say that a row appeared; the figures in it are what finance actually reads back.
hist() { bk | py "print(' | '.join('%g<-%s' % (c['cost'], 'none' if c['previous'] is None else '%g' % c['previous']) for c in d.get('costs',[]) if c['itemId']=='$ID'))"; }
itemcost() { bk | py "print('%g' % [i['cost'] for i in d['items'] if i['id']=='$ID'][0])"; }
echo "== the opening price is remembered"
check "the price landed on the garment" "$(itemcost)" '^30$'
# previous is null rather than a figure, which is what makes this row the start of the history
# instead of an edit somebody made on day one.
check "one opening row, at the opening price" "$(hist)" '^30<-none$'
echo "== a change records what it moved from"
check "raise the price" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":34.5}")" '"ok":true'
N1=$(bk | py "print(len([c for c in d.get('costs',[]) if c['itemId']=='$ID']))")
check "two rows now" "$N1" '^2$'
check "the latest is the new price" "$(bk | py "print([c['cost'] for c in d.get('costs',[]) if c['itemId']=='$ID'][-1])")" '34.5'
check "and remembers the old one" "$(bk | py "print([c['previous'] for c in d.get('costs',[]) if c['itemId']=='$ID'][-1])")" '^30'
check "and who changed it" "$(bk | py "print([c['byName'] for c in d.get('costs',[]) if c['itemId']=='$ID'][-1])")" 'Cora Cost'
echo "== setting the same price again is not a change"
check "save the identical price" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":34.5}")" '"ok":true'
check "no row for the identical price" "$(hist)" '^30<-none | 34\.5<-30$'
echo "== editing other fields leaves the history alone"
check "rename it" "$(mut catalog.update "{\"id\":\"$ID\",\"item\":\"Priced Gown (Blue)\"}")" '"ok":true'
check "no row for a rename" "$(hist)" '^30<-none | 34\.5<-30$'
echo "== a price drop is recorded as a drop"
check "lower the price" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":29}")" '"ok":true'
check "a third row, 34.5 down to 29" "$(hist)" '^30<-none | 34\.5<-30 | 29<-34\.5$'
echo "== a negative price is refused on an edit"
check "negative refused" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":-5}")" 'Invalid cost'
# Refused has to mean nothing was written. An error message sent after the update had already gone
# through would read exactly the same from out here, with the garment left priced at -5.
check "the price is untouched" "$(itemcost)" '^29$'
check "and the history is untouched" "$(hist)" '^30<-none | 34\.5<-30 | 29<-34\.5$'
echo "== a negative price on creation is clamped, not refused"
# catalog.add takes Math.max(0, cost) where catalog.update throws, so the two doors disagree about
# the same bad figure. Pinned here so that whichever way it is settled, it is settled deliberately.
R2=$(mut catalog.add '{"item":"Odd Gown","sku":"OG1","supplier":"Alpha Supply","cost":-5,"sizes":["S"]}')
check "creation accepted" "$R2" '"id"'
ID=$(echo "$R2" | py "print(d['result']['id'])")
check "the price is zero, not negative" "$(itemcost)" '^0$'
check "and a zero price opens no history" "$(hist)" '^$'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+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 ]
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Mobile pack: photo store + attachments (approval / receipt / return), delivery rounds (signature + proof), photo route auth, backup round-trip, rounds page.
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-mob-cj.txt"; rm -f "$J"
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}"; }
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"; }
bk() { curl -s -b "$J" "$B/api/backup"; }
PNG='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
JPG='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA='
TS=$(date +%s)
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\":\"Mob\",\"last\":\"Admin\",\"facility\":\"Mobile Hospital $TS\",\"email\":\"mob$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
check "dept" "$(mut dept.save '{"name":"Willow Ward","cc":"RGH-3010"}')" '"ok":true'
check "catalog" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"RN Scrub Top","sku":"T1","supplier":"Alpha Supply","cost":"30","group":"Registered Nurse","sizes":"S|M"}]}')" '"created":1'
check "opening accepted" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"5"}]}')" '"created":1'
# The response only counts rows walked — it still says {"created":1} when the quantity parsed as 0
# and nothing reached the shelf. Everything below issues from this stock, so check the shelf itself.
check "opening 5 on the shelf" "$(bk | py 'print(([s["opening"] for s in d["stock"] if s["sizeIndex"]==1] or [0])[0])')" '^5$'
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Nina","last":"Nurse","phone":"0400 111 222","group":"Registered Nurse","dept":"Willow Ward","top":"M","pants":"M"}]}')" '"created":1'
BK=$(bk); T1=$(echo "$BK" | py 'print(d["items"][0]["id"])'); NINA=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
echo "== photo store"
check "bad photo rejected" "$(mut photo.put '{"kind":"approval","data":"data:text/html;base64,PGI+"}')" 'JPEG or PNG'
check "svg rejected" "$(mut photo.put '{"kind":"approval","data":"data:image/svg+xml;base64,PHN2Zz4="}')" 'JPEG or PNG'
R=$(mut photo.put "{\"kind\":\"approval\",\"data\":\"$JPG\"}"); check "jpeg stored" "$R" '"id"'; PH1=$(echo "$R" | py 'print(d["result"]["id"])')
R=$(mut photo.put "{\"kind\":\"sig\",\"data\":\"$PNG\"}"); check "png stored" "$R" '"id"'; PH2=$(echo "$R" | py 'print(d["result"]["id"])')
check "photo route serves jpeg" "$(curl -s -b "$J" -o /dev/null -w '%{http_code} %{content_type}' "$B/api/photo/$PH1")" '200 image/jpeg'
check "photo route serves png" "$(curl -s -b "$J" -o /dev/null -w '%{http_code} %{content_type}' "$B/api/photo/$PH2")" '200 image/png'
check "photo route needs auth" "$(curl -s -o /dev/null -w '%{http_code}' "$B/api/photo/$PH1")" '401'
echo "== attachments"
R=$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"2\",\"photoId\":\"$PH1\"}"); check "approval with form photo" "$R" '"id"'
check "approval photoId in backup" "$(bk | py 'print(d["approvals"][0]["photoId"]=="'$PH1'")')" 'True'
# A real photo belonging to another facility, not an invented id: an id that exists nowhere is
# still refused by an ownPhoto with the facility filter torn out, so only a genuine cross-facility
# id can show that the ownership rule is still there.
J2="$T/tc-mob-cj2.txt"; rm -f "$J2"
check "second facility signup" "$(curl -s -c "$J2" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Other\",\"last\":\"Admin\",\"facility\":\"Other Hospital $TS\",\"email\":\"oth$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
R=$(curl -s -b "$J2" -c "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"photo.put\",\"payload\":{\"kind\":\"approval\",\"data\":\"$JPG\"}}"); check "second facility photo stored" "$R" '"id"'; OTHER_PH=$(echo "$R" | py 'print(d["result"]["id"])')
check "approval with foreign photo rejected" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"1\",\"photoId\":\"$OTHER_PH\"}")" 'Photo not found'
check "approval with unknown photo rejected" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"1\",\"photoId\":\"nope123\"}")" 'Photo not found'
check "issue order-in" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"order\"}]}")" '"ordered":1'
ORD=$(bk | py 'print([o["id"] for o in d["orders"] if o["orderFor"]=="Staff Member"][0])'); LID=$(bk | py 'print([o for o in d["orders"] if o["orderFor"]=="Staff Member"][0]["lines"][0]["id"])')
R=$(mut photo.put "{\"kind\":\"receipt\",\"data\":\"$JPG\"}"); PH3=$(echo "$R" | py 'print(d["result"]["id"])')
R=$(mut order.receive "{\"id\":\"$ORD\",\"invoice\":\"INV-1\",\"photoId\":\"$PH3\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1,\"dest\":\"pickup\"}]}"); check "receive with invoice photo accepted" "$R" '"ok":true'
# "ok":true is the envelope every op that doesn't throw returns, so it says nothing about where the
# garments went. Only a pending pickup row proves the delivery was routed to the wearer instead of
# quietly landing on the shelf — which is what the whole rounds section below then relies on.
check "receive with invoice photo → pickup" "$(bk | py 'print(len([p for p in d["pickups"] if p["orderId"]=="'$ORD'" and not p["pickedUp"]]))')" '^1$'
check "receipt photoId stored" "$(bk | py 'print([o for o in d["orders"] if o["id"]=="'$ORD'"][0]["receipts"][0]["photoId"]=="'$PH3'")')" 'True'
check "issue from stock" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
ISS=$(bk | py 'print([i["id"] for i in d["issues"] if not i["direct"]][0])')
R=$(mut photo.put "{\"kind\":\"return\",\"data\":\"$JPG\"}"); PH4=$(echo "$R" | py 'print(d["result"]["id"])')
check "return with damage photo" "$(mut issue.return "{\"id\":\"$ISS\",\"cond\":\"Returned - Damaged\",\"photoId\":\"$PH4\"}")" '"ok":true'
check "return photoId stored" "$(bk | py 'print([i for i in d["issues"] if i["id"]=="'$ISS'"][0]["returnPhotoId"]=="'$PH4'")')" 'True'
echo "== delivery rounds"
PU=$(bk | py 'print([p["id"] for p in d["pickups"] if not p["pickedUp"]][0])')
# The ward name sits in every /app page's HTML whether this list renders or not — the layout
# serialises the whole snapshot into the flight payload — so match the per-row button, which only
# a rendered pending delivery emits.
check "rounds page renders ward row" "$(curl -s -b "$J" "$B/app/rounds")" 'Sign for the delivery to Nina Nurse'
check "rounds page tel link" "$(curl -s -b "$J" "$B/app/rounds")" 'tel:0400111222'
check "dashboard tel link" "$(curl -s -b "$J" "$B/app")" 'tel:0400111222'
R=$(mut photo.put "{\"kind\":\"proof\",\"data\":\"$JPG\"}"); PH5=$(echo "$R" | py 'print(d["result"]["id"])')
check "deliver with signature + proof" "$(mut pickup.deliver "{\"id\":\"$PU\",\"deliveredTo\":\"J. Barnes, Ward Manager\",\"sigId\":\"$PH2\",\"proofId\":\"$PH5\"}")" '"ok":true'
check "pickup marked delivered round" "$(bk | py 'p=[p for p in d["pickups"] if p["id"]=="'$PU'"][0]; print(p["deliveredRound"], p["deliveredTo"], p["sigId"]=="'$PH2'", p["proofId"]=="'$PH5'", bool(p["pickedUp"]))')" 'True J. Barnes, Ward Manager True True True'
check "direct issue created + receipt signed" "$(bk | py 'i=[i for i in d["issues"] if i["direct"]][0]; print(i["receipt"], i["orderCode"]!="")')" 'True True'
check "deliver twice refused" "$(mut pickup.deliver "{\"id\":\"$PU\",\"deliveredTo\":\"x\"}")" 'Already handed over'
check "rounds page now empty" "$(curl -s -b "$J" "$B/app/rounds")" 'Nothing waiting for delivery'
echo "== backup round-trip keeps photos"
BKF=$(bk)
# A row count alone passes on a backup full of empty strings, which is exactly what the move to
# disk storage broke: a photo whose file can't be read is exported as data:"" and still counted.
check "backup has photos" "$(echo "$BKF" | py 'print(len(d["photos"]), sum(1 for p in d["photos"] if str(p.get("data") or "").startswith("data:image/")))')" '^5 5$'
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$BKF}")" '"ok":true\|restored'
check "photos restored" "$(bk | py 'print(len(d["photos"]))')" '^5$'
NEWAP=$(bk | py 'print(d["approvals"][0]["photoId"] or "")'); check "approval photo remapped + served" "$(curl -s -b "$J" -o /dev/null -w '%{http_code}' "$B/api/photo/$NEWAP")" '200'
check "pickup sig/proof remapped" "$(bk | py 'p=d["pickups"][0]; print(bool(p["sigId"]) and bool(p["proofId"]) and p["deliveredTo"])')" 'J. Barnes, Ward Manager'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Photos on disk: stored as files rather than base64, served only to the owning facility, and
# still whole in a backup.
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-ph-cj.txt"; K="$T/tc-ph-cj2.txt"; rm -f "$J" "$K"
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}"; }
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 200)"; fi; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
# A real 1x1 JPEG, so what is written is a genuine image rather than random bytes.
JPEG_B64=$(python3 -c "
import base64
# smallest valid JPEG
b = bytes.fromhex('ffd8ffe000104a46494600010100000100010000ffdb004300ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc00011080001000101011100ffc40014000100000000000000000000000000000009ffc40014100100000000000000000000000000000000ffda0008010100003f0054df')
print(base64.b64encode(b).decode())
")
DATAURL="data:image/jpeg;base64,$JPEG_B64"
TS=$(date +%s)
echo "== setup"
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.17.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Pia\",\"last\":\"Photo\",\"facility\":\"Photo Hospital $TS\",\"email\":\"ph$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
echo "== storing"
R=$(mut photo.put "{\"kind\":\"sig\",\"data\":\"$DATAURL\"}")
check "photo accepted" "$R" '"id"'
PID=$(echo "$R" | py "print(d['result']['id'])")
echo "== it is on disk, not in the database"
ROW=$(curl -s -b "$J" "$B/api/backup" | py "
ph = [x for x in d['photos'] if x['id']=='$PID'][0]
print('path=' + (ph.get('path') or '') + ' mime=' + (ph.get('mime') or '') + ' bytes=' + str(ph.get('bytes')))
")
# Named after the row id we generated, never after anything the request supplied — the trailing
# space keeps the match inside the path field, so an empty path cannot borrow the slash in the mime.
check "the row records a file path" "$ROW" "path=[A-Za-z0-9_-]\{1,\}/$PID\.jpg "
check "and the mime type" "$ROW" 'mime=image/jpeg'
# The decoded image is 143 bytes (192 as base64). Pinning the number is what catches bytes ever
# recording the encoded length instead of the real size of the file on disk.
check "and the size" "$ROW" 'bytes=143$'
echo "== serving"
CT=$(curl -s -b "$J" -o /dev/null -w '%{content_type} %{http_code}' "$B/api/photo/$PID")
check "served as a jpeg" "$CT" 'image/jpeg 200'
check "signed out is refused" "$(curl -s -o /dev/null -w '%{http_code}' "$B/api/photo/$PID")" '401'
echo "== another facility cannot read it"
check "second facility signs up" "$(curl -s -c "$K" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.18.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Oth\",\"last\":\"Er\",\"facility\":\"Other Photo $TS\",\"email\":\"pho$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "their request 404s" "$(curl -s -b "$K" -o /dev/null -w '%{http_code}' "$B/api/photo/$PID")" '404'
echo "== the backup is still whole"
# Byte-identical to what went in, not merely a data URL with the right prefix: the prefix is built
# from the mime column, so a truncated or wrong file on disk still produces one.
check "backup embeds the image" "$(curl -s -b "$J" "$B/api/backup" | py "
ph = [x for x in d['photos'] if x['id']=='$PID'][0]
print('same' if ph.get('data') == '$DATAURL' else 'DIFFERENT')
")" 'same'
echo "== rubbish is refused"
check "not an image" "$(mut photo.put '{"kind":"sig","data":"data:text/html;base64,PGgxPmhp"}')" 'JPEG or PNG'
check "empty" "$(mut photo.put '{"kind":"sig","data":""}')" 'JPEG or PNG'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Shared preflight and setup for the e2e runners. Sourced by each of them, never run on its own.
#
# Every suite in here starts by creating an account or signing one in, and both of those doors are
# behind Cloudflare Turnstile. In production mode the check fails *closed*: with no TURNSTILE_SECRET
# the server refuses to start at all, and if it is running with one but no reachable Cloudflare, the
# auth routes answer "Security check unavailable". Either way a run against `next start` on a box
# with no Cloudflare keys fails on its first line, with an error about a security check rather than
# about the thing being tested — so it is caught here once, with the way out.
#
# The way out is TURNSTILE_OPTIONAL=1 on the *server* being tested. It is a local smoke-test switch
# only; scripts/deploy.sh refuses to deploy a secrets file that carries it.
#
# `next dev` needs none of this: outside production verifyTurnstile is advisory and skips.
e2e_preflight() {
local base=${1:?base url} body
body=$(curl -s --max-time 10 -X POST "$base/api/auth/login" \
-H 'content-type: application/json' \
-d '{"email":"e2e-preflight@example.com","password":"not-a-password"}' 2>/dev/null || true)
if [ -z "$body" ]; then
echo "FATAL: nothing answering at $base."
echo " Start the server first — \`npm run dev -- -p 3111\`, or \`next start\` with TURNSTILE_OPTIONAL=1."
exit 1
fi
case "$body" in
*"Security check unavailable"*|*"complete the security check"*)
echo "FATAL: $base is in production mode and Turnstile is refusing every auth route."
echo " Restart that server with TURNSTILE_OPTIONAL=1 (local smoke tests only), or run against \`next dev\`."
exit 1 ;;
esac
}
# A facility's staff groups, named the way a coordinator names them on the first day.
#
# A new facility starts with none, and with no group on the FTE table or the starting kit, because any
# list the product shipped would be one employer's job titles handed to every other employer. So a
# suite that signs up and then files somebody under "Registered Nurse" is testing a facility nobody
# has set up, where everybody is on manager approval. Every suite that signs up calls this straight
# afterwards, with its own cookie jar: e2e_groups "$B" "$J".
#
# The names are generic on purpose — no customer's org chart belongs in a test — and they go in through
# settings.update, the op the settings screen saves with, so a change to what that op accepts breaks
# the setup out loud instead of being stepped around. The response is printed for the suite's own
# check() to judge.
e2e_groups() {
local base=${1:?base url} jar=${2:?cookie jar}
curl -s -b "$jar" -c "$jar" -X POST "$base/api/mutate" -H 'content-type: application/json' \
-d '{"op":"settings.update","payload":{"staffGroups":["Registered Nurse","Enrolled Nurse","Support Services","Kitchen","Security"],"nursingGroups":["Registered Nurse","Enrolled Nurse"],"kitGroups":["Support Services"]}}'
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Pre-loved uniforms (Update.V3): pool adjust, hand-ins (good/rag, credit), pre-loved issues (free, no ledger/entitlement/replenish), pool stocktake, backup round-trip.
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-pl-cj.txt"; rm -f "$J"
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}"; }
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"; }
bk() { curl -s -b "$J" "$B/api/backup"; }
pool() { bk | py 'print(next((s["preloved"] for s in d["stock"] if s["itemId"]=="'$1'" and s["sizeIndex"]=='$2'),0))'; }
TS=$(date +%s)
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\":\"PL\",\"last\":\"Admin\",\"facility\":\"Preloved Hospital $TS\",\"email\":\"pl$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
check "dept" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
check "catalog" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"RN Scrub Top","sku":"T1","supplier":"Alpha Supply","cost":"30","group":"Registered Nurse","sizes":"S|M"},{"item":"Scrub Pant","sku":"P1","supplier":"Alpha Supply","cost":"25","group":"Registered Nurse","sizes":"S|M"},{"item":"Security Shirt","sku":"S1","supplier":"Alpha Supply","cost":"40","group":"Security","sizes":"M|L"}]}')" '"created":3'
check "opening" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"10"},{"sku":"P1","size":"M","opening":"10"},{"sku":"S1","size":"L","opening":"4"}]}')" '"created":3'
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Nina","last":"Nurse","group":"Registered Nurse","dept":"Ward 1","top":"M","pants":"M"},{"num":"2","first":"Sam","last":"Guard","group":"Security","dept":"Ward 1","ent":"3","top":"L","pants":"L"}]}')" '"created":2'
BK=$(bk)
T1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="T1"][0])'); P1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="P1"][0])'); S1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="S1"][0])')
NINA=$(echo "$BK" | py 'print([s["id"] for s in d["staff"] if s["num"]=="1"][0])'); SAM=$(echo "$BK" | py 'print([s["id"] for s in d["staff"] if s["num"]=="2"][0])')
echo "== pool adjust"
check "pre-loved adjust +3" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":3}]}")" '"ok":true'
check "pool T1/M = 3" "$(pool $T1 1)" '^3$'
check "pre-loved adjust -5 clamps to 0" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":-5}]}")" '"ok":true'
check "pool clamped 0" "$(pool $T1 1)" '^0$'
check "pool adjust leaves shelf alone (no moves)" "$(bk | py 'print(len(d["moves"]))')" '^0$'
check "pre-loved adjust +2 again" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":2}]}")" '"ok":true'
echo "== hand-ins"
check "hand-in needs lines" "$(mut handin.add "{\"staffId\":\"$SAM\",\"lines\":[]}")" 'at least one'
check "issue Sam 2 shirts from stock" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")" '"stock":2'
check "Sam has 2 shirts on his record" "$(bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$SAM'" and not i["preloved"]))')" '^2$'
# The ceiling itself, proved by the refusal it should earn: he holds 2 shirts, and 5 more would be 7
# tops against the six anyone may hold. His register figure of 3 is a yearly reporting number now and
# refuses nothing on its own. Counting his issue rows never shows that.
check "Sam holds 2 tops, 5 more is past six" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":5,\"src\":\"order\"}]}")" 'the most anyone holds is 6 sets'
R=$(mut handin.add "{\"staffId\":\"$SAM\",\"credit\":true,\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":1,\"cond\":\"Good\",\"laundered\":true},{\"itemId\":\"$S1\",\"si\":1,\"qty\":1,\"cond\":\"Rag\",\"laundered\":false}]}")
check "hand-in recorded" "$R" '"good":1,"rag":1,"credit":true'
check "hand-in message" "$R" '1 to the pre-loved pool · 1 to rag disposal · allowance credited'
check "pool S1/L = 1 (rag not pooled)" "$(pool $S1 1)" '^1$'
# Assert the quantity handed in, not the number of rows it landed on. A hand-in that covers part of
# an issue row now splits it, so two lines against one qty-2 row leave two stamped rows rather than
# one — the same two garments, recorded with the condition each of them actually went out under.
check "handedIn qty stamped on past issues" "$(bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$SAM'" and i["handedIn"]))')" '^2$'
check "Sam holds nothing after handing both in" "$(bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$SAM'" and not i["handedIn"] and not i["returnedDate"]))')" '^0$'
check "hand-in in backup" "$(bk | py 'print(len(d["handins"]), d["handins"][0]["credit"], len(d["handins"][0]["lines"]))')" '1 True 2'
# Both shirts handed in leaves him holding nothing, so two more go straight through: the room comes
# from what he gave back.
check "handing in freed room (2 more OK)" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")" '"stock":2'
# Holding 2 again, so 5 more is 7 tops — past the six, and refused without an override.
check "past six again is refused" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":0,\"qty\":5,\"src\":\"order\"}]}")" 'the most anyone holds is 6 sets'
echo "== manager approval credit"
check "approval 2 sets" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"2\"}")" '"id"'
check "issue Nina 2 sets from stock" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":2,\"src\":\"stock\"},{\"itemId\":\"$P1\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")" '"apDeducted":2'
check "approval fully used" "$(bk | py 'print(d["approvals"][0]["used"])')" '^2$'
check "hand-in 1 top + 1 pant credited" "$(mut handin.add "{\"staffId\":\"$NINA\",\"credit\":true,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"},{\"itemId\":\"$P1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")" '"setsBack":1'
check "approval used back to 1" "$(bk | py 'print(d["approvals"][0]["used"])')" '^1$'
check "pool T1/M now 3 (2 adjust + 1 hand-in)" "$(pool $T1 1)" '^3$'
echo "== pre-loved issue"
SHELF_BEFORE=$(bk | py 'print(next(s["opening"]+s["adj"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')
check "pre-loved over pool rejected" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":9,\"src\":\"preloved\"}]}")" 'Not enough pre-loved'
# The issue ASKS for two sets it must not be given. An approval is the ward agreeing to pay for new
# uniform; a pre-loved garment came back off somebody else, costs the ward nothing and goes out
# free, so spending a set on one takes something from the wearer the ward never spent. Nina has one
# set left here, so a request for two is refused by the rule rather than by arithmetic — and asking
# for none, as this line used to, would pass just as well with the rule deleted.
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":2,\"src\":\"preloved\"}]}")
check "pre-loved issue ok" "$R" '"preloved":2'
check "no approval deduction, even though two sets were asked for" "$R" '"apDeducted":0'
check " and the approval row is untouched" "$(bk | py 'print(d["approvals"][0]["used"])')" '^1$'
check "pool T1/M down to 1" "$(pool $T1 1)" '^1$'
# Drawing from the pool leaves the shelf where it was: the counted stock doesn't move and no
# movement is written, or a free garment quietly comes off the linen room's own figures.
check "pre-loved issue leaves the shelf alone" "$(bk | py 'print(next(s["opening"]+s["adj"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')" "^$SHELF_BEFORE\$"
check "pre-loved issue writes no stock movement" "$(bk | py 'print(len(d["moves"]))')" '^0$'
# Anchored, because a top costs 30 and an unanchored "0 Pre-loved" matches "30 Pre-loved" just as
# happily — and costing the ward nothing is the half of this row that matters.
check "issue row preloved + cost 0" "$(bk | py 'i=[i for i in d["issues"] if i["preloved"]][0]; print(i["cost"], i["cond"])')" '^0 Pre-loved$'
check "no replenishment draft line for pre-loved (T1 M draft qty stays 2)" "$(bk | py 'print(sum(l["qty"] for o in d["orders"] if o["replenish"] for l in o["lines"] if l["itemId"]=="'$T1'" and l["size"]=="M"))')" '^2$'
check "issue Sam pre-loved not blocked by entitlement" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2}]}"; mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2,\"src\":\"preloved\"}]}")" '"preloved":2'
echo "== pool stocktake"
check "pool stocktake sets count" "$(mut stocktake.apply "{\"mode\":\"preloved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":5},{\"itemId\":\"$P1\",\"si\":1,\"counted\":1}]}")" '"counted":2,"variances":1'
check "pool T1/M = 5 after pool take" "$(pool $T1 1)" '^5$'
check "shelf adj untouched by pool take" "$(bk | py 'print(next(s["adj"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')" '^0$'
check "stocktake filed with mode preloved" "$(bk | py 'print([t["mode"] for t in d["stocktakes"]])')" "preloved"
check "shelf stocktake still shelf mode" "$(mut stocktake.apply "{\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":6}]}"; bk | py 'print(sorted(t["mode"] for t in d["stocktakes"]))')" "\['preloved', 'shelf'\]"
echo "== backup round-trip keeps the pool"
BKF=$(bk)
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$BKF}")" '"ok":true'
# Name the garment and the size each figure belongs to, not just the numbers. A restore re-creates
# every row with a fresh id, so the pool is only really back if each count came down on the item and
# the size it was counted against — five pre-loved tops in M read as "a 5 in the list" whether they
# landed on the tops, on the pants, or on the small ones.
check "pool survived restore, on the right item and size" "$(bk | py 'it={i["id"]:i for i in d["items"]}; print(" ".join(sorted(it[s["itemId"]]["sku"]+"/"+it[s["itemId"]]["sizes"][s["sizeIndex"]]+"="+str(s["preloved"]) for s in d["stock"] if s["preloved"]>0)))')" '^P1/M=1 S1/L=1 T1/M=5$'
# Not the number of hand-ins: the garments that came back on them and the credit those earned.
# `credited` is what every wearer's entitlement is worked out from, so a restore that dropped it
# would move Sam from 3-used to 4-used with two hand-in rows sitting there looking perfectly right.
check "handins survived restore, lines and credit intact" "$(bk | py 'print(sorted((h["credit"], sum(l["qty"] for l in h["lines"]), sum(l["credited"] for l in h["lines"])) for h in d["handins"]))')" '^\[(True, 2, 1), (True, 2, 2)\]$'
# Garments, not rows: an issue row carries a quantity, so counting rows would call a restore that
# brought back one of Nina's two pre-loved tops a complete one. Free travels with them: a pre-loved
# garment that comes back priced at the catalogue cost starts charging a ward for something it was
# given, which is the one thing the pool exists not to do.
check "preloved issues survived restore, still free" "$(bk | py 'p=[i for i in d["issues"] if i["preloved"]]; print(sum(i["qty"] for i in p), sorted({i["cost"] for i in p}))')" '^4 \[0\]$'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# Scan-to-add / scan-a-size / start-fresh: catalog.add with barcodes, catalog.variantAdd,
# ambiguous-binding guards, /api/lookup gating and data.reset.
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-scan-cj.txt"; J2="$T/tc-scan-cj2.txt"; rm -f "$J" "$J2"
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}"; }
mut2() { curl -s -b "$J2" -c "$J2" -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" "$(echo "$out" | head -c 300)"; else ok "$name"; fi; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
XFF="x-forwarded-for: 10.7.$((RANDOM%250)).$((RANDOM%250))"
TS=$(date +%s)
echo "== setup"
check "signup admin" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "$XFF" -d "{\"first\":\"Scan\",\"last\":\"Admin\",\"facility\":\"Scan Hospital $TS\",\"email\":\"scan$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check "supplier" "$(mut supplier.add '{"name":"Northline Workwear"}')" '"id"'
echo "== quick add from a scan (catalog.add + barcodes)"
R=$(mut catalog.add '{"item":"Scrub Top","gender":"Unisex","group":"All","sku":"ST-1","supplier":"Northline Workwear","cost":24.5,"sizes":["S","M","L"],"barcodes":[{"si":1,"code":"9312345678907"}]}')
check "created with a bound barcode" "$R" '"id"'
IT=$(echo "$R" | py 'print(d["result"]["id"])')
BK=$(curl -s -b "$J" "$B/api/backup")
check "barcode stored against size index 1" "$(echo "$BK" | py 'print([(b["code"],b["sizeIndex"]) for b in d["barcodes"]])')" "9312345678907', 1"
check "barcode source is bound" "$(echo "$BK" | py 'print(d["barcodes"][0]["source"])')" '^bound$'
check "size out of range rejected" "$(mut catalog.add '{"item":"Bad","cost":1,"sizes":["S"],"barcodes":[{"si":4,"code":"111"}]}')" 'has to point at one of the sizes'
check "no item created by the rejected add" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["items"]))')" '^1$'
echo "== scan a size onto an existing product (catalog.variantAdd)"
check "bind a code to an existing size" "$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"S\",\"code\":\"9312345678891\"}")" '"si":0'
check "existing size not duplicated" "$(curl -s -b "$J" "$B/api/backup" | py 'print(d["items"][0]["sizes"])')" "\['S', 'M', 'L'\]"
R=$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"2XL\",\"code\":\"9312345678914\"}")
check "new size appended" "$R" '"si":3'
check "new size reported as created" "$R" '"created":true'
check "sizes now 4" "$(curl -s -b "$J" "$B/api/backup" | py 'print(d["items"][0]["sizes"])')" "\['S', 'M', 'L', '2XL'\]"
check "3 barcodes bound" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["barcodes"]))')" '^3$'
check "size is case-insensitive (no duplicate)" "$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"2xl\",\"code\":\"9312345678921\"}")" '"si":3'
check "still 4 sizes" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["items"][0]["sizes"]))')" '^4$'
check "blank size rejected" "$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\" \",\"code\":\"9312345678938\"}")" 'Size required'
echo "== ambiguous bindings are refused"
check "code already on another size" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":2,\"code\":\"9312345678907\"}")" 'is already on Scrub Top · size M'
check "same code, same size is a no-op not an error" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":1,\"code\":\"9312345678907\"}")" '"ok":true'
check "force moves it" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":2,\"code\":\"9312345678907\",\"force\":true}")" '"ok":true'
check "moved to size index 2" "$(curl -s -b "$J" "$B/api/backup" | py 'print([b["sizeIndex"] for b in d["barcodes"] if b["code"]=="9312345678907"][0])')" '^2$'
R2=$(mut catalog.add '{"item":"Cargo Pant","cost":30,"sizes":["S","M"]}')
IT2=$(echo "$R2" | py 'print(d["result"]["id"])')
SORT2=$(curl -s -b "$J" "$B/api/backup" | py "print([i['sort'] for i in d['items'] if i['id']=='$IT2'][0])")
GEN=$((930000000 + SORT2 * 100 + 1))
check "generated code of another item is refused" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":0,\"code\":\"$GEN\"}")" 'generated code for Cargo Pant'
check "generated code onto its own item is fine" "$(mut barcode.bind "{\"itemId\":\"$IT2\",\"si\":1,\"code\":\"$GEN\"}")" '"ok":true'
echo "== printing our own barcode for a garment that arrived without one"
# Whole ranges turn up unlabelled and a garment nobody can scan is invisible to a count. The number
# has to be one GS1 will never issue to a manufacturer, so it is a real EAN-13 in the restricted
# circulation range (prefix 29) — a genuine check digit, readable by any scanner in the building.
RG=$(mut catalog.add '{"item":"Cafe Shirt","cost":34.1,"sizes":["8","10","12"]}')
ITG=$(echo "$RG" | py 'print(d["result"]["id"])')
check "a garment with no codes at all" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$ITG']))")" '^0$'
# One supplier code already on the middle size: generating must fill the gaps and leave that alone.
check "one size already carries a supplier code" "$(mut barcode.bind "{\"itemId\":\"$ITG\",\"si\":1,\"code\":\"9312345678952\"}")" '"ok":true'
G=$(mut barcode.generate "{\"itemId\":\"$ITG\"}")
check "generates for the sizes that have none" "$G" '"count":2'
check " and says which sizes it did" "$(echo "$G" | py 'print(sorted(x["size"] for x in d["result"]["made"]))')" "\['12', '8'\]"
check "every generated code is a 13-digit 29 code" "$(echo "$G" | py 'print(all(len(x["code"])==13 and x["code"].isdigit() and x["code"].startswith("29") for x in d["result"]["made"]))')" '^True$'
# A wrong check digit is a label that prints and then will not scan, which is worse than no label.
check " with a valid check digit" "$(echo "$G" | py '
def cd(f):
return (10 - sum(int(c)*(1 if i%2==0 else 3) for i,c in enumerate(f[:12])) % 10) % 10
print(all(cd(x["code"]) == int(x["code"][12]) for x in d["result"]["made"]))')" '^True$'
check " and they are all different" "$(echo "$G" | py 'print(len({x["code"] for x in d["result"]["made"]}))')" '^2$'
check "the supplier code was left where it was" "$(curl -s -b "$J" "$B/api/backup" | py "print([b['code'] for b in d['barcodes'] if b['itemId']=='$ITG' and b['sizeIndex']==1][0])")" '^9312345678952$'
check "every size is now labelled" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$ITG']))")" '^3$'
# The two refusals differ only in their wording, and the wording is the only sign of which path
# ran. A whole-garment answer to a one-size request means the si was ignored — and what follows
# that is a whole rack relabelled when one hook was asked about.
check "asking again says there is nothing to do" "$(mut barcode.generate "{\"itemId\":\"$ITG\"}")" 'Every size on this garment already has a barcode'
check "and a single size that is taken says so too" "$(mut barcode.generate "{\"itemId\":\"$ITG\",\"si\":0}")" 'That size already has a barcode'
# The generated code is a real binding, so it resolves on a scan like any other.
GC=$(echo "$G" | py 'print([x["code"] for x in d["result"]["made"] if x["size"]=="8"][0])')
check "a generated code scans back to its own size" "$(curl -s -b "$J" "$B/api/backup" | py "print([b['sizeIndex'] for b in d['barcodes'] if b['code']=='$GC'][0])")" '^0$'
# One size at a time is the other half of the op, and proving it needs a garment with more than
# one gap: somebody holding a single unlabelled size gets one label, not a fresh number printed
# over every size on the garment.
RC=$(mut catalog.add '{"item":"Theatre Cap","cost":6,"sizes":["S","M"]}')
ITC=$(echo "$RC" | py 'print(d["result"]["id"])')
G2=$(mut barcode.generate "{\"itemId\":\"$ITC\",\"si\":1}")
check "one size asked for, one code made" "$G2" '"count":1'
check " and it is the size that was asked for" "$(echo "$G2" | py 'print([x["size"] for x in d["result"]["made"]])')" "\['M'\]"
check " and the other size is left unlabelled" "$(curl -s -b "$J" "$B/api/backup" | py "print([b['sizeIndex'] for b in d['barcodes'] if b['itemId']=='$ITC'])")" "\[1\]"
echo "== Set on hand records the difference, not the number"
# Stock the hand-in pool first. An empty pool answers 0 whether Set left it alone or ate the lot,
# and the seconds sitting in it were never on the shelf the count is correcting. Safe to leave in
# place for the rest of the section: the pool sits outside on-hand.
mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":3}]}" >/dev/null
check "receive 3 onto a fresh size" "$(mut stock.moves "{\"mode\":\"Receive\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":3}]}")" '"ok":true'
oh() { curl -s -b "$J" "$B/api/backup" | py "
import sys
st=[s for s in d['stock'] if s['itemId']=='$IT' and s['sizeIndex']==1]
base=(st[0]['opening']+st[0]['adj']) if st else 0
mv=sum(m['qty'] for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1)
iss=sum(i['qty'] for i in d['issues'] if i['itemId']=='$IT' and i['sizeIndex']==1 and not i.get('direct'))
print(base+mv-iss)"; }
check "on hand is 3" "$(oh)" '^3$'
check "set it to 2 (the count)" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":2}]}")" '"ok":true'
check "on hand is now 2, not 5" "$(oh)" '^2$'
check "the difference was recorded as -1" "$(curl -s -b "$J" "$B/api/backup" | py "print([m['qty'] for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1][-1])")" '^-1$'
check "recorded as an adjust with a reason" "$(curl -s -b "$J" "$B/api/backup" | py "m=[m for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1][-1]; print(m['type'], m['reason'])")" 'adjust Counted correction'
check "setting to the same number records nothing" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":2}]}" >/dev/null; curl -s -b "$J" "$B/api/backup" | py "print(len([m for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1]))")" '^2$'
check "set upward to 9 works too" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":9}]}" >/dev/null; oh)" '^9$'
check "set back to 0 empties the line" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":0}]}" >/dev/null; oh)" '^0$'
check "negative count refused" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":-2}]}")" "can't be negative"
check "bad mode still refused" "$(mut stock.moves "{\"mode\":\"Nonsense\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":1}]}")" 'Bad mode'
check "Set does not touch the pre-loved pool" "$(curl -s -b "$J" "$B/api/backup" | py "print(sum(s['preloved'] for s in d['stock'] if s['itemId']=='$IT'))")" '^3$'
# On a size that carries a real opening balance, counted to a different figure, so the answer
# can't be mistaken for either number. Size 0 is untouched by the size-1 arithmetic above.
mut stock.moves "{\"mode\":\"Opening\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":5}]}" >/dev/null
mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":2}]}" >/dev/null
check "Set does not rewrite the opening balance" "$(curl -s -b "$J" "$B/api/backup" | py "print([s['opening'] for s in d['stock'] if s['itemId']=='$IT' and s['sizeIndex']==0][0])")" '^5$'
echo "== duplicate a garment for another colour"
check "reorder level on the source" "$(mut stock.reorder "{\"itemId\":\"$IT\",\"si\":0,\"reorder\":6}")" '"ok":true'
# Give the source a real product type before copying it. Type is what files a garment under Tops
# or Bottoms, so a duplicate that quietly drops it puts the new colour under the wrong heading —
# and a blank type on both sides would agree with itself and prove nothing.
check "product type on the source" "$(mut catalog.update "{\"id\":\"$IT\",\"type\":\"Scrub top\"}")" '"ok":true'
R=$(mut catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Scrub Top — EN (Navy)\",\"group\":\"Enrolled Nurse\",\"sku\":\"ST-EN\"}")
check "duplicate created" "$R" '"id"'
check "reports the size count" "$R" '"sizes":4'
DUP=$(echo "$R" | py 'print(d["result"]["id"])')
BK=$(curl -s -b "$J" "$B/api/backup")
check "name applied" "$(echo "$BK" | py "print([i['item'] for i in d['items'] if i['id']=='$DUP'][0])")" 'Scrub Top — EN (Navy)'
check "group applied" "$(echo "$BK" | py "print([i['group'] for i in d['items'] if i['id']=='$DUP'][0])")" '^Enrolled Nurse$'
check "sku applied" "$(echo "$BK" | py "print([i['sku'] for i in d['items'] if i['id']=='$DUP'][0])")" '^ST-EN$'
check "sizes copied" "$(echo "$BK" | py "print([i['sizes'] for i in d['items'] if i['id']=='$DUP'][0] == [i['sizes'] for i in d['items'] if i['id']=='$IT'][0])")" '^True$'
check "cost/supplier/gender/type copied" "$(echo "$BK" | py "src=[i for i in d['items'] if i['id']=='$IT'][0]; c=[i for i in d['items'] if i['id']=='$DUP'][0]; print(c['cost']==src['cost'] and c['supplier']==src['supplier'] and c['gender']==src['gender'] and c['type']==src['type']=='Scrub top')")" '^True$'
check "reorder level carried over" "$(echo "$BK" | py "print([s['reorder'] for s in d['stock'] if s['itemId']=='$DUP' and s['sizeIndex']==0][0])")" '^6$'
check "NO barcodes copied" "$(echo "$BK" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$DUP']))")" '^0$'
check "NO stock copied" "$(echo "$BK" | py "print(sum(s['opening']+s['adj'] for s in d['stock'] if s['itemId']=='$DUP'))")" '^0$'
# Every one of the source's codes by name: a duplicate that dragged three of the four across to
# the new colour would leave one behind and satisfy a mere "still has some".
check "source untouched" "$(echo "$BK" | py "print(sorted(b['code'] for b in d['barcodes'] if b['itemId']=='$IT'))")" "\['9312345678891', '9312345678907', '9312345678914', '9312345678921'\]"
check "same name and group refused" "$(mut catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Scrub Top — EN (Navy)\",\"group\":\"Enrolled Nurse\"}")" 'already exists for Enrolled Nurse'
check "same name, different group allowed" "$(mut catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Scrub Top — EN (Navy)\",\"group\":\"Registered Nurse\"}")" '"id"'
check "unknown source refused" "$(mut catalog.duplicate '{"id":"nope","item":"X"}')" 'Unknown catalogue item'
check "each colour keeps its own barcode" "$(mut barcode.bind "{\"itemId\":\"$DUP\",\"si\":0,\"code\":\"9300222000003\"}")" '"ok":true'
check "binding the colour did not move the source's code" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$IT'])==4 and [(b['code'],b['sizeIndex']) for b in d['barcodes'] if b['itemId']=='$DUP']==[('9300222000003', 0)])")" '^True$'
echo "== product lookup is off until an admin turns it on"
check "lookup default off" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["enabled"])')" '^False$'
check "off explains itself" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907")" 'Product lookup is off'
check "GTIN still decoded while off" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["gtin"]["kind"], d["gtin"]["valid"], d["gtin"]["origin"])')" 'EAN-13 True Australia'
check "bad check digit flagged" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678901" | py 'print(d["gtin"]["valid"])')" '^False$'
check "turn lookup on" "$(mut settings.update '{"barcodeLookup":true}')" '"ok":true'
check "lookup now enabled" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["enabled"])')" '^True$'
check "mis-read code is never sent out" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678901")" "check digit doesn't match"
check "non-GTIN code is never sent out" "$(curl -s -b "$J" "$B/api/lookup?code=ABC123")" 'Not a standard retail barcode'
check "signed-out lookup refused" "$(curl -s "$B/api/lookup?code=9312345678907")" 'Not signed in'
# The public barcode databases belong to somebody else: they go down, they run the day's free
# quota out, and the linen room still has to be told something. Either a name came back or a note
# says in plain words why not — never a blank answer and never a stack trace.
check "lookup answers even when the public product database doesn't" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["enabled"], bool(d.get("name") or d.get("note")))')" '^True True$'
check "turn lookup back off" "$(mut settings.update '{"barcodeLookup":false}')" '"ok":true'
echo "== issuer can scan but not bind"
check "add issuer" "$(mut users.add "{\"first\":\"Iss\",\"last\":\"Uer\",\"email\":\"iss$TS@example.com\",\"password\":\"password123\",\"role\":\"ISSUER\"}")" '"ok":true\|"id"'
check "issuer login" "$(curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "$XFF" -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
check "issuer can't bind" "$(mut2 barcode.bind "{\"itemId\":\"$IT\",\"si\":0,\"code\":\"9300000000000\"}")" 'Admin only'
check "issuer can't add a variant" "$(mut2 catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"4XL\",\"code\":\"9300000000001\"}")" 'Admin only'
check "issuer can't duplicate" "$(mut2 catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Sneaky\"}")" 'Admin only'
check "issuer can't set a counted quantity" "$(mut2 stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":1}]}")" 'Admin only'
check "issuer can't look up" "$(curl -s -b "$J2" "$B/api/lookup?code=9312345678907")" 'Admin only'
check "issuer can't reset the facility" "$(mut2 data.reset '{"confirm":"RESET"}')" 'Admin only'
echo "== product type + opening stock on create"
R=$(mut catalog.add '{"item":"Ward Dress","type":"Dress","cost":40,"sizes":["8","10","12"],"barcodes":[{"si":0,"code":"9300111000005"},{"si":1,"code":"9300111000012"}],"opening":[{"si":0,"qty":4},{"si":2,"qty":7}]}')
check "created with type, barcodes and opening" "$R" '"id"'
DR=$(echo "$R" | py 'print(d["result"]["id"])')
BK=$(curl -s -b "$J" "$B/api/backup")
check "type stored" "$(echo "$BK" | py "print([i['type'] for i in d['items'] if i['id']=='$DR'][0])")" '^Dress$'
check "two barcodes bound on create" "$(echo "$BK" | py "print(sorted((b['code'],b['sizeIndex']) for b in d['barcodes'] if b['itemId']=='$DR'))")" "\[('9300111000005', 0), ('9300111000012', 1)\]"
check "opening set on size 0 and 2" "$(echo "$BK" | py "print(sorted((s['sizeIndex'],s['opening']) for s in d['stock'] if s['itemId']=='$DR'))")" "\[(0, 4), (2, 7)\]"
check "size with no opening has no level row" "$(echo "$BK" | py "print(len([s for s in d['stock'] if s['itemId']=='$DR']))")" '^2$'
check "duplicate barcode across sizes rejected" "$(mut catalog.add '{"item":"Dup","cost":1,"sizes":["S","M"],"barcodes":[{"si":0,"code":"9300111000029"},{"si":1,"code":"9300111000029"}]}')" 'same barcode is on two sizes'
check "barcode already on another garment rejected at create" "$(mut catalog.add '{"item":"Steal","cost":1,"sizes":["S"],"barcodes":[{"si":0,"code":"9300111000005"}]}')" 'is already on Ward Dress'
check "negative opening rejected" "$(mut catalog.add '{"item":"Neg","cost":1,"sizes":["S"],"opening":[{"si":0,"qty":-3}]}')" "can't be negative"
check "opening pointing at a missing size rejected" "$(mut catalog.add '{"item":"Bad","cost":1,"sizes":["S"],"opening":[{"si":5,"qty":1}]}')" 'has to point at one of the sizes'
check "nothing created by the rejected adds" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([i for i in d['items'] if i['item'] in ('Dup','Steal','Neg','Bad')]))")" '^0$'
echo "== product type drives the tops/pants split"
check "type can be edited" "$(mut catalog.update "{\"id\":\"$DR\",\"type\":\"Tunic\"}")" '"ok":true'
check "type updated" "$(curl -s -b "$J" "$B/api/backup" | py "print([i['type'] for i in d['items'] if i['id']=='$DR'][0])")" '^Tunic$'
check "type survives a CSV import column" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Cargo Pant CSV","type":"Cargo pants","cost":"30","sizes":"77|82"}]}')" '"created":1'
check "imported type stored" "$(curl -s -b "$J" "$B/api/backup" | py "print([i['type'] for i in d['items'] if i['item']=='Cargo Pant CSV'][0])")" '^Cargo pants$'
echo "== backup round-trip keeps type"
BK2=$(curl -s -b "$J" "$B/api/backup")
echo "$BK2" > "$T/tc-scan-backup.json"
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$(cat "$T/tc-scan-backup.json")}")" '"ok":true'
check "type survived restore" "$(curl -s -b "$J" "$B/api/backup" | py "print(sorted(i['type'] for i in d['items'] if i['type']))")" "Cargo pants.*Tunic"
# Restore re-creates every row with fresh ids, so re-resolve the item the later checks use.
IT=$(curl -s -b "$J" "$B/api/backup" | py 'print([i["id"] for i in d["items"] if i["sku"]=="ST-1"][0])')
echo "== start fresh (data.reset)"
check "dept" "$(mut dept.save '{"name":"Ward 9","cc":"900"}')" '"ok":true'
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"77","first":"Sam","last":"Reed","group":"Registered Nurse","dept":"Ward 9","top":"M","pants":"M"}]}')" '"created":1'
ST=$(curl -s -b "$J" "$B/api/backup" | py 'print(d["staff"][0]["id"])')
check "opening balance" "$(mut stock.moves "{\"mode\":\"Opening\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":10}]}")" '"ok":true'
check "an issue exists" "$(mut issue.create "{\"staffId\":\"$ST\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":1,\"src\":\"shelf\"}]}")" '"ok":true\|"id"'
check "wrong confirmation refused" "$(mut data.reset '{"confirm":"reset"}')" 'Type RESET to confirm'
check "data still there" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["items"]), len(d["staff"]), len(d["issues"]))')" '^8 1 1$'
check "reset" "$(mut data.reset '{"confirm":"RESET"}')" '"ok":true'
BK=$(curl -s -b "$J" "$B/api/backup")
check "catalogue empty" "$(echo "$BK" | py 'print(len(d["items"]))')" '^0$'
check "barcodes empty" "$(echo "$BK" | py 'print(len(d["barcodes"]))')" '^0$'
check "stock levels empty" "$(echo "$BK" | py 'print(len(d["stock"]))')" '^0$'
check "staff empty" "$(echo "$BK" | py 'print(len(d["staff"]))')" '^0$'
check "departments empty" "$(echo "$BK" | py 'print(len(d["depts"]))')" '^0$'
check "suppliers empty" "$(echo "$BK" | py 'print(len(d["suppliers"]))')" '^0$'
check "issues empty" "$(echo "$BK" | py 'print(len(d["issues"]))')" '^0$'
check "orders empty" "$(echo "$BK" | py 'print(len(d["orders"]))')" '^0$'
check "moves empty" "$(echo "$BK" | py 'print(len(d["moves"]))')" '^0$'
check "facility name kept" "$(echo "$BK" | py 'print(d["facility"]["name"])')" "Scan Hospital $TS"
check "staff groups kept" "$(echo "$BK" | py 'print(len(d["facility"]["staffGroups"])>0)')" '^True$'
check "numbering restarted" "$(echo "$BK" | py 'print(d["facility"]["orderSeq"], d["facility"]["catalogSeq"])')" '^0 0$'
check "logins kept — admin still signed in" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
check "first product after reset is sort 1" "$(mut catalog.add '{"item":"Fresh Top","cost":10,"sizes":["M"]}' >/dev/null; curl -s -b "$J" "$B/api/backup" | py 'print(d["items"][0]["sort"])')" '^1$'
check "keepSuppliers option" "$(mut supplier.add '{"name":"Keepme"}' >/dev/null; mut data.reset '{"confirm":"RESET","keepSuppliers":true}')" '"ok":true'
check "supplier survived" "$(curl -s -b "$J" "$B/api/backup" | py 'print([x["name"] for x in d["suppliers"]])')" 'Keepme'
echo
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Security hardening checks: headers, CSRF/origin gating, session invalidation on password change, rate limits,
# demo session guard, proxy bad-cookie recovery, receipt cost validation, admin-only write-offs, locked orders, hand-in/return double-count guards.
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-sec-cj.txt"; rm -f "$J"
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}"; }
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"; }
bk() { curl -s -b "$J" "$B/api/backup"; }
echo "== headers"
H=$(curl -s -D - -o /dev/null "$B/" | tr A-Z a-z)
check "CSP present" "$H" 'content-security-policy: .*frame-ancestors .none.'
check "HSTS" "$H" 'strict-transport-security'
check "nosniff" "$H" 'x-content-type-options: nosniff'
check "no x-powered-by" "$(echo "$H" | grep -ci 'x-powered-by' || true)" '^0$'
check "referrer policy" "$H" 'referrer-policy'
echo "== CSRF / origin gating"
TS=$(date +%s); EMAIL="sec$TS@example.com"
check "cross-site signup refused" "$(curl -s -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H 'origin: https://evil.example' -d '{}')" 'Cross-site'
check "sec-fetch-site cross-site refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H 'sec-fetch-site: cross-site' -d '{}')" 'Cross-site'
check "text/plain form body refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: text/plain' -d '{"email":"a@b.c","password":"x"}')" 'Expected JSON'
check "signup ok" "$(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\":\"Sec\",\"last\":\"Admin\",\"facility\":\"Sec Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check "mutate cross-site refused" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -H 'origin: https://evil.example' -d '{"op":"settings.update","payload":{}}')" 'Cross-site'
check "mutate same-origin ok" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -H 'sec-fetch-site: same-origin' -d '{"op":"settings.update","payload":{"coordinator":"X"}}')" '"ok":true'
check "logout cross-site refused" "$(curl -s -b "$J" -X POST "$B/api/auth/logout" -H 'origin: https://evil.example')" 'Cross-site'
echo "== session bound to password"
OLD=$(grep tc_session "$J" | awk '{print $7}')
check "old token works" "$(curl -s -b "tc_session=$OLD" -o /dev/null -w '%{http_code}' "$B/api/backup")" '200'
check "change password" "$(mut me.password '{"current":"password123","next":"password456"}')" '"ok":true'
check "old token dead after password change" "$(curl -s -b "tc_session=$OLD" -o /dev/null -w '%{http_code}' "$B/api/backup")" '401'
check "login with new password" "$(curl -s -c "$J" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"$EMAIL\",\"password\":\"password456\"}")" '"ok":true'
# Two separate promises, so two separate checks: joined by an alternation, a proxy that sent the
# visitor somewhere else entirely still passed as long as it dropped the stale cookie.
BC=$(curl -s -D - -o /dev/null -b 'tc_session=a.!!!' "$B/app" | tr A-Z a-z)
check "bad cookie → redirect to /auth (not 500)" "$BC" 'location: /auth?next=%2fapp'
check "bad cookie cleared" "$BC" 'set-cookie: tc_session=;'
echo "== demo session guard"
check "signed-in user can't be swapped into demo" "$(curl -s -D - -o /dev/null -b "$J" "$B/api/auth/demo?as=admin" | tr A-Z a-z)" 'location: /demo?signedin=1'
check "cross-site demo link bounces to /demo" "$(curl -s -D - -o /dev/null -H 'sec-fetch-site: cross-site' "$B/api/auth/demo?as=admin" | tr A-Z a-z)" 'location: /demo.\?$'
echo "== rate limits"
# A source address of its own for each run of each spray. Both ceilings are per-IP and held in
# memory for the life of the server, so a second run inside the window would start part-way up the
# bucket and the exact counts below would be wrong through no fault of the limiter.
SIP="10.7.$((RANDOM%250)).$((RANDOM%250))"; LIP="10.8.$((RANDOM%250)).$((RANDOM%250))"
N=0; for i in $(seq 1 7); do R=$(curl -s -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: $SIP" -d "{\"first\":\"A\",\"last\":\"B\",\"facility\":\"RL$TS$i\",\"email\":\"rl$TS$i@example.com\",\"password\":\"password123\"}"); echo "$R" | grep -q 'Too many' && N=$((N+1)); done
# Sign-up counts every attempt, so seven tries against a ceiling of five must be refused exactly
# twice. One refusal would mean the ceiling had crept up to six new facilities an hour per
# connection; seven would mean it now turns everybody away.
check "signup rate-limited (5/h per IP)" "$N" '^2$'
N=0; for i in $(seq 1 45); do R=$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "x-forwarded-for: $LIP" -d "{\"email\":\"spray$i@example.com\",\"password\":\"x\"}"); echo "$R" | grep -q 'Too many' && N=$((N+1)); done
# Sign-in counts only the attempts that FAILED, and a refusal is not itself counted (lib/ratelimit.ts),
# so 45 sprayed passwords against a 40-failure ceiling must leave exactly the last 5 refused.
check "login spray limited per IP" "$N" '^5$'
echo "== ops hardening"
check "supplier" "$(mut supplier.add '{"name":"Alpha"}')" '"id"'
check "dept" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
# For every group: Sam is in Security, and everything below issues this top to him. Tagged for one
# group of nurses it would be refused at the counter as outside his staff group, and the hand-in and
# return guards this section is about would never be reached.
check "catalog" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Uniform Top","sku":"T1","supplier":"Alpha","cost":"30","group":"All","sizes":"S|M"}]}')" '"created":1'
check "opening" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"5"}]}')" '"created":1'
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Sam","last":"Guard","group":"Security","dept":"Ward 1","ent":"3","top":"M","pants":"M"}]}')" '"created":1'
BK=$(bk); T1=$(echo "$BK" | py 'print(d["items"][0]["id"])'); SAM=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
R=$(mut order.create "{\"orderFor\":\"Stock\",\"supplier\":\"Alpha\",\"lines\":[{\"itemId\":\"$T1\",\"size\":\"M\",\"qty\":4}]}"); ORD=$(echo "$R" | py 'print(d["result"]["id"])')
check "mark ordered" "$(mut order.status "{\"id\":\"$ORD\",\"status\":\"Ordered\"}")" '"ok":true'
LID=$(bk | py 'print([o for o in d["orders"] if o["id"]=="'$ORD'"][0]["lines"][0]["id"])')
check "negative invoiced cost rejected" "$(mut order.receive "{\"id\":\"$ORD\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1,\"dest\":\"shelf\",\"cost\":-5}]}")" 'between'
check "over-delivery rejected" "$(mut order.receive "{\"id\":\"$ORD\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":9,\"dest\":\"shelf\"}]}")" 'outstanding'
check "receive ok" "$(mut order.receive "{\"id\":\"$ORD\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":4,\"dest\":\"shelf\"}]}")" '"ok":true'
check "received order locked" "$(mut order.update "{\"id\":\"$ORD\",\"ref\":\"hack\"}")" 'locked'
check "issue 1 from stock" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
ISS=$(bk | py 'print(d["issues"][0]["id"])')
# setsBack, not good: `good` is the posted quantity echoed straight back, so it survives the
# matching loop being deleted outright. setsBack counts the garments that earned credit, and only a
# real, un-returned, non-pre-loved past issue produces one.
check "hand-in with credit (matched real issue)" "$(mut handin.add "{\"staffId\":\"$SAM\",\"credit\":true,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")" '"setsBack":1'
check "credited qty recorded = 1" "$(bk | py 'print(d["handins"][0]["lines"][0]["credited"])')" '^1$'
check "return of handed-in issue refused" "$(mut issue.return "{\"id\":\"$ISS\",\"cond\":\"Returned - Good\"}")" 'handed in'
check "pre-loved issue" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"preloved\"}]}")" '"preloved":1'
# The backup lists hand-ins in no particular order, so "one of the two earned credit" was the most
# a comparison of the whole list could say — and which one is the entire point of the check. Pick
# this hand-in out by its own id instead.
H2=$(mut handin.add "{\"staffId\":\"$SAM\",\"credit\":true,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")
check "hand-in of pre-loved earns no credit" "$H2" '"setsBack":0'
H2ID=$(echo "$H2" | py 'print(d["result"]["id"])')
check "pre-loved hand-in credited 0" "$(bk | py 'print([l["credited"] for h in d["handins"] if h["id"]=="'$H2ID'" for l in h["lines"]])')" '^\[0\]$'
# That hand-in took the pre-loved top back, so it cannot also be returned — counting one garment into
# the pool twice is what this used to pass on. Put one more in the pool, issue it, and return that.
mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1}]}" >/dev/null
check "second pre-loved issue" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"preloved\"}]}")" '"preloved":1'
PLI=$(bk | py 'print([i["id"] for i in d["issues"] if i["preloved"] and not i["handedIn"] and not i["returnedDate"]][0])')
POOL0=$(bk | py 'print(next(s["preloved"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')
check "returned-good pre-loved goes back to pool" "$(mut issue.return "{\"id\":\"$PLI\",\"cond\":\"Returned - Good\"}" >/dev/null; bk | py 'print(next(s["preloved"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')" "^$((POOL0+1))$"
check "add issuer" "$(mut users.add "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\",\"first\":\"I\",\"last\":\"S\",\"role\":\"ISSUER\"}")" '"id"'
J2="$T/tc-sec-cj2.txt"; rm -f "$J2"; curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}" >/dev/null
check "issuer cannot write off (Adjust)" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"stock.moves\",\"payload\":{\"mode\":\"Adjust\",\"reason\":\"x\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":-3}]}}")" 'Admin only'
check "issuer can still receive" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"stock.moves\",\"payload\":{\"mode\":\"Receive\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1}]}}")" '"ok":true'
# Photos are no longer part of this: a file carrying more images than we will take back has
# them trimmed and counted rather than being refused outright, so that a room restoring a
# year of records is not left with nothing over a pile of signatures. The RECORD caps still
# refuse, and they are the ones standing between a hostile file and a million rows built in
# memory — items is checked before the envelope is even validated.
check "restore caps enforced" "$(python3 -c 'import json; print(json.dumps({"op":"backup.restore","payload":{"format":"threadcount-backup-v2","items":[{}]*5001}}))' | curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' --data-binary @-)" 'too many items'
check "import row cap" "$(python3 -c 'import json; print(json.dumps({"op":"import.rows","payload":{"kind":"depts","rows":[{"name":"x","cc":"1"}]*20001}}))' | curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d @-)" 'at most 20,000'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+817
View File
@@ -0,0 +1,817 @@
#!/usr/bin/env bash
# The uniform ceiling: six sets HELD at any time, for every staff group, nursing included. There is no
# financial year in it — room comes back only by handing something in, and past six takes a coordinator
# override that is recorded as one. Every refusal below is earned by the ceiling and by nothing else,
# and each sits beside the collection one garment short of it, so a check that matched everything would
# fail its neighbour.
#
# The routes up to that ceiling are the facility's own answer. It names its staff groups and puts each
# one on the FTE table, the starting kit or manager approval, and nothing reads the route off the
# letters in a group's name — the same job is "Housekeeping" in one building and "Support Services" in
# the next. The last three sections move one group between routes and read what its wearer is offered
# after each move, so a screen that ignored the setting would give the same answer twice and fail one
# of the pair.
#
# The closing sections are the staff groups a garment is tagged with. A garment can be for several
# groups, and anybody is offered their own group's garments plus those for every group: the staff app
# refuses the rest outright, and the counter hands them over only on the coordinator's override,
# recorded apart from an override of the ceiling. Every refusal there sits beside the same ask from
# somebody whose group the garment is for. The counter's own request door and every order raised for
# a person refuse the same garments, with no override of their own; what the counter did issue on the
# override stays marked through a collection, a ward round and a partial hand-in.
#
# The next sections are what happens around those rules: one refusal for a cart both outside the group
# and past six, an approved bag handed over after its wearer changed group (marked, beside one that is
# not), a queue place for another group's garment that can't be offered, and a group renamed with the
# garments tagged for it.
#
# Then the cut of uniform somebody is offered — the staff record's Uniform style — asked at every door
# the staff group is asked at, because it is the same rule about a different column: Men's sees the
# men's cut and the unisex range, Women's the women's and the unisex, Either the lot, and blank, which
# is what every record on every register reads until a coordinator says otherwise, is offered
# everything exactly as it is today. Every refusal there sits beside the same ask from somebody whose
# cut it is, and beside a blank record being handed the very garment the refusal is about.
#
# Then the two things that happen to a marked row afterwards, in the same order the staff group's
# sections take them: an approved bag handed over after its wearer was set to another cut, at the
# counter and on the ward round alike (marked, beside one whose wearer was left as she was), and a
# garment issued on the override then split by a hand-in, a return and a swap for another size —
# every half of every split still marked, the new-size row included, beside a garment of the same
# wearer's own cut put through the very same three splits and marked on none of them.
#
# Last, a backup restore that keeps a garment's groups, both marks on every issue row, and the style
# on every staff record.
set -u
B=${BASE:-http://127.0.0.1:3111}
. "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B"
T=${TMP:-/tmp}; J="$T/tc-sets-cj.txt"; W="$T/tc-sets-wearer.txt"; rm -f "$J" "$W"
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}"; }
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" "$(echo "$out" | head -c 300)"; else ok "$name"; fi; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
bk() { curl -s -b "$J" "$B/api/backup"; }
L() { echo "{\"itemId\":\"$1\",\"si\":0,\"qty\":$2,\"src\":\"$3\"}"; }
issue(){ mut issue.create "{\"staffId\":\"$1\",\"lines\":[$2]${3:-}}"; }
handin(){ mut handin.add "{\"staffId\":\"$1\",\"credit\":false,\"lines\":[{\"itemId\":\"$2\",\"si\":0,\"qty\":1,\"cond\":\"Good\",\"laundered\":true}]}"; }
# What somebody still has of one garment: not returned, not handed in.
held() { bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$1'" and i["itemId"]=="'$2'" and not i["handedIn"] and not i["returnedDate"]))'; }
overq(){ bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$1'" and i["override"]))'; }
# The facility's three lists as stored, in their stored order: staff groups | FTE table | starting kit.
groups(){ bk | py 'f=d["facility"]; print("|".join(",".join(f[k]) for k in ("staffGroups","nursingGroups","kitGroups")))'; }
CAP='the most anyone holds is 6 sets'
TS=$(date +%s)
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\":\"Sets\",\"last\":\"Admin\",\"facility\":\"Sets Hospital $TS\",\"email\":\"sets$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
# A new facility arrives with no staff groups, and with no group on the FTE table or the starting kit:
# any list the product shipped would be one employer's job titles, handed to a hotel. Read before the
# coordinator names them and again after, so a signup that went back to writing a list of its own
# fails here instead of being quietly overwritten by the setup.
check "a brand-new facility has no staff groups, and no group on either route" "$(groups)" '^||$'
check "the coordinator names this facility's groups" "$(e2e_groups "$B" "$J")" '"ok":true'
check " and they are the facility's from then on" "$(groups)" '^Registered Nurse,Enrolled Nurse,Support Services,Kitchen,Security|Registered Nurse,Enrolled Nurse|Support Services$'
mut supplier.add '{"name":"Alpha Supply"}' >/dev/null
mut dept.save '{"name":"Ward 1","cc":"100"}' >/dev/null
# Types are given outright rather than left to the garment's name, so which half of a set each one is
# never depends on a word in its title.
mut import.rows '{"kind":"catalog","rows":[{"item":"Uniform Top","sku":"UT","type":"Scrub top","supplier":"Alpha Supply","cost":"30","group":"All","sizes":"M"},{"item":"Uniform Pant","sku":"UP","type":"Pants","supplier":"Alpha Supply","cost":"25","group":"All","sizes":"M"},{"item":"Fleece Jacket","sku":"FJ","type":"Fleece","supplier":"Alpha Supply","cost":"50","group":"All","sizes":"M"}]}' >/dev/null
mut import.rows '{"kind":"opening","rows":[{"sku":"UT","size":"M","opening":"60"},{"sku":"UP","size":"M","opening":"60"},{"sku":"FJ","size":"M","opening":"20"}]}' >/dev/null
# Owen is on the starting kit because this facility put Support Services there, not because of any
# word in the name. Ella's register figure of 1 is the old yearly number. It is there to be ignored.
mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Owen","last":"Ops","group":"Support Services","dept":"Ward 1","top":"M","pants":"M"},{"num":"2","first":"Nora","last":"Nurse","group":"Registered Nurse","dept":"Ward 1","fte":"1.0","top":"M","pants":"M"},{"num":"3","first":"Kira","last":"Kitchen","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M"},{"num":"4","first":"Ella","last":"Kitchen","group":"Kitchen","dept":"Ward 1","ent":"1","top":"M","pants":"M"},{"num":"5","first":"Sam","last":"Guard","group":"Security","dept":"Ward 1","top":"M","pants":"M"}]}' >/dev/null
BK=$(bk)
check "setup: three garments, five people" "$(echo "$BK" | py 'print(len(d["items"]), len(d["staff"]))')" '^3 5$'
sku(){ echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="'$1'"][0])'; }
num(){ echo "$BK" | py 'print([s["id"] for s in d["staff"] if s["num"]=="'$1'"][0])'; }
UT=$(sku UT); UP=$(sku UP); FJ=$(sku FJ)
OWEN=$(num 1); NORA=$(num 2); KIRA=$(num 3); ELLA=$(num 4); SAM=$(num 5)
echo "== a new starter's kit is an ordinary issue"
check "Owen, holding nothing, takes his three-set kit" "$(issue "$OWEN" "$(L $UT 3 stock),$(L $UP 3 stock)")" '"stock":6'
check " and nothing on his record is marked an override" "$(overq "$OWEN")" '^0$'
echo "== six sets held, and not one more — the yearly figure governs nothing"
check "Ella, register figure 1, takes six sets" "$(issue "$ELLA" "$(L $UT 6 stock),$(L $UP 6 stock)")" '"stock":12'
R=$(issue "$ELLA" "$(L $UT 1 stock)")
check " a seventh top is refused" "$R" "$CAP"
check " and the refusal says what would make room" "$R" 'Hand a top in'
check " and wrote nothing" "$(held "$ELLA" "$UT")" '^6$'
echo "== handing one in makes room, credit box unticked"
check "Ella hands a top back" "$(handin "$ELLA" "$UT")" '"good":1'
check " and the replacement goes through" "$(issue "$ELLA" "$(L $UT 1 stock)")" '"stock":1'
check " and she is back at six tops" "$(held "$ELLA" "$UT")" '^6$'
echo "== the ceiling bites on each half, not on whole sets"
check "Kira takes six tops and no trousers" "$(issue "$KIRA" "$(L $UT 6 stock)")" '"stock":6'
check " a seventh top is refused though she holds no whole set" "$(issue "$KIRA" "$(L $UT 1 stock)")" "$CAP"
check " the other half still has room" "$(issue "$KIRA" "$(L $UP 6 stock)")" '"stock":6'
echo "== nursing is capped like everybody else"
check "Nora, a nurse, is refused a seventh top" "$(issue "$NORA" "$(L $UT 7 order)")" "$CAP"
echo "== an override is real, and recorded"
check "Nora takes seven with the coordinator's override" "$(issue "$NORA" "$(L $UT 7 stock)" ',"override":true')" '"stock":7'
check " and all seven are marked an override" "$(overq "$NORA")" '^7$'
echo "== garments on order count, across visits"
check "Sam has six tops ordered in" "$(issue "$SAM" "$(L $UT 6 order)")" '"ordered":6'
check " a top off the shelf on a later visit is refused" "$(issue "$SAM" "$(L $UT 1 stock)")" "$CAP"
echo "== pre-loved counts, and handing pre-loved back frees room"
check "six pre-loved trousers into the pool" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$UP\",\"si\":0,\"qty\":6}]}")" '"ok":true'
check "Sam takes six pre-loved trousers" "$(issue "$SAM" "$(L $UP 6 preloved)")" '"preloved":6'
check " a new pair on top of them is refused" "$(issue "$SAM" "$(L $UP 1 stock)")" "$CAP"
check "Sam hands a pre-loved pair back" "$(handin "$SAM" "$UP")" '"good":1'
check " and a new pair goes through" "$(issue "$SAM" "$(L $UP 1 stock)")" '"stock":1'
echo "== garments in no set carry their own ceiling"
check "Kira is refused seven fleeces" "$(issue "$KIRA" "$(L $FJ 7 stock)")" 'outside a set is the most anyone holds'
check " and given six" "$(issue "$KIRA" "$(L $FJ 6 stock)")" '"stock":6'
echo "== the route is the facility's answer, and the wearer is told it"
# Read off Owen's own request screen, which puts the sentence allowance() in lib/sets.ts writes in
# front of a wearer before they ask for anything. His group's place on the lists is the only thing
# moved between the reads below, so the setting is the only thing that can change what he is told.
CODE=$(mut staff.selfCode "{\"id\":\"$OWEN\"}" | py "print(d['result']['code'])")
check "Owen claims his staff account" "$(curl -s -c "$W" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"owen$TS@example.com\",\"password\":\"wearerpass1\"}")" '"ok":true'
mine(){ curl -s -b "$W" "$B/my/request"; }
# Four rather than the standing three, so the number on his screen can only have come from this
# facility's own setting.
KIT='4 sets on starting'; SIGNED='each one approved by a manager'; HOURS='hours worked propose the starting number'
check "this facility's starting kit is four sets" "$(mut settings.update '{"initialSets":4}')" '"ok":true'
R=$(mine)
check "Support Services is on the starting kit, so Owen is offered four sets to start" "$R" "$KIT"
no " and is not told each set waits on a manager" "$R" "$SIGNED"
check "the same group taken off the starting kit" "$(mut settings.update '{"kitGroups":[]}')" '"ok":true'
R=$(mine)
check " puts Owen on manager approval" "$R" "$SIGNED"
no " with no starting kit offered" "$R" 'sets on starting'
echo "== one route per group"
check "Support Services goes back on the starting kit" "$(mut settings.update '{"kitGroups":["Support Services"]}')" '"ok":true'
# Spelt the way a roster export spells it, because group names are compared with case and stray spaces
# set aside, and a refusal that only caught an exact match would let one team onto both routes under a
# second spelling. Sent on its own, so it is settled against the starting-kit list already stored.
R=$(mut settings.update '{"nursingGroups":["Registered Nurse","Enrolled Nurse","support services "]}')
check "the FTE table cannot take a group already on the starting kit" "$R" 'can.t be on the FTE table and the starting kit at once'
check " and the refused save changed neither list" "$(groups)" '|Registered Nurse,Enrolled Nurse|Support Services$'
check "moving it across, both lists in one save, is allowed" "$(mut settings.update '{"nursingGroups":["Registered Nurse","Enrolled Nurse","Support Services"],"kitGroups":[]}')" '"ok":true'
check " and puts Owen on the FTE table" "$(mine)" "$HOURS"
check "and moving it back the same way" "$(mut settings.update '{"nursingGroups":["Registered Nurse","Enrolled Nurse"],"kitGroups":["Support Services"]}')" '"ok":true'
check " offers him the starting kit again" "$(mine)" "$KIT"
echo "== renaming a group keeps it on its route"
R=$(mut settings.renameGroup '{"from":"Support Services","to":"Housekeeping"}')
check "Support Services is renamed Housekeeping" "$R" '"ok":true'
check " moving the one person filed under it" "$R" '"staff":1'
check " on the facility's list and the starting kit's alike" "$(groups)" '^Registered Nurse,Enrolled Nurse,Housekeeping,Kitchen,Security|Registered Nurse,Enrolled Nurse|Housekeeping$'
check " and on Owen's record" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$OWEN'][0])")" '^Housekeeping$'
R=$(mine)
check "Owen is still offered the starting kit" "$R" "$KIT"
no " and the rename did not drop him onto manager approval" "$R" "$SIGNED"
# Renaming onto a name already in use would be two teams under one name, one of them quietly taking
# the other's route.
check "renaming onto a name already in use is refused" "$(mut settings.renameGroup '{"from":"Kitchen","to":"housekeeping"}')" 'already a staff group here'
check " and Kira is still in Kitchen" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$KIRA'][0])")" '^Kitchen$'
echo "== a group with people in it can't be taken off the list"
# Dropping a group that still has people filed under it would move them all to manager approval with
# nothing on any screen to say so, so the save is refused — while a group nobody is in goes freely.
# The list and Kira's group are read off the facility each time, because the sections above rename.
LIST(){ bk | py 'print(json.dumps(d["facility"]["staffGroups"]))'; }
KGRP=$(bk | py 'print([s["group"] for s in d["staff"] if s["num"]=="3"][0])')
check "an empty group can be added" "$(mut settings.update "{\"staffGroups\":$(LIST | py 'print(json.dumps(d+["Porters"]))')}")" '"ok":true'
check " and taken off again, with nobody in it" "$(mut settings.update "{\"staffGroups\":$(LIST | py 'print(json.dumps([g for g in d if g!="Porters"]))')}")" '"ok":true'
check "a group with people filed under it is refused" "$(mut settings.update "{\"staffGroups\":$(LIST | KGRP="$KGRP" py 'import os; print(json.dumps([g for g in d if g!=os.environ["KGRP"]]))')}")" 'filed under it'
check " and stays on the list" "$(LIST)" "$KGRP"
echo "== a garment is for the staff groups it is tagged with"
# The owner's rule: a garment may be tagged for several staff groups, and everybody is offered their
# own group's garments plus those for every group. Everybody below holds nothing when first served and
# asks for one garment at a time, so the ceiling never comes into it — the group is the only thing
# that can refuse any of this, and each refusal has somebody whose group it is for beside it.
check "a garment tagged for two groups and one for a third, off the catalogue CSV" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Ward Tunic","sku":"WT","type":"Tunic","supplier":"Alpha Supply","cost":"32","group":"Registered Nurse|Enrolled Nurse","sizes":"M"},{"item":"Patrol Shirt","sku":"PS","type":"Shirt","supplier":"Alpha Supply","cost":"28","group":"Security","sizes":"M"}]}')" '"created":2'
mut import.rows '{"kind":"opening","rows":[{"sku":"WT","size":"M","opening":"20"},{"sku":"PS","size":"M","opening":"20"}]}' >/dev/null
# Mara is the manager all three report to, so the staff app has somebody to send their asks to, and
# so she can be seen raising for one of them.
check "four more people, three of them reporting to the fourth" "$(mut import.rows '{"kind":"staff","rows":[{"num":"6","first":"Enzo","last":"Enrolled","group":"Enrolled Nurse","dept":"Ward 1","fte":"1.0","top":"M","pants":"M","manager":"9"},{"num":"7","first":"Rhea","last":"Registered","group":"Registered Nurse","dept":"Ward 1","fte":"1.0","top":"M","pants":"M","manager":"9"},{"num":"8","first":"Gus","last":"Guard","group":"Security","dept":"Ward 1","top":"M","pants":"M","manager":"9"},{"num":"9","first":"Mara","last":"Manager","group":"Registered Nurse","dept":"Ward 1","fte":"1.0"}]}')" '"created":4'
BK=$(bk)
WT=$(sku WT); PS=$(sku PS)
ENZO=$(num 6); RHEA=$(num 7); GUS=$(num 8); MARA=$(num 9)
check " all three report to Mara" "$(echo "$BK" | py "print(len([s for s in d['staff'] if s['managerId']=='$MARA']))")" '^3$'
# The list as stored and the CSV's own spelling of it beside it, which is what a backup carries.
check "the tunic holds both groups, in the order they were written" "$(echo "$BK" | py "i=[i for i in d['items'] if i['sku']=='WT'][0]; print(','.join(i['groups']), i['group'])")" '^Registered Nurse,Enrolled Nurse Registered Nurse|Enrolled Nurse$'
check " and the shirt its one" "$(echo "$BK" | py "print(','.join([i['groups'] for i in d['items'] if i['sku']=='PS'][0]))")" '^Security$'
EJ="$T/tc-sets-enzo.txt"; RJ="$T/tc-sets-rhea.txt"; GJ="$T/tc-sets-gus.txt"; MJ="$T/tc-sets-mara.txt"; rm -f "$EJ" "$RJ" "$GJ" "$MJ"
claim(){ local code; code=$(mut staff.selfCode "{\"id\":\"$1\"}" | py "print(d['result']['code'])")
curl -s -c "$2" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$code\",\"email\":\"$3$TS@example.com\",\"password\":\"wearerpass1\"}"; }
smut(){ curl -s -b "$2" -c "$2" -X POST "$B/api/staff/mutate" -H 'content-type: application/json' -H "origin: $B" -d "{\"op\":\"$1\",\"payload\":$3}"; }
check "all four claim staff accounts" "$(printf '%s\n' "$(claim "$ENZO" "$EJ" enzo)" "$(claim "$RHEA" "$RJ" rhea)" "$(claim "$GUS" "$GJ" gus)" "$(claim "$MARA" "$MJ" mara)" | grep -c '"ok":true')" '^4$'
# What each of them is offered to ask for, read off their own request screen. Every "not offered"
# sits beside something the same page does offer, so a page that failed to render fails there.
shop(){ curl -s -b "$1" "$B/my/request"; }
R=$(shop "$EJ")
check "Enzo, an enrolled nurse, is offered the tunic tagged for both nursing groups" "$R" 'Ward Tunic'
check " and the jacket for every group" "$R" 'Fleece Jacket'
no " and not the shirt for Security" "$R" 'Patrol Shirt'
R=$(shop "$RJ")
check "Rhea, a registered nurse, is offered the same tunic" "$R" 'Ward Tunic'
no " and not the shirt for Security either" "$R" 'Patrol Shirt'
R=$(shop "$GJ")
check "Gus, in Security, is offered his own group's shirt" "$R" 'Patrol Shirt'
check " and the jacket for every group" "$R" 'Fleece Jacket'
no " and never the nurses' tunic" "$R" 'Ward Tunic'
echo "== the staff app refuses a garment for another group"
ASK(){ echo "{\"lines\":[$1],\"reason\":\"Worn out\"}"; }
RL(){ echo "{\"itemId\":\"$1\",\"si\":0,\"qty\":1}"; }
R=$(smut request.create "$GJ" "$(ASK "$(RL "$WT")")")
check "Gus asking for the nurses' tunic is refused" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only\. You.re in Security'
no " and nothing is raised" "$R" '"code"'
R=$(smut request.create "$GJ" "$(ASK "$(RL "$PS"),$(RL "$WT")")")
check " so is the tunic asked for beside his own shirt" "$R" 'Ward Tunic is for'
no " and the refusal names only the garment that isn't his" "$R" 'Patrol Shirt is for'
check "his own group's shirt and the jacket for everyone go through" "$(smut request.create "$GJ" "$(ASK "$(RL "$PS"),$(RL "$FJ")")")" '"code"'
check "Enzo asking for the tunic tagged for his group goes through" "$(smut request.create "$EJ" "$(ASK "$(RL "$WT")")")" '"code"'
# Measured against the person it is for, whoever raises it.
check "Mara cannot raise the tunic for Gus" "$(smut request.create "$MJ" "{\"subjectId\":\"$GUS\",\"lines\":[$(RL "$WT")],\"reason\":\"Worn out\"}")" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only\. Gus is in Security\.'
check " but can raise it for Enzo" "$(smut request.create "$MJ" "{\"subjectId\":\"$ENZO\",\"lines\":[$(RL "$WT")],\"reason\":\"Worn out\"}")" '"code"'
check "Gus cannot queue for the tunic either" "$(smut waitlist.join "$GJ" "{\"itemId\":\"$WT\",\"si\":0}")" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only'
check " while queueing for his own shirt is allowed" "$(smut waitlist.join "$GJ" "{\"itemId\":\"$PS\",\"si\":0}")" '"id"'
echo "== at the counter it takes the override, recorded apart from the ceiling's"
# Each issue row as SKU=offGroup/override, for one person, sorted by garment.
flags(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s' % (sk[r['itemId']], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1')))"; }
notes(){ bk | py "print(' | '.join(o['notes'] for o in d['orders'] if o['staffId']=='$1'))"; }
R=$(issue "$GUS" "$(L $WT 1 stock)")
check "the tunic is refused to Gus without the override" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse .*Gus Guard is in Security'
check " and the refusal says what lets it through" "$R" 'Tick the coordinator override'
no " and is not the ceiling's refusal" "$R" "$CAP"
check " and wrote nothing" "$(held "$GUS" "$WT")" '^0$'
check "Enzo is handed the same tunic with no override" "$(issue "$ENZO" "$(L $WT 1 stock)")" '"stock":1'
check " and so is Rhea" "$(issue "$RHEA" "$(L $WT 1 stock)")" '"stock":1'
R=$(issue "$GUS" "$(L $WT 1 stock),$(L $PS 1 stock)" ',"override":true')
check "with the override Gus takes the tunic, and his own shirt beside it" "$R" '"stock":2'
check " and the counter is told one garment was outside his group" "$R" '"offGroup":1'
# Two garments, well inside six sets: the tunic's row says outside his group, the shirt's says
# nothing, and neither says override — that word stays the ceiling's.
check " the tunic's row is marked outside his group, and not as an override" "$(flags "$GUS")" '^PS=False/False WT=True/False$'
check " while Enzo's tunic is marked neither" "$(flags "$ENZO")" '^WT=False/False$'
# Ordered in, there is no issue row to mark yet, so the supplier order carries it.
check "the tunic ordered in for Gus on the override" "$(issue "$GUS" "$(L $WT 1 order)" ',"override":true')" '"ordered":1'
check " and the supplier order says so, and who allowed it" "$(notes "$GUS")" "Outside Gus.s staff group: Ward Tunic .*override recorded by Sets Admin"
check "Enzo's tunic ordered in the same way" "$(issue "$ENZO" "$(L $WT 1 order)")" '"ordered":1'
check " carries the ordinary note" "$(notes "$ENZO")" 'Ordered at issue for Enzo Enrolled'
no " and nothing about a staff group" "$(notes "$ENZO")" 'Outside'
# The same ask refused to him at the top of this section. Holding one now changes nothing: a fresh
# request is measured against his group, whatever the counter handed him on its override, because
# holding another group's garment is no reason to be handed a second one.
R=$(smut request.create "$GJ" "$(ASK "$(RL "$WT")")")
check "once Gus holds the tunic, the staff app still refuses him another" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only\. You.re in Security'
no " and nothing is raised" "$R" '"code"'
# The one exemption, and the neighbour that keeps the refusal honest: the very garment he holds,
# reported damaged, is replaced like for like.
GWT=$(bk | py "print([i['id'] for i in d['issues'] if i['staffId']=='$GUS' and i['itemId']=='$WT' and not i['handedIn'] and not i['returnedDate']][0])")
check " but reporting that tunic torn raises its replacement" "$(smut damage.report "$GJ" "{\"issueId\":\"$GWT\",\"kind\":\"Torn\",\"replace\":true}")" '"replacement":{"id":"[^"]*","code"'
echo "== the phone's one-group label, saved back as it was shown"
# The phone counter's catalogue card sends one group string, and shows a garment with several groups
# as one label. Saving its other fields sends that label straight back, and must leave every group
# in place; picking one group there still moves the garment to it, so the label is not just ignored.
GRPS(){ bk | py "print(','.join([i['groups'] for i in d['items'] if i['id']=='$1'][0]))"; }
check "the tunic saved with its label as shown and a new price" "$(mut catalog.update "{\"id\":\"$WT\",\"group\":\"Registered Nurse, Enrolled Nurse\",\"cost\":33}")" '"ok":true'
check " took the price" "$(bk | py "print('%g' % [i['cost'] for i in d['items'] if i['id']=='$WT'][0])")" '^33$'
check " and kept both its groups" "$(GRPS "$WT")" '^Registered Nurse,Enrolled Nurse$'
check "saved with one group picked" "$(mut catalog.update "{\"id\":\"$WT\",\"group\":\"Enrolled Nurse\"}")" '"ok":true'
check " it is for that group alone" "$(GRPS "$WT")" '^Enrolled Nurse$'
check "the desktop's list puts both back" "$(mut catalog.update "{\"id\":\"$WT\",\"groups\":[\"Registered Nurse\",\"Enrolled Nurse\"]}")" '"ok":true'
check " and it holds both again" "$(GRPS "$WT")" '^Registered Nurse,Enrolled Nurse$'
echo "== the counter's request door asks the same question"
# request.raise is the linen room raising for somebody without a phone. The hand-over at the end of a
# request trusts that the garments on it are the wearer's group's, so this door has to refuse what the
# staff app refuses — or it is the way round it. It has no override of its own.
reqs(){ bk | py "print(len([r for r in d['requests'] if r['subjectId']=='$1']))"; }
N0=$(reqs "$GUS")
R=$(mut request.raise "{\"staffId\":\"$GUS\",\"lines\":[$(RL "$PS"),$(RL "$WT")],\"reason\":\"Worn out\"}")
check "the counter cannot raise the nurses' tunic for Gus" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only .*Gus Guard is in Security, and a request can only carry'
check " and is told the Issue screen's override is the way" "$R" 'on the Issue screen with the coordinator override ticked'
no " and the refusal names only the garment that isn't his" "$R" 'Patrol Shirt is for'
check " and raised nothing" "$(reqs "$GUS")" "^$N0\$"
check "his own shirt and the jacket for everyone, raised the same way, go through" "$(mut request.raise "{\"staffId\":\"$GUS\",\"lines\":[$(RL "$PS"),$(RL "$FJ")],\"reason\":\"Worn out\"}")" '"code"'
check " and are on his record as one request" "$(reqs "$GUS")" "^$((N0+1))\$"
echo "== an order for somebody carries their own group's garments"
# What reaches a person off an order is issued at pickup with nobody deciding anything, so the order
# is where the group is asked. A stock order is for nobody, and may carry anything.
ords(){ bk | py "print(len([o for o in d['orders'] if o['staffId']=='$1']))"; }
OL(){ echo "{\"itemId\":\"$1\",\"size\":\"M\",\"qty\":1}"; }
N0=$(ords "$GUS")
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$GUS\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$WT")]}")
check "an order for Gus carrying the nurses' tunic is refused" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only .*Gus Guard is in Security, and an order for somebody can only carry'
check " and is pointed at Order in with the override" "$R" 'use Order in on the Issue screen with the coordinator override ticked'
check " and wrote no order" "$(ords "$GUS")" "^$N0\$"
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$GUS\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$PS")]}")
check "an order for Gus carrying his own shirt is written" "$R" '"code"'
GD=$(echo "$R" | py "print(d['result']['id'])")
check " and is his" "$(ords "$GUS")" "^$((N0+1))\$"
# Growing an order is putting a garment on it, and is asked the same question.
check "the tunic can't be added to it afterwards" "$(mut order.lineAdd "{\"id\":\"$GD\",\"itemId\":\"$WT\",\"size\":\"M\",\"qty\":1}")" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only'
check " while the jacket for everyone can" "$(mut order.lineAdd "{\"id\":\"$GD\",\"itemId\":\"$FJ\",\"size\":\"M\",\"qty\":1}")" '"ok":true'
check " leaving the shirt and the jacket on it, and no tunic" "$(bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted(sk[l['itemId']] for l in [o for o in d['orders'] if o['id']=='$GD'][0]['lines'])))")" '^FJ PS$'
R=$(mut order.create "{\"orderFor\":\"Stock\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$WT")]}")
check "a stock order carries the tunic, being for nobody" "$R" '"code"'
SO=$(echo "$R" | py "print(d['result']['id'])")
FOR(){ bk | py "print([o['orderFor'] for o in d['orders'] if o['id']=='$1'][0])"; }
check " but can't then be put in Gus's name" "$(mut order.update "{\"id\":\"$SO\",\"staffId\":\"$GUS\"}")" 'Take it off this order first'
check " and is still a stock order" "$(FOR "$SO")" '^Stock$'
check " while Enzo's name, whose group it is for, goes on it" "$(mut order.update "{\"id\":\"$SO\",\"staffId\":\"$ENZO\"}")" '"ok":true'
check " making it his" "$(FOR "$SO")" '^Staff Member$'
echo "== ordered in on the override, the garment is marked when it reaches them"
# Gus's tunic and Enzo's were both ordered in at the counter further up, Gus's on the override. The
# supplier order carries the note; the issue row the collection writes has to carry the mark, or the
# Exceptions report never sees the garment in the month it reached him.
openOrd(){ bk | py "o=[o['id'] for o in d['orders'] if o['staffId']=='$1' and o['status']=='Ordered']; print(o[0] if len(o)==1 else 'expected one open order, found %d' % len(o))"; }
toCounter(){ mut order.receive "{\"id\":\"$1\",\"lines\":$(bk | py "o=[o for o in d['orders'] if o['id']=='$1'][0]; print(json.dumps([{'lineId': l['id'], 'arrived': l['qty'], 'dest': 'pickup'} for l in o['lines']]))")}"; }
puOf(){ bk | py "p=[p['id'] for p in d['pickups'] if p['orderId']=='$1' and not p['pickedUp']]; print(p[0] if p else 'none')"; }
# The issue rows one order turned into, for one person, as SKU=offGroup.
got(){ bk | py "c=[o['code'] for o in d['orders'] if o['id']=='$2'][0]; sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s' % (sk[r['itemId']], r['offGroup']) for r in d['issues'] if r['staffId']=='$1' and r['direct'] and r['orderCode']==c)))"; }
GO=$(openOrd "$GUS"); EO=$(openOrd "$ENZO")
check "Gus's tunic arrives and waits at the counter" "$(toCounter "$GO")" '"ok":true'
check " and so does Enzo's" "$(toCounter "$EO")" '"ok":true'
check "Gus collects his" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$GO")\"}")" '"ok":true'
check " and the issue row it wrote is marked outside his group" "$(got "$GUS" "$GO")" '^WT=True$'
check "Enzo collects his" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$EO")\"}")" '"ok":true'
check " and his is marked nothing" "$(got "$ENZO" "$EO")" '^WT=False$'
# The ward round hands over the same way, and marks the same way.
check "another tunic ordered in for Gus on the override" "$(issue "$GUS" "$(L $WT 1 order)" ',"override":true')" '"ordered":1'
check " and one for Rhea, whose group it is for" "$(issue "$RHEA" "$(L $WT 1 order)")" '"ordered":1'
GO=$(openOrd "$GUS"); RO=$(openOrd "$RHEA")
check "both arrive for the ward round" "$(printf '%s\n' "$(toCounter "$GO")" "$(toCounter "$RO")" | grep -c '"ok":true')" '^2$'
check "both are signed for on the round" "$(printf '%s\n' "$(mut pickup.deliver "{\"id\":\"$(puOf "$GO")\",\"deliveredTo\":\"Ward 1 desk\"}")" "$(mut pickup.deliver "{\"id\":\"$(puOf "$RO")\",\"deliveredTo\":\"Ward 1 desk\"}")" | grep -c '"ok":true')" '^2$'
check " Gus's row marked outside his group" "$(got "$GUS" "$GO")" '^WT=True$'
check " and Rhea's not" "$(got "$RHEA" "$RO")" '^WT=False$'
echo "== a partial hand-in keeps the mark on both halves"
# Two of a garment handed over in one act are one issue row; handing one back splits it, and the
# half that stays out and the half that came back were both handed over outside her group. Her own
# group's shirt goes through the same split beside it, so a split that marked everything fails there.
check "one more guard on the register" "$(mut import.rows '{"kind":"staff","rows":[{"num":"10","first":"Tess","last":"Guard","group":"Security","dept":"Ward 1","top":"M","pants":"M"}]}')" '"created":1'
TESS=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="10"][0])')
R=$(issue "$TESS" "$(L $WT 2 stock),$(L $PS 2 stock)" ',"override":true')
check "Tess takes two nurses' tunics on the override, and two of her own shirts" "$R" '"stock":4'
check " one garment of them outside her group" "$R" '"offGroup":1'
check "she hands one tunic back" "$(handin "$TESS" "$WT")" '"good":1'
check " and one shirt" "$(handin "$TESS" "$PS")" '"good":1'
# Each issue row as SKU:qty:offGroup:held-or-in.
rows(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s:%d:%s:%s' % (sk[r['itemId']], r['qty'], r['offGroup'], 'in' if r['handedIn'] else 'held') for r in d['issues'] if r['staffId']=='$1')))"; }
check " each row split in two, both halves of the tunic marked and neither half of the shirt" "$(rows "$TESS")" '^PS:1:False:held PS:1:False:in WT:1:True:held WT:1:True:in$'
echo "== a list of groups typed on the phone"
# The phone sends one group string. One matching no garment's label is a list somebody typed, and
# kept whole it would be a single group called "Kitchen, Security" that nobody is in — refusing the
# garment to Kitchen and Security alike. Kira is in Kitchen: before, the shirt is another group's;
# after, what stops her is the six tops she already holds, and nothing about her group.
R=$(issue "$KIRA" "$(L $PS 1 stock)")
check "the shirt is refused to Kira as another group's" "$R" 'Patrol Shirt is for Security .*Kira Kitchen is in Kitchen'
check "the shirt saved from the phone as a group string no garment is labelled with" "$(mut catalog.update "{\"id\":\"$PS\",\"group\":\"Kitchen, Security\"}")" '"ok":true'
check " is stored as two groups, not one called both" "$(bk | py "print(json.dumps([i['groups'] for i in d['items'] if i['id']=='$PS'][0]))")" '^\["Kitchen", "Security"\]$'
R=$(issue "$KIRA" "$(L $PS 1 stock)")
no " so Kira is no longer told it is another group's" "$R" 'is for'
check " only that she already holds six tops" "$R" "$CAP"
echo "== outside the group and past six, in one refusal"
# One override tick answers both questions, so a cart that is both another group's AND past six sets
# has to say both in the one refusal — or ticking the box for the group would wave the ceiling through
# with nobody told. Kira holds six tops and the tunic is a top for the nurses; Tess, beside her, holds
# two, so the same tunic refused to her is the group's refusal alone.
R=$(issue "$KIRA" "$(L $WT 1 stock)")
check "the nurses' tunic for Kira, holding six tops, is refused as another group's" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse .*Kira Kitchen is in Kitchen'
check " and the same refusal says it would also take her past six" "$R" 'It would also take them past what one person holds'
check " naming the ceiling" "$R" "$CAP"
check " and wrote nothing" "$(held "$KIRA" "$WT")" '^0$'
R=$(issue "$TESS" "$(L $WT 1 stock)")
check "the same tunic for Tess, well inside six, is refused as another group's" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse .*Tess Guard is in Security'
no " with nothing about the ceiling" "$R" 'It would also take them past'
echo "== handed over after the wearer changed group, the garment is marked"
# Both doors that raise a request refuse another group's garment, and a hand-over is not a second
# chance to refuse what a manager approved. So a wearer moved to another group between the ask and the
# collection is still handed it, and the issue row is marked outside their group. Enzo and Rhea both
# ask for the nurses' tunic while it is theirs, one bag each for the counter and one each for the ward
# round; then Enzo is moved to Security and Rhea stays where she is. Each hand-over is read as the one
# new issue row it wrote, as SKU=offGroup/override.
ids(){ bk | py "print(' '.join(i['id'] for i in d['issues'] if i['staffId']=='$1'))"; }
since(){ bk | B4="$2" py "import os; b=set(os.environ['B4'].split()); sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s' % (sk[r['itemId']], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1' and r['id'] not in b)))"; }
rid(){ echo "$1" | py "print(d['result']['id'])" 2>/dev/null; }
Q1=$(smut request.create "$EJ" "$(ASK "$(RL "$WT")")"); Q2=$(smut request.create "$EJ" "$(ASK "$(RL "$WT")")")
Q3=$(smut request.create "$RJ" "$(ASK "$(RL "$WT")")"); Q4=$(smut request.create "$RJ" "$(ASK "$(RL "$WT")")")
check "Enzo asks for the tunic twice, and so does Rhea" "$(printf '%s\n' "$Q1" "$Q2" "$Q3" "$Q4" | grep -c '"code"')" '^4$'
EC=$(rid "$Q1"); ER=$(rid "$Q2"); RC=$(rid "$Q3"); RR=$(rid "$Q4")
check " and Mara approves all four" "$(for r in $EC $ER $RC $RR; do smut request.approve "$MJ" "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"accepted"')" '^4$'
check "Mara takes the ward-desk flag, so the round has somebody on Ward 1 to sign" "$(mut staff.patch "{\"id\":\"$MARA\",\"wardDesk\":true}")" '"ok":true'
check "the linen room picks all four" "$(for r in $EC $ER $RC $RR; do mut request.pick "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"picking"')" '^4$'
check " holds one of each at the counter" "$(for r in $EC $RC; do mut request.hold "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"ready"')" '^2$'
check " and sends the other two on the round" "$(for r in $ER $RR; do mut request.round "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"round"')" '^2$'
# Queued for the same tunic while it is theirs, for the waitlist section below.
WQE=$(smut waitlist.join "$EJ" "{\"itemId\":\"$WT\",\"si\":0}"); WQR=$(smut waitlist.join "$RJ" "{\"itemId\":\"$WT\",\"si\":0}")
check "both queue for the tunic too, while it is their group's" "$(printf '%s\n' "$WQE" "$WQR" | grep -c '"id"')" '^2$'
# staff.patch carries no group; the staff record's own save is what moves somebody to another group.
check "Enzo is moved to Security" "$(mut staff.save "{\"id\":\"$ENZO\",\"num\":\"6\",\"first\":\"Enzo\",\"last\":\"Enrolled\",\"group\":\"Security\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\"}")" '"ok":true'
check " and is in it" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$ENZO'][0])")" '^Security$'
E0=$(ids "$ENZO"); R0=$(ids "$RHEA")
check "Enzo collects his bag at the counter" "$(mut request.collected "{\"id\":\"$EC\"}")" '"status":"collected"'
check " and its row is marked outside his group, and not as an override" "$(since "$ENZO" "$E0")" '^WT=True/False$'
check "Rhea collects hers" "$(mut request.collected "{\"id\":\"$RC\"}")" '"status":"collected"'
check " and hers is marked neither" "$(since "$RHEA" "$R0")" '^WT=False/False$'
E0=$(ids "$ENZO"); R0=$(ids "$RHEA")
check "Mara signs for Enzo's bag on the round" "$(smut round.sign "$MJ" "{\"id\":\"$ER\"}")" '"ok":true'
check " and its row is marked outside his group" "$(since "$ENZO" "$E0")" '^WT=True/False$'
check "and for Rhea's" "$(smut round.sign "$MJ" "{\"id\":\"$RR\"}")" '"ok":true'
check " and hers is not" "$(since "$RHEA" "$R0")" '^WT=False/False$'
echo "== a place in the queue for another group's garment isn't offered"
# Joining is refused for another group's garment, but a place joined while it was theirs outlives a
# move to another group. Offering it would hold the stock for forty-eight hours for somebody whose
# acceptance is then refused, so the offer is refused — beside Rhea's place for the same tunic.
WE=$(rid "$WQE"); WR=$(rid "$WQR")
offered(){ bk | py "print([w['offeredAt'] is not None for w in d['waitlist'] if w['id']=='$1'][0])"; }
R=$(mut waitlist.offer "{\"id\":\"$WE\"}")
check "the tunic can't be offered to Enzo's place, now he is in Security" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only .*Enzo Enrolled is in Security, so they can.t take it'
check " and the linen room is told to take him off the list" "$R" 'Take them off this waitlist instead'
check " and his place is not marked offered" "$(offered "$WE")" '^False$'
check "Rhea's place for the same tunic is offered" "$(mut waitlist.offer "{\"id\":\"$WR\"}")" '"heldUntil"'
check " and marked offered" "$(offered "$WR")" '^True$'
echo "== a renamed group takes its garments with it"
# Garments tagged for a group are renamed with it. Left under the old name they would be for a group
# nobody is in any more, and the person the garment is for would need the override to be handed it.
check "a cap for Security alone, off the catalogue CSV" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Guard Cap","sku":"GC","type":"Hat","supplier":"Alpha Supply","cost":"12","group":"Security","sizes":"M"}]}')" '"created":1'
mut import.rows '{"kind":"opening","rows":[{"sku":"GC","size":"M","opening":"10"}]}' >/dev/null
GC=$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="GC"][0])')
R=$(mut settings.renameGroup '{"from":"Security","to":"Protective Services"}')
check "Security is renamed Protective Services" "$R" '"ok":true'
check " moving both garments tagged for it, the cap and the shirt" "$R" '"garments":2'
check " the cap, read back off the backup, is for the new name" "$(GRPS "$GC")" '^Protective Services$'
check " the shirt keeps Kitchen beside the new name" "$(GRPS "$PS")" '^Kitchen,Protective Services$'
check " and the tunic, for neither, is untouched" "$(GRPS "$WT")" '^Registered Nurse,Enrolled Nurse$'
check " and Tess is filed under the new name" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$TESS'][0])")" '^Protective Services$'
check "Tess, in the renamed group, is handed the cap with no override" "$(issue "$TESS" "$(L $GC 1 stock)")" '"stock":1'
check " and its row is marked neither outside her group nor as an override" "$(bk | py "print(' '.join('%s/%s' % (r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$TESS' and r['itemId']=='$GC'))")" '^False/False$'
R=$(issue "$RHEA" "$(L $GC 1 stock)")
check "while Rhea, a nurse, is refused it under its new name" "$R" 'Guard Cap is for Protective Services .*Rhea Registered is in Registered Nurse'
check " and wrote nothing" "$(held "$RHEA" "$GC")" '^0$'
echo "== the cut of uniform a wearer is offered"
# The owner's second rule, and deliberately the same shape as the staff group above: the staff record
# carries a Uniform style — Men's, Women's or Either — and each garment carries a cut. Somebody set to
# Men's is offered the men's cut and the unisex range, somebody set to Women's the women's and the
# unisex, somebody set to Either every cut. Blank is the fourth state and the one every record on
# every register reads until a coordinator sets it: nobody has said, and nothing is refused.
#
# The people below are new to the register and hold nothing, and each asks for one or two garments at
# a time against a ceiling of six, so the ceiling never comes into any of this — the cut is the only
# thing that can refuse anything here, and each refusal sits beside the same ask from somebody whose
# cut it is. Kitchen and the two nursing groups are used throughout because the renames above leave
# them under the names they were given at the top of the file.
#
# The types are given outright: the blouse and both shirts are tops, so the two cuts sit in the same
# half of a set and nothing below turns on which bucket a garment fell into.
check "a women's cut, a men's cut, and a women's cut for the nurses alone" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Ward Blouse","sku":"WB","type":"Blouse","gender":"Female","supplier":"Alpha Supply","cost":"36","group":"All","sizes":"M"},{"item":"Field Shirt","sku":"FS","type":"Shirt","gender":"Male","supplier":"Alpha Supply","cost":"34","group":"All","sizes":"M"},{"item":"Theatre Scrub Top","sku":"TT","type":"Scrub top","gender":"Female","supplier":"Alpha Supply","cost":"33","group":"Registered Nurse|Enrolled Nurse","sizes":"M"}]}')" '"created":3'
mut import.rows '{"kind":"opening","rows":[{"sku":"WB","size":"M","opening":"20"},{"sku":"FS","size":"M","opening":"20"},{"sku":"TT","size":"M","opening":"20"}]}' >/dev/null
# Four more people, their cuts written in the register CSV's own column and in the spellings a file
# actually arrives in — "Mens" without the apostrophe, "both" for the one who sees everything — so a
# rule that only matched the words a picker offers fails here. Dana's and Robin's cells are empty,
# which is every record on every register today.
check "four more people, two of them with a cut on the file" "$(mut import.rows '{"kind":"staff","rows":[{"num":"11","first":"Dana","last":"Lee","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9"},{"num":"12","first":"Milo","last":"Reed","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9","style":"Mens"},{"num":"13","first":"Ash","last":"Quinn","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9","style":"both"},{"num":"14","first":"Robin","last":"Vale","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9"}]}')" '"created":4'
BK=$(bk)
WB=$(sku WB); FS=$(sku FS); TT=$(sku TT)
DANA=$(num 11); MILO=$(num 12); ASH=$(num 13); ROBIN=$(num 14)
# One person's cut as stored, in brackets, so blank — nobody has said — is something a check can name
# rather than an empty string that matches anything.
styleOf(){ bk | py "print('[' + [s['uniformStyle'] for s in d['staff'] if s['id']=='$1'][0] + ']')"; }
# Each issue row as SKU=offStyle/override, for one person, sorted by garment: the flags() above with
# the cut's mark in place of the group's, because the two are recorded apart and neither is `override`.
sflags(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s' % (sk[r['itemId']], r['offStyle'], r['override']) for r in d['issues'] if r['staffId']=='$1')))"; }
check "the blouse is the women's cut, the shirt the men's, the theatre top the women's" "$(echo "$BK" | py "print(' '.join(sorted(i['sku'] + '=' + i['gender'] for i in d['items'] if i['sku'] in ('WB', 'FS', 'TT'))))")" '^FS=Male TT=Female WB=Female$'
check "Milo's cut, written Mens in the file, is stored as the style itself" "$(styleOf "$MILO")" "^\[Men.s\]$"
check " and Ash's, written both, as the one that sees every cut" "$(styleOf "$ASH")" '^\[Either\]$'
check " while Dana and Robin, whose cells were empty, are blank" "$(printf '%s%s' "$(styleOf "$DANA")" "$(styleOf "$ROBIN")")" '^\[\]\[\]$'
DJ="$T/tc-sets-dana.txt"; MIJ="$T/tc-sets-milo.txt"; AJ="$T/tc-sets-ash.txt"; rm -f "$DJ" "$MIJ" "$AJ"
check "three of them claim staff accounts" "$(printf '%s\n' "$(claim "$DANA" "$DJ" dana)" "$(claim "$MILO" "$MIJ" milo)" "$(claim "$ASH" "$AJ" ash)" | grep -c '"ok":true')" '^3$'
echo "== blank is what every register reads today, and it is offered everything"
# Nothing may change for a record nobody has set. Dana is blank here and is offered, and handed, the
# men's cut — the very garment she is refused further down, once a coordinator has said which cut she
# wears. Both reads are of the same person and the same garment, so the only thing between them is
# the field.
R=$(shop "$DJ")
check "Dana, blank, is offered the men's shirt on her own request screen" "$R" 'Field Shirt'
check " and the women's blouse beside it" "$R" 'Ward Blouse'
check "Dana is handed the men's shirt at the counter with no override" "$(issue "$DANA" "$(L $FS 1 stock)")" '"stock":1'
check " and its row is marked neither the wrong cut nor an override" "$(sflags "$DANA")" '^FS=False/False$'
# Joined while nobody had said which cut she wears, for the offer section below.
WSD=$(smut waitlist.join "$DJ" "{\"itemId\":\"$FS\",\"si\":0}")
check " and she takes a place in the queue for one" "$WSD" '"id"'
WSM=$(smut waitlist.join "$MIJ" "{\"itemId\":\"$FS\",\"si\":0}")
check "Milo, set to Men's, queues for the same shirt" "$WSM" '"id"'
echo "== set to Women's, the counter refuses the men's cut without the override"
check "the coordinator sets Dana to Women's" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"Women's\"}")" '"ok":true'
check " and it is on her record" "$(styleOf "$DANA")" "^\[Women.s\]$"
# A style nothing recognises would be stored and then read as blank, quietly offering her everything
# while the screen showed a decision somebody thought they had made.
check "a word the rule doesn't know is refused rather than stored" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"whatever the ward says\"}")" 'Uniform style has to be'
check " and she is still set to Women's" "$(styleOf "$DANA")" "^\[Women.s\]$"
check "Dana takes the women's blouse with no override" "$(issue "$DANA" "$(L $WB 1 stock)")" '"stock":1'
check " and the jacket worn in every cut" "$(issue "$DANA" "$(L $FJ 1 stock)")" '"stock":1'
R=$(issue "$DANA" "$(L $FS 1 stock)")
check "the men's shirt is refused to her" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s"
check " and the refusal says what lets it through" "$R" 'Tick the coordinator override'
no " and is not the ceiling's refusal" "$R" "$CAP"
# One: the shirt she was handed before anybody set her cut. A refusal that wrote anyway reads two.
check " and wrote nothing" "$(held "$DANA" "$FS")" '^1$'
check "Milo, whose cut it is, is handed the same shirt with no override" "$(issue "$MILO" "$(L $FS 1 stock)")" '"stock":1'
check " and his row is marked neither" "$(sflags "$MILO")" '^FS=False/False$'
echo "== at the counter the cut takes the override, recorded apart from the ceiling's"
R=$(issue "$DANA" "$(L $FS 1 stock),$(L $WB 1 stock)" ',"override":true')
check "with the override Dana takes the men's shirt, and her own blouse beside it" "$R" '"stock":2'
check " and the counter is told one garment was not her cut" "$R" '"offStyle":1'
# Two garments, well inside six sets: the shirt's row says the wrong cut, the blouse's says nothing,
# and neither says override — that word stays the ceiling's.
check " the shirt's row alone is marked the wrong cut, and nothing is marked an override" "$(sflags "$DANA")" '^FJ=False/False FS=False/False FS=True/False WB=False/False WB=False/False$'
echo "== the staff app lists her own cut and the unisex range, and refuses the rest"
R=$(shop "$DJ")
check "Dana's request screen offers the women's blouse" "$R" 'Ward Blouse'
check " and the jacket worn in every cut" "$R" 'Fleece Jacket'
no " and not the men's shirt, though she is holding two" "$R" 'Field Shirt'
R=$(smut request.create "$DJ" "$(ASK "$(RL "$FS")")")
check "asking for the men's shirt is refused" "$R" "Field Shirt is the Men.s cut\. You.re set to Women.s"
no " and nothing is raised" "$R" '"code"'
check "her own blouse goes through" "$(smut request.create "$DJ" "$(ASK "$(RL "$WB")")")" '"code"'
check "Milo asking for the shirt in his own cut goes through" "$(smut request.create "$MIJ" "$(ASK "$(RL "$FS")")")" '"code"'
# Measured against the person it is for, whoever raises it.
check "Mara cannot raise the men's shirt for Dana" "$(smut request.create "$MJ" "{\"subjectId\":\"$DANA\",\"lines\":[$(RL "$FS")],\"reason\":\"Worn out\"}")" "Field Shirt is the Men.s cut\. Dana is set to Women.s\."
check " but can raise the blouse for her" "$(smut request.create "$MJ" "{\"subjectId\":\"$DANA\",\"lines\":[$(RL "$WB")],\"reason\":\"Worn out\"}")" '"code"'
check "Dana cannot queue for the men's shirt now" "$(smut waitlist.join "$DJ" "{\"itemId\":\"$FS\",\"si\":0}")" "Field Shirt is the Men.s cut"
check " while queueing for the blouse is allowed" "$(smut waitlist.join "$DJ" "{\"itemId\":\"$WB\",\"si\":0}")" '"id"'
echo "== the counter's request door and every order for her ask the same question"
N0=$(reqs "$DANA")
R=$(mut request.raise "{\"staffId\":\"$DANA\",\"lines\":[$(RL "$WB"),$(RL "$FS")],\"reason\":\"Worn out\"}")
check "the counter cannot raise the men's shirt for Dana" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s, and a request can only carry their own style"
check " and is told the Issue screen's override is the way" "$R" 'on the Issue screen with the coordinator override ticked'
no " and the refusal names only the garment that isn't her cut" "$R" 'Ward Blouse is the'
check " and raised nothing" "$(reqs "$DANA")" "^$N0\$"
check "her blouse and the jacket for every cut, raised the same way, go through" "$(mut request.raise "{\"staffId\":\"$DANA\",\"lines\":[$(RL "$WB"),$(RL "$FJ")],\"reason\":\"Worn out\"}")" '"code"'
O0=$(ords "$DANA")
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$DANA\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$FS")]}")
check "an order for Dana carrying the men's shirt is refused" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s, and an order for somebody can only carry their own style"
check " and is pointed at Order in with the override" "$R" 'use Order in on the Issue screen with the coordinator override ticked'
check " and wrote no order" "$(ords "$DANA")" "^$O0\$"
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$DANA\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$WB")]}")
check "an order for Dana carrying her own blouse is written" "$R" '"code"'
DD=$(echo "$R" | py "print(d['result']['id'])")
check "the men's shirt can't be added to it afterwards" "$(mut order.lineAdd "{\"id\":\"$DD\",\"itemId\":\"$FS\",\"size\":\"M\",\"qty\":1}")" "Field Shirt is the Men.s cut"
check " while the jacket for every cut can" "$(mut order.lineAdd "{\"id\":\"$DD\",\"itemId\":\"$FJ\",\"size\":\"M\",\"qty\":1}")" '"ok":true'
R=$(mut order.create "{\"orderFor\":\"Stock\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$FS")]}")
check "a stock order carries the men's shirt, being for nobody" "$R" '"code"'
SS=$(echo "$R" | py "print(d['result']['id'])")
check " but can't then be put in Dana's name" "$(mut order.update "{\"id\":\"$SS\",\"staffId\":\"$DANA\"}")" 'Take it off this order first'
check " and is still a stock order" "$(FOR "$SS")" '^Stock$'
check " while Milo's name, whose cut it is, goes on it" "$(mut order.update "{\"id\":\"$SS\",\"staffId\":\"$MILO\"}")" '"ok":true'
check " making it his" "$(FOR "$SS")" '^Staff Member$'
echo "== ordered in on the override, the cut is marked when the garment reaches her"
# There is no issue row to mark when a garment is ordered in, so the supplier order carries it; the
# row the collection writes has to carry the mark, or the Exceptions report never sees the garment in
# the month it reached her. Both orders below are the only open ones each of them has.
check "the men's shirt ordered in for Dana on the override" "$(issue "$DANA" "$(L $FS 1 order)" ',"override":true')" '"ordered":1'
check " and the supplier order says whose cut it isn't, and who allowed it" "$(notes "$DANA")" "Not Dana.s uniform style: Field Shirt .*override recorded by Sets Admin"
check "one ordered in for Milo, whose cut it is" "$(issue "$MILO" "$(L $FS 1 order)")" '"ordered":1'
check " carries the ordinary note" "$(notes "$MILO")" 'Ordered at issue for Milo Reed'
no " and nothing about a uniform style" "$(notes "$MILO")" 'uniform style'
# The issue rows one order turned into, for one person, as SKU=offStyle.
gotS(){ bk | py "c=[o['code'] for o in d['orders'] if o['id']=='$2'][0]; sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s' % (sk[r['itemId']], r['offStyle']) for r in d['issues'] if r['staffId']=='$1' and r['direct'] and r['orderCode']==c)))"; }
DO=$(openOrd "$DANA"); MO=$(openOrd "$MILO")
check "Dana's shirt arrives and waits at the counter" "$(toCounter "$DO")" '"ok":true'
check " and so does Milo's" "$(toCounter "$MO")" '"ok":true'
check "Dana collects hers" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$DO")\"}")" '"ok":true'
check " and the issue row it wrote is marked the wrong cut" "$(gotS "$DANA" "$DO")" '^FS=True$'
check "Milo collects his" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$MO")\"}")" '"ok":true'
check " and his is marked nothing" "$(gotS "$MILO" "$MO")" '^FS=False$'
echo "== a place in the queue for another cut isn't offered"
# Joining is refused for another cut, but Dana's place was joined before anybody had said which cut
# she wears and outlives the decision. Offering it would hold the stock for forty-eight hours for
# somebody whose acceptance her own request door now refuses — beside Milo's place for the same shirt.
WD=$(rid "$WSD"); WM=$(rid "$WSM")
R=$(mut waitlist.offer "{\"id\":\"$WD\"}")
check "the men's shirt can't be offered to Dana's place, now she is set to Women's" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s, so they can.t take it"
check " and the linen room is told to take her off the list" "$R" 'Take them off this waitlist instead'
check " and her place is not marked offered" "$(offered "$WD")" '^False$'
check "Milo's place for the same shirt is offered" "$(mut waitlist.offer "{\"id\":\"$WM\"}")" '"heldUntil"'
check " and marked offered" "$(offered "$WM")" '^True$'
echo "== Either sees both cuts, and so does a record nobody has set"
R=$(issue "$ASH" "$(L $WB 1 stock),$(L $FS 1 stock)")
check "Ash, set to Either, takes the women's blouse and the men's shirt in one cart, no override" "$R" '"stock":2'
check " and the counter is told neither was the wrong cut" "$R" '"offStyle":0'
R=$(shop "$AJ")
check " his request screen offers the women's blouse" "$R" 'Ward Blouse'
check " and the men's shirt beside it" "$R" 'Field Shirt'
check "Robin, whom nobody has set, is handed the women's blouse with no override" "$(issue "$ROBIN" "$(L $WB 1 stock)")" '"stock":1'
check " and his record is still blank rather than quietly decided for him" "$(styleOf "$ROBIN")" '^\[\]$'
echo "== another group's garment and another cut, in one refusal"
# One override tick answers both questions, so a cart that is both has to say both in the one refusal
# — or ticking the box for the cut would wave the group through with nobody told. The theatre top is
# the nurses', in the women's cut; Milo is in Kitchen and set to Men's, so it is wrong on both counts.
# Mara, a nurse nobody has set a cut for, is handed the same garment beside him.
R=$(issue "$MILO" "$(L $TT 1 stock)")
check "the nurses' women's-cut top is refused to Milo as another group's" "$R" 'Theatre Scrub Top is for Registered Nurse, Enrolled Nurse — Milo Reed is in Kitchen'
check " and, in the same refusal, as another cut" "$R" "Theatre Scrub Top is the Women.s cut — Milo Reed is set to Men.s"
check " asking for the tick once, for the one garment" "$(echo "$R" | grep -o 'Tick the coordinator override to issue it anyway' | wc -l | tr -d ' ')" '^1$'
no " with nothing about the ceiling" "$R" 'It would also take them past'
check " and wrote nothing" "$(held "$MILO" "$TT")" '^0$'
check "Mara, a nurse with no cut set, is handed the same top with no override" "$(issue "$MARA" "$(L $TT 1 stock)")" '"stock":1'
echo "== the cut survives the register CSV the settings screen imports"
# The Settings screen parses the file and posts the rows through import.rows, so the round trip worth
# testing is the template's own header row and example line, read out of csv.ts and put through that
# same op. A style column added to one and not the other, or an example the importer can't read,
# fails here rather than on a coordinator's first real file.
CSVH=$(sed -n 's/^[[:space:]]*staff:.*headers: "\([^"]*\)".*$/\1/p' "$(dirname "$0")/../lib/csv.ts")
CSVX=$(sed -n 's/^[[:space:]]*staff:.*example: `\([^`]*\)`.*$/\1/p' "$(dirname "$0")/../lib/csv.ts")
check "the staff template names a style column" "$(echo ",$CSVH,")" ',style,'
check " and its header row and example line are the same width" "$(HDR="$CSVH" EX="$CSVX" python3 -c 'import csv, io, os; print(len(next(csv.reader(io.StringIO(os.environ["HDR"])))), len(next(csv.reader(io.StringIO(os.environ["EX"])))))')" '^16 16$'
ROW=$(HDR="$CSVH" EX="$CSVX" python3 -c 'import csv, io, json, os; h=next(csv.reader(io.StringIO(os.environ["HDR"]))); x=next(csv.reader(io.StringIO(os.environ["EX"]))); r=dict(zip(h, x)); r["num"]="15"; r["manager"]="9"; print(json.dumps(r))')
check "the template's own example row imports" "$(mut import.rows "{\"kind\":\"staff\",\"rows\":[$ROW]}")" '"created":1'
FIF=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="15"][0])')
check " carrying the cut the example writes" "$(styleOf "$FIF")" "^\[Women.s\]$"
check "the same person re-imported with the style cell empty" "$(mut import.rows '{"kind":"staff","rows":[{"num":"15","first":"Mara","last":"Whitfield","style":""}]}')" '"updated":1'
check " keeps the cut that was there, as every blank cell does" "$(styleOf "$FIF")" "^\[Women.s\]$"
# A register exported from a payroll or a roster writes this column as a gender, and that is the file
# this gets loaded from, so those spellings land on a style instead of being turned away.
check "a register exported with a gender column reads as a cut" "$(mut import.rows '{"kind":"staff","rows":[{"num":"16","first":"Noor","last":"Haddad","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","gender":"F"}]}')" '"created":1'
check " and she is set to Women's" "$(styleOf "$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="16"][0])')")" "^\[Women.s\]$"
R=$(mut import.rows '{"kind":"staff","rows":[{"num":"17","first":"Rae","last":"Okonkwo","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","style":"whatever the ward says"}]}')
check "a cell the rule can't read is reported rather than guessed at" "$R" "isn.t Men.s, Women.s, Either or blank"
check " and the row is imported all the same" "$R" '"created":1'
RAE=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="17"][0])')
check " with the cut left blank" "$(styleOf "$RAE")" '^\[\]$'
check " so nothing is refused to her at the counter" "$(issue "$RAE" "$(L $FS 1 stock)")" '"stock":1'
echo "== handed over after the wearer was set to another cut, the garment is marked"
# The same shape as the staff-group section above, and there for the same reason: both doors that
# raise a request refuse another cut, and a hand-over is not a second chance to refuse what a manager
# approved. So somebody set to a cut between the ask and the collection is still handed the bag, and
# the issue row is marked the wrong cut. Vera and Nell each ask for the women's blouse twice while it
# is theirs — one bag apiece for the counter and one apiece for the ward round — and then Vera is set
# to Men's and Nell is left exactly as she was.
#
# The blouse is for every staff group and neither of them ends up holding more than two of it, so
# neither the group rule nor the six-set ceiling can mark anything here: the cut is the only thing
# between the two readings, and every row is read for all three marks rather than the one.
check "two more in Kitchen, both set to Women's, reporting to Mara" "$(mut import.rows "{\"kind\":\"staff\",\"rows\":[{\"num\":\"18\",\"first\":\"Vera\",\"last\":\"Pike\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\",\"manager\":\"9\",\"style\":\"Women's\"},{\"num\":\"19\",\"first\":\"Nell\",\"last\":\"Ross\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\",\"manager\":\"9\",\"style\":\"Women's\"}]}")" '"created":2'
VERA=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="18"][0])')
NELL=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="19"][0])')
check " and the register carries the cut for both of them" "$(printf '%s%s' "$(styleOf "$VERA")" "$(styleOf "$NELL")")" "^\[Women.s\]\[Women.s\]$"
VJ="$T/tc-sets-vera.txt"; NJ="$T/tc-sets-nell.txt"; rm -f "$VJ" "$NJ"
check "both claim staff accounts" "$(printf '%s\n' "$(claim "$VERA" "$VJ" vera)" "$(claim "$NELL" "$NJ" nell)" | grep -c '"ok":true')" '^2$'
# Each new issue row as SKU=offStyle/offGroup/override: since() above with the cut's mark first and
# the other two beside it, because a hand-over that stamped the staff group's flag or the ceiling's
# instead of the cut's would otherwise read as a pass.
sinceS(){ bk | B4="$2" py "import os; b=set(os.environ['B4'].split()); sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s/%s' % (sk[r['itemId']], r['offStyle'], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1' and r['id'] not in b)))"; }
V1=$(smut request.create "$VJ" "$(ASK "$(RL "$WB")")"); V2=$(smut request.create "$VJ" "$(ASK "$(RL "$WB")")")
N1=$(smut request.create "$NJ" "$(ASK "$(RL "$WB")")"); N2=$(smut request.create "$NJ" "$(ASK "$(RL "$WB")")")
check "Vera asks for the women's blouse twice while it is her cut, and so does Nell" "$(printf '%s\n' "$V1" "$V2" "$N1" "$N2" | grep -c '"code"')" '^4$'
VC=$(rid "$V1"); VR=$(rid "$V2"); NC=$(rid "$N1"); NR=$(rid "$N2")
check " and Mara approves all four" "$(for r in $VC $VR $NC $NR; do smut request.approve "$MJ" "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"accepted"')" '^4$'
check "the linen room picks all four" "$(for r in $VC $VR $NC $NR; do mut request.pick "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"picking"')" '^4$'
check " holds one of each at the counter" "$(for r in $VC $NC; do mut request.hold "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"ready"')" '^2$'
check " and sends the other two on the round" "$(for r in $VR $NR; do mut request.round "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"round"')" '^2$'
# staff.patch is the control the cut has of its own, and the one the sections above set a cut with.
check "the coordinator sets Vera to Men's, her bags already picked" "$(mut staff.patch "{\"id\":\"$VERA\",\"uniformStyle\":\"Men's\"}")" '"ok":true'
check " and her record reads it" "$(styleOf "$VERA")" "^\[Men.s\]$"
check " while Nell is left as she was" "$(styleOf "$NELL")" "^\[Women.s\]$"
VB=$(ids "$VERA"); NB=$(ids "$NELL")
check "Vera collects her bag at the counter" "$(mut request.collected "{\"id\":\"$VC\"}")" '"status":"collected"'
check " and the row it wrote is marked the wrong cut, and neither another group's nor an override" "$(sinceS "$VERA" "$VB")" '^WB=True/False/False$'
check "Nell collects hers" "$(mut request.collected "{\"id\":\"$NC\"}")" '"status":"collected"'
check " and hers is marked none of the three" "$(sinceS "$NELL" "$NB")" '^WB=False/False/False$'
VB=$(ids "$VERA"); NB=$(ids "$NELL")
check "Mara signs for Vera's bag on the round" "$(smut round.sign "$MJ" "{\"id\":\"$VR\"}")" '"ok":true'
check " and that row is marked the wrong cut as well" "$(sinceS "$VERA" "$VB")" '^WB=True/False/False$'
check "and for Nell's" "$(smut round.sign "$MJ" "{\"id\":\"$NR\"}")" '"ok":true'
check " and hers is not" "$(sinceS "$NELL" "$NB")" '^WB=False/False/False$'
echo "== every split of an off-style garment keeps the mark"
# What the counter handed over on the override is split by three ordinary acts — a hand-in, a return
# over the counter, and a swap for another size — and each of them writes a new issue row off the old
# one. A split that dropped the mark would take the garment off the Exceptions report for the month
# it was queried, and leave the half still out with the wearer reading as an ordinary issue. Both
# halves of every split are read, the new-size row a swap writes included, beside a garment of Rory's
# own cut put through the very same three splits: a split that stamped every row it wrote would pass
# the blouse and fail the trouser.
#
# Two sizes on each, because a swap needs another size to swap into. A blouse and a trouser because
# they are the two halves of a set and carry a ceiling each, so four of each is four tops and four
# pairs — well inside six of either, and nothing below is an override of the ceiling. Both are for
# every staff group, so the group rule marks nothing here either.
# These two go on the catalogue through the counter's own add rather than the importer, and the
# reason is a ceiling rather than a preference: every import and every restore counts against one
# bucket of twenty per coordinator per ten minutes (app/api/mutate/route.ts). This suite is a single
# coordinator that already spends most of them, so adding a catalogue import and an opening-stock
# import here pushed it to twenty-one — and the call refused was the restore at the very end, which
# is where the marks are read back. The add carries the sizes and the opening stock in the one call,
# so the section still builds its own fixtures and stands alone. That leaves nineteen, one spare:
# anything added here later should come in the same way rather than through the importer.
# No group is sent, and no group means every group, so the group rule marks nothing in this section.
check "a women's-cut blouse, two sizes, with stock on the shelf" "$(mut catalog.add '{"item":"Bistro Blouse","sku":"BB","type":"Blouse","gender":"Female","supplier":"Alpha Supply","cost":31,"sizes":["S","M"],"opening":[{"si":0,"qty":20},{"si":1,"qty":20}]}')" '"id":"'
check " and a men's-cut trouser beside it" "$(mut catalog.add '{"item":"Bistro Trouser","sku":"BT","type":"Trousers","gender":"Male","supplier":"Alpha Supply","cost":29,"sizes":["S","M"],"opening":[{"si":0,"qty":20},{"si":1,"qty":20}]}')" '"id":"'
BB=$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="BB"][0])')
BT=$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="BT"][0])')
check "one more in Kitchen, set to Men's and wearing S" "$(mut import.rows "{\"kind\":\"staff\",\"rows\":[{\"num\":\"20\",\"first\":\"Rory\",\"last\":\"Blake\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"S\",\"pants\":\"S\",\"style\":\"Men's\"}]}")" '"created":1'
RORY=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="20"][0])')
# Every issue row he has, as SKU:size:qty:state:offStyle:offGroup:override. The size, because a swap
# writes its new row in another one; the state, because these three splits leave rows handed in,
# returned and still out beside each other, and each has to be named for what it is. All three flags
# are read, so a split that carried the mark onto the wrong one fails here rather than reading alike.
srows(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; sz={i['id']: i['sizes'] for i in d['items']}; st=lambda r: 'in' if r['handedIn'] else (('ret-' + (r['returnedCond'] or '').split(' - ')[-1].lower().replace(' ', '-')) if r['returnedDate'] else 'held'); print(' '.join(sorted('%s:%s:%d:%s:%s:%s:%s' % (sk[r['itemId']], sz[r['itemId']][r['sizeIndex']], r['qty'], st(r), r['offStyle'], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1')))"; }
R=$(issue "$RORY" "$(L $BB 4 stock),$(L $BT 4 stock)" ',"override":true')
check "Rory takes four women's-cut blouses on the override, and four trousers of his own cut" "$R" '"stock":8'
check " and the counter is told one garment was not his cut" "$R" '"offStyle":1'
check " the blouses' row marked the wrong cut, the trousers' not, and neither a group nor an override" "$(srows "$RORY")" '^BB:S:4:held:True:False:False BT:S:4:held:False:False:False$'
check "Rory hands one blouse in, and one trouser beside it" "$(printf '%s\n' "$(handin "$RORY" "$BB")" "$(handin "$RORY" "$BT")" | grep -c '"good":1')" '^2$'
check " each row split in two, both halves of the blouse marked and neither half of the trouser" "$(srows "$RORY")" '^BB:S:1:in:True:False:False BB:S:3:held:True:False:False BT:S:1:in:False:False:False BT:S:3:held:False:False:False$'
# The one row of that garment still out with him, named rather than picked from a list, so a split
# that left two rows out or marked the wrong half fails here instead of being read past.
out1(){ bk | py "x=[i['id'] for i in d['issues'] if i['staffId']=='$1' and i['itemId']=='$2' and not i['handedIn'] and not i['returnedDate']]; print(x[0] if len(x)==1 else 'expected one row still out, found %d' % len(x))"; }
check "one blouse comes back over the counter damaged, and one trouser with it" "$(printf '%s\n' "$(mut issue.return "{\"id\":\"$(out1 "$RORY" "$BB")\",\"qty\":1,\"cond\":\"Returned - Damaged\"}")" "$(mut issue.return "{\"id\":\"$(out1 "$RORY" "$BT")\",\"qty\":1,\"cond\":\"Returned - Damaged\"}")" | grep -c '"ok":true')" '^2$'
check " the returned half and the half still out both marked, and the trouser's neither" "$(srows "$RORY")" '^BB:S:1:in:True:False:False BB:S:1:ret-damaged:True:False:False BB:S:2:held:True:False:False BT:S:1:in:False:False:False BT:S:1:ret-damaged:False:False:False BT:S:2:held:False:False:False$'
check "one of the two blouses still out is swapped for the other size" "$(mut issue.exchange "{\"id\":\"$(out1 "$RORY" "$BB")\",\"si\":1,\"qty\":1}")" '"size":"M"'
check " and one trouser the same way" "$(mut issue.exchange "{\"id\":\"$(out1 "$RORY" "$BT")\",\"si\":1,\"qty\":1}")" '"size":"M"'
check " the new size, the size it replaced and the one still out all marked, and the trouser's none of them" "$(srows "$RORY")" '^BB:M:1:held:True:False:False BB:S:1:held:True:False:False BB:S:1:in:True:False:False BB:S:1:ret-damaged:True:False:False BB:S:1:ret-good:True:False:False BT:M:1:held:False:False:False BT:S:1:held:False:False:False BT:S:1:in:False:False:False BT:S:1:ret-damaged:False:False:False BT:S:1:ret-good:False:False:False$'
echo "== correcting a phone number doesn't clear the cut somebody was set to"
# The details form saves the whole record and sends no style, because the style has its own control.
# A save that blanked it every time would put people back to "nobody has said" — offered the whole
# catalogue again — with nothing on any screen to say it had happened.
check "Dana's record saved from the details form, with a phone number added" "$(mut staff.save "{\"id\":\"$DANA\",\"num\":\"11\",\"first\":\"Dana\",\"last\":\"Lee\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\",\"phone\":\"0400 000 111\"}")" '"ok":true'
check " leaves her set to Women's" "$(styleOf "$DANA")" "^\[Women.s\]$"
check " and the men's shirt is still refused to her" "$(issue "$DANA" "$(L $FS 1 stock)")" "Field Shirt is the Men.s cut"
# Blank is a decision a coordinator can go back to: whoever ticked the wrong one has to be able to
# undo it, and undoing it offers her everything again, which is where this section started.
check "the cut can be cleared back to nobody having said" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"\"}")" '"ok":true'
check " and her record reads blank" "$(styleOf "$DANA")" '^\[\]$'
check " and the men's shirt is on her request screen again" "$(shop "$DJ")" 'Field Shirt'
check "and she is set back to Women's, for the restore below" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"Women's\"}")" '"ok":true'
echo "== a restore keeps a garment's groups and cut, both marks, and every wearer's style"
# Last, because a restore gives every row a fresh id and the staff sessions above are keyed to the
# old ones. Everything is read by staff number and SKU, which survive it. Every issue row is compared,
# both marks and neither alike, so a restore that dropped one and one that stamped it on everything
# both fail — and the tunic, tagged for two groups, has to come back with both.
#
# The register's own column is compared the same way and for the same reason: every record at once,
# so a restore that lost the cut and one that wrote a cut onto the blanks both fail. Blank has to come
# back blank — it is the state nobody has decided, not a value worth guessing at.
GBY(){ bk | py "print(','.join([i['groups'] for i in d['items'] if i['sku']=='$1'][0]))"; }
every(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; nm={s['id']: s['num'] for s in d['staff']}; print(' '.join(sorted('%s:%s:%d:%s:%s:%s' % (nm[r['staffId']], sk[r['itemId']], r['qty'], 'in' if r['handedIn'] else 'held', r['offGroup'], r['offStyle']) for r in d['issues'])))"; }
# Every staff record's cut, by staff number, in brackets so blank — nobody has said — is a value a
# check can name rather than an empty space between two others.
styles(){ bk | py "print(' '.join(sorted('[%s=%s]' % (s['num'], s['uniformStyle']) for s in d['staff'])))"; }
A0=$(every); S0=$(styles)
check "before: the tunic is for two groups" "$(GBY WT)" '^Registered Nurse,Enrolled Nurse$'
check " and Gus holds a tunic marked outside his group, and not as the wrong cut" "$A0" '8:WT:1:held:True:False'
check " beside Rhea's, which is marked neither" "$A0" '7:WT:1:held:False:False'
check " while Dana holds a men's shirt marked the wrong cut, and not as another group's" "$A0" '11:FS:1:held:False:True'
check " beside the one she was handed before anybody set her cut" "$A0" '11:FS:1:held:False:False'
check " and the register carries her cut" "$S0" "\[11=Women.s\]"
check " Ash's Either, and Robin's blank" "$S0" '\[13=Either\] \[14=\]'
RF="$T/tc-sets-restore.json"
# Written to a file and sent from it: the whole facility's backup is too long to pass as one argument.
printf '{"op":"backup.restore","payload":%s}' "$(bk)" > "$RF"
check "the backup restores" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' --data-binary @"$RF")" '"ok":true'
A1=$(every); S1=$(styles)
check "after: the tunic is still for both groups" "$(GBY WT)" '^Registered Nurse,Enrolled Nurse$'
check " and the cap for the renamed one" "$(GBY GC)" '^Protective Services$'
check " the blouse is still the women's cut and the shirt the men's" "$(bk | py "print(' '.join(sorted(i['sku'] + '=' + i['gender'] for i in d['items'] if i['sku'] in ('WB', 'FS'))))")" '^FS=Male WB=Female$'
check " Gus's tunic is still marked outside his group" "$A1" '8:WT:1:held:True:False'
check " and Dana's shirt is still marked the wrong cut" "$A1" '11:FS:1:held:False:True'
if [ -n "$A0" ] && [ "$A1" = "$A0" ]; then ok " and every issue row carries both marks it had before"; else fail " and every issue row carries both marks it had before" "$(echo "$A1" | head -c 300)"; fi
if [ -n "$S0" ] && [ "$S1" = "$S0" ]; then ok " and every staff record the cut it was set to, blanks included"; else fail " and every staff record the cut it was set to, blanks included" "$(echo "$S1" | head -c 300)"; fi
# Read once more through the rule itself rather than off the record: a restore that brought the word
# back as something normalUniformStyle() doesn't know would read blank here and refuse nothing.
check "and the men's shirt is still refused to Dana after the restore" "$(issue "$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="11"][0])')" "$(L "$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="FS"][0])')" 1 stock)")" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s"
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+218
View File
@@ -0,0 +1,218 @@
#!/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 ]
+718
View File
@@ -0,0 +1,718 @@
#!/usr/bin/env bash
# The staff app: request → approve → fulfil, and everything that must refuse.
#
# The design rests on three separations, and most of this file exists to prove they hold:
# · the linen room cannot approve — that is the ward's job;
# · a manager can only see and decide requests addressed to them;
# · a wearer can only see their own record, and nothing of the register.
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-sa-coord.txt" # linen room
W="$T/tc-sa-wearer.txt" # the staff member
M="$T/tc-sa-mgr.txt" # their ward manager
D="$T/tc-sa-desk.txt" # the ward clerk
O="$T/tc-sa-other.txt" # a manager on another ward
X="$T/tc-sa-dir.txt" # the ward manager's own manager, a level up
N="$T/tc-sa-noward.txt" # a desk clerk whose ward was never filled in
# Three more nurses on Jamila's ward, under Jamila's manager. They are here because the server holds
# one person to twelve requests an hour — every request emails a manager, and nobody asks twelve
# times in an hour for a reason anybody would recognise. This file does, because it walks every flow
# end to end, so the sections below are spread across a ward rather than put through one nurse.
V="$T/tc-sa-trials.txt" # the nurse whose asks are all meant to be refused
Y="$T/tc-sa-lines.txt" # the nurse who asks for several garments at once
Z="$T/tc-sa-bags.txt" # the nurse whose bags wait at the counter and go out on the round
K="$T/tc-sa-solo.txt" # somebody recorded as their own manager, with nobody else under them
L="$T/tc-sa-lead.txt" # somebody recorded as their own manager, with a report of her own
rm -f "$C" "$W" "$M" "$D" "$O" "$X" "$N" "$V" "$Y" "$Z" "$K" "$L"
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 200)"; fi; }
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 200)"; 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}"; }
smut() { curl -s -b "$2" -c "$2" -X POST "$B/api/staff/mutate" -H 'content-type: application/json' -H "origin: $B" -d "{\"op\":\"$1\",\"payload\":$3}"; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
# A screen that refuses somebody: the not-found page comes back and none of the screen's own words
# do. Deliberately not a status check -- these routes stream under a loading boundary, so the header
# is sent before the page decides, and 200 says nothing either way. Pass the cookie jar, the path,
# and a word the real screen would have rendered.
refused() { local name=$1 jar=$2 path=$3 leak=$4
local body; body=$(curl -s -b "$jar" "$B$path")
if ! printf '%s' "$body" | grep -q "That page isn"; then fail "$name" "no refusal: $(printf '%s' "$body" | head -c 160)"; return; fi
if printf '%s' "$body" | grep -q "$leak"; then fail "$name" "REFUSED BUT LEAKED $leak"; return; fi
ok "$name"
}
# Claim a staff account: generate a code as the linen room, then activate it into a jar.
claim() { local sid=$1 jar=$2 email=$3
local code; code=$(mut staff.selfCode "{\"id\":\"$sid\"}" | py "print(d['result']['code'])")
curl -s -c "$jar" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" \
-d "{\"agreed\":true,\"code\":\"$code\",\"email\":\"$email\",\"password\":\"wearerpass1\"}"; }
TS=$(date +%s)
CO="sa$TS@example.com"
echo "== setup"
check "linen room signs up" "$(curl -s -c "$C" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.31.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Sal\",\"last\":\"Linen\",\"facility\":\"Request Hospital $TS\",\"email\":\"$CO\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$C")" '"ok":true'
WID=$(mut staff.save '{"num":"W1","first":"Jamila","last":"Wearer","group":"Registered Nurse","dept":"Rosewood Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
MID=$(mut staff.save '{"num":"M1","first":"Dele","last":"Manager","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
DID=$(mut staff.save '{"num":"D1","first":"Ade","last":"Clerk","group":"Admin","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
OID=$(mut staff.save '{"num":"O1","first":"Otto","last":"Elsewhere","group":"Security","dept":"Linden Ward"}' | py "print(d['result']['id'])")
CID=$(mut staff.save '{"num":"C1","first":"Chidi","last":"Director","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
# Two people whose ward was never filled in: a desk clerk and somebody the clerk must not reach.
# Sharing a blank ward with somebody used to read as sharing a ward, which was the widest version
# of the door the desk raised through. That door is shut now, and this pair is what proves it.
NID=$(mut staff.save '{"num":"N1","first":"Nia","last":"Nodesk","group":"Admin"}' | py "print(d['result']['id'])")
PID=$(mut staff.save '{"num":"P1","first":"Pat","last":"Noward","group":"Registered Nurse"}' | py "print(d['result']['id'])")
# Counted, not concatenated: an id that came back empty because staff.save refused is exactly what
# makes the refusals further down pass for the wrong reason, since a blank subjectId falls into a
# different branch of request.create entirely.
check "seven people on the register" "$(printf '%s\n' "$WID" "$MID" "$DID" "$OID" "$CID" "$NID" "$PID" | grep -c .)" '^7$'
check "the wearer gets a manager" "$(mut staff.patch "{\"id\":\"$WID\",\"managerId\":\"$MID\"}")" '"ok":true'
check "so does the clerk" "$(mut staff.patch "{\"id\":\"$DID\",\"managerId\":\"$MID\"}")" '"ok":true'
check "and the ward-less nurse" "$(mut staff.patch "{\"id\":\"$PID\",\"managerId\":\"$MID\"}")" '"ok":true'
check "the clerk is put on the desk" "$(mut staff.patch "{\"id\":\"$DID\",\"wardDesk\":true}")" '"ok":true'
check "so is the one with no ward" "$(mut staff.patch "{\"id\":\"$NID\",\"wardDesk\":true}")" '"ok":true'
# Anybody may be their own manager now (the owner's decision, 12 September 2026) — that is proved on
# people of its own further down, so Jamila keeps Dele. What is still refused is a manager who is not
# on the register at all, and refusing it must leave her manager where it was.
check "a manager nobody on the register is refused" "$(mut staff.patch "{\"id\":\"$WID\",\"managerId\":\"nope\"}")" 'Unknown staff member'
check " and the wearer keeps the one she has" "$(curl -s -b "$C" "$B/api/backup" | py "print([s['managerId'] for s in d['staff'] if s['id']=='$WID'][0] == '$MID')")" '^True$'
# The three whose sections come later. On Jamila's ward, so the desk can sign for their bags on the
# round, and under Jamila's manager, so a request of theirs is decided exactly as one of hers is.
VID=$(mut staff.save '{"num":"W2","first":"Bea","last":"Trials","group":"Registered Nurse","dept":"Rosewood Ward","top":"S","pants":"10"}' | py "print(d['result']['id'])")
YID=$(mut staff.save '{"num":"W3","first":"Rafa","last":"Lines","group":"Registered Nurse","dept":"Rosewood Ward","top":"L","pants":"14"}' | py "print(d['result']['id'])")
ZID=$(mut staff.save '{"num":"W4","first":"Ines","last":"Trolley","group":"Registered Nurse","dept":"Rosewood Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
check "three more nurses on the ward" "$(printf '%s\n' "$VID" "$YID" "$ZID" | grep -c .)" '^3$'
check " all three under the same manager" "$(printf '%s\n' \
"$(mut staff.patch "{\"id\":\"$VID\",\"managerId\":\"$MID\"}")" \
"$(mut staff.patch "{\"id\":\"$YID\",\"managerId\":\"$MID\"}")" \
"$(mut staff.patch "{\"id\":\"$ZID\",\"managerId\":\"$MID\"}")" | grep -c '"ok":true')" '^3$'
# 143 rather than a dozen, and deliberately not a number that is also one of the sizes: the shelf
# check below proves the count never reaches the ward, and it can only prove that against a figure
# that has no other reason to be on the page.
ITEM=$(mut catalog.add '{"item":"Navy tunic","type":"Tunic","sizes":["10","12","14"],"cost":30,"opening":[{"si":1,"qty":143},{"si":2,"qty":1}]}')
IID=$(echo "$ITEM" | py "print(d['result']['id'])")
check "a garment is on the shelf" "$ITEM" '"id"'
check "with a par level on the thin size" "$(mut stock.reorder "{\"itemId\":\"$IID\",\"si\":2,\"reorder\":3}")" '"ok":true'
# Two more garments, so one request can carry three of them and a decline can be told apart from
# an approval by which shelf moved.
TID=$(mut catalog.add '{"item":"Navy trousers","type":"Trousers","sizes":["10","12","14"],"cost":22,"opening":[{"si":0,"qty":6}]}' | py "print(d['result']['id'])")
FID=$(mut catalog.add '{"item":"Fleece jacket","type":"Fleece","sizes":["S","M","L"],"cost":48,"opening":[{"si":0,"qty":5}]}' | py "print(d['result']['id'])")
check "and two more beside it" "$(printf '%s\n' "$TID" "$FID" | grep -c .)" '^2$'
check "the wearer claims an account" "$(claim "$WID" "$W" "w$TS@example.com")" '"ok":true'
check "the manager claims one" "$(claim "$MID" "$M" "m$TS@example.com")" '"ok":true'
check "the clerk claims one" "$(claim "$DID" "$D" "d$TS@example.com")" '"ok":true'
check "the other ward claims one" "$(claim "$OID" "$O" "o$TS@example.com")" '"ok":true'
check "the director claims one" "$(claim "$CID" "$X" "c$TS@example.com")" '"ok":true'
check "the ward-less clerk claims one" "$(claim "$NID" "$N" "n$TS@example.com")" '"ok":true'
check "the nurse whose asks are refused claims one" "$(claim "$VID" "$V" "v$TS@example.com")" '"ok":true'
check "the several-garments nurse too" "$(claim "$YID" "$Y" "y$TS@example.com")" '"ok":true'
check "and the one whose bags go out" "$(claim "$ZID" "$Z" "z$TS@example.com")" '"ok":true'
echo "== the two doors into the staff app"
check "/api/staff/mutate refuses an anonymous caller" "$(curl -s -X POST "$B/api/staff/mutate" -H 'content-type: application/json' -H "origin: $B" -d '{}')" 'Not signed in'
# /api/staff/decide is the one route with no session behind it by design — the manager taps the
# button in their mail client, signed out — so refusing an anonymous caller is not a property it
# has. Its gate is the token, and a POST carrying none gets the same answer a spent link does.
check "and /api/staff/decide refuses one with no token" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d '{"action":"approve"}')" 'expired'
echo "== wards see words, never counts"
SHELF=$(curl -s -b "$W" "$B/my/shelf")
check "the shelf check renders" "$SHELF" 'Navy tunic'
check "and says In stock" "$SHELF" 'In stock'
check "and Low for the thin size" "$SHELF" '>Low<'
# A leak would put the figure itself in a size row, in whatever wording the day's code happened to
# use, so the check is the number rather than a sentence somebody would have had to write first.
#
# Read off the words on the page and nothing else. The response also carries the framework's own
# payload — a wall of build ids and hashed chunk names — and three digits turn up somewhere in that
# by luck often enough, which reports the ward leaking stock counts on a day when nothing changed.
# The screen is rendered on the server, so everything a nurse can see is in the text once the
# scripts and the mark-up are taken out, and nothing incidental is.
WORDS=$(echo "$SHELF" | python3 -c "
import re, sys
html = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', sys.stdin.read())
print(re.sub(r'(?s)<[^>]*>', ' ', html))
")
# An empty read would make the line below pass without looking at anything.
check " and the size rows are what we are reading" "$WORDS" 'Navy tunic'
no "and never a bare count of the shelf" "$WORDS" '\b143\b'
echo "== raising a request"
REQ=$(smut request.create "$W" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}],\"reason\":\"Worn out\",\"note\":\"Both worn through\"}")
check "the wearer can raise one" "$REQ" '"code"'
RID=$(echo "$REQ" | py "print(d['result']['id'])")
check "and it names the approver" "$REQ" 'Dele Manager'
no " and nothing was escalated" "$REQ" '"escalated":true'
check "an unknown garment is refused" "$(smut request.create "$W" '{"lines":[{"itemId":"nope","si":0,"qty":1}]}')" "isn't available"
check "a size that doesn't exist is refused" "$(smut request.create "$W" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":99,\"qty\":1}]}")" 'Pick a size'
check "somebody with no manager cannot raise" "$(smut request.create "$O" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "manager isn't set"
echo "== what may go on one request"
# Bea's section: six asks in a row, all but the last meant to be refused. A refused ask counts
# against the hourly ceiling exactly as a kept one does — it has to, since a phone stuck in a retry
# loop sends nothing but refusals — so this is somebody's own morning rather than Jamila's.
check "a request with no garments is refused" "$(smut request.create "$V" '{"lines":[]}')" 'at least one garment'
check "so is one with nothing but a reason" "$(smut request.create "$V" '{"reason":"Lost"}')" 'at least one garment'
check "none of a garment is refused" "$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":0}]}")" 'between 1 and 20'
check "and a wild quantity is too" "$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":99}]}")" 'between 1 and 20'
# Eleven lines, built rather than typed out, so the cap is read from the refusal and not from here.
ELEVEN=$(python3 -c "import json;print(json.dumps([{'itemId':'$IID','si':i%3,'qty':1} for i in range(11)]))")
check "an eleventh garment is refused" "$(smut request.create "$V" "{\"lines\":$ELEVEN}")" 'up to 10 garments'
# The same garment and size twice is one line with the quantities added, not two rows the manager
# has to decide twice and the linen room has to pick twice.
DUP=$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}],\"reason\":\"Extra for shifts\"}")
DUPID=$(echo "$DUP" | py "print(d['result']['id'])")
DUPROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$DUPID'][0]))")
check "a repeated garment becomes one line" "$(echo "$DUPROW" | py "print(d['lineCount'])")" '^1$'
check " carrying the total" "$(echo "$DUPROW" | py "print(d['lines'][0]['qty'])")" '^3$'
check "tidied away again" "$(mut request.withdraw "{\"id\":\"$DUPID\",\"reason\":\"Raised in error\"}")" '"ok":true'
echo "== the linen room cannot approve"
check "no pick before approval" "$(mut request.pick "{\"id\":\"$RID\"}")" "can't move"
LR=$(curl -s -b "$C" "$B/api/requests")
check "it is visible to the linen room" "$LR" "$RID"
check " shown as awaiting" "$LR" '"status":"awaiting"'
echo "== only the addressed manager can decide"
check "another ward's manager cannot approve" "$(smut request.approve "$O" "{\"id\":\"$RID\"}")" 'No such request'
check "the wearer cannot approve their own" "$(smut request.approve "$W" "{\"id\":\"$RID\"}")" 'No such request'
check "a decline with no reason is refused" "$(smut request.decline "$M" "{\"id\":\"$RID\"}")" 'Pick a reason'
check "a made-up reason is refused" "$(smut request.decline "$M" "{\"id\":\"$RID\",\"reason\":\"Because\"}")" 'Pick a reason'
APPROVED=$(smut request.approve "$M" "{\"id\":\"$RID\"}")
check "the manager approves" "$APPROVED" '"status":"accepted"'
# The other half of the self-approval marking further down: a manager deciding somebody else's
# request is never handed back as having approved their own.
check " as an ordinary approval, not a self-approval" "$APPROVED" '"selfApproved":false'
check "and cannot approve twice" "$(smut request.approve "$M" "{\"id\":\"$RID\"}")" 'already been decided'
check "nor decline after approving" "$(smut request.decline "$M" "{\"id\":\"$RID\",\"reason\":\"Over allowance\"}")" 'already been decided'
echo "== fulfilment is the linen room's"
check "the manager cannot pick" "$(smut request.pick "$M" "{\"id\":\"$RID\"}")" 'Unknown action'
check "the linen room picks" "$(mut request.pick "{\"id\":\"$RID\"}")" '"status":"picking"'
check "collected is refused out of order" "$(mut request.collected "{\"id\":\"$RID\"}")" "can't move"
HOLD=$(mut request.hold "{\"id\":\"$RID\",\"holdUntil\":\"Fri 6pm\"}")
check "held at the counter" "$HOLD" '"status":"ready"'
ORDER=$(curl -s -b "$W" "$B/my/orders/$RID")
check "the wearer sees a collection code" "$ORDER" 'Show at the counter'
check "and the hold" "$ORDER" 'Fri 6pm'
check "and the timeline names the approver" "$ORDER" 'Approved by Dele Manager'
check "the linen room marks it collected" "$(mut request.collected "{\"id\":\"$RID\"}")" '"status":"collected"'
echo "== a decline carries its reason to the staff member"
R2=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":2,\"qty\":1}],\"reason\":\"Lost\"}" | py "print(d['result']['id'])")
DEC2=$(smut request.decline "$M" "{\"id\":\"$R2\",\"reason\":\"Over allowance\"}")
check "declined with a reason" "$DEC2" '"status":"declined"'
# One garment, so the summary says Declined outright rather than counting it out as 1 of 1.
check " and summarised without arithmetic" "$DEC2" '"summary":"Declined"'
D2=$(curl -s -b "$Y" "$B/my/orders/$R2")
check "the wearer is told which one" "$D2" 'Over allowance'
check "and who decided" "$D2" 'Dele Manager'
R2ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$R2'][0]))")
check "the garment under it was refused too" "$(echo "$R2ROW" | py "print(d['lines'][0]['status'])")" '^declined$'
check " and carries the same reason" "$(echo "$R2ROW" | py "print(d['lines'][0]['declineReason'])")" '^Over allowance$'
echo "== one request, three garments, one decision"
# The whole point of lines. A nurse who needs a tunic, trousers and a fleece asks once; the manager
# reads the lot on one screen and answers in one action, but can knock back a single garment. Only
# what survives that is picked, bagged and handed over, and the refusal stays on the record so the
# wearer can see what happened to the fleece.
ML=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2},{\"itemId\":\"$TID\",\"si\":0,\"qty\":1},{\"itemId\":\"$FID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\",\"note\":\"Starting on nights\"}")
check "three garments go on one request" "$ML" '"code"'
MLID=$(echo "$ML" | py "print(d['result']['id'])")
ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$MLID'][0]))")
check " three lines on it" "$(echo "$ROW" | py "print(d['lineCount'])")" '^3$'
check " four garments between them" "$(echo "$ROW" | py "print(d['garments'])")" '^4$'
check " and a one-liner for a collapsed row" "$(echo "$ROW" | py "print(d['summary'])")" '4 garments · Navy tunic, Navy trousers, Fleece jacket'
check " with nothing decided yet" "$(echo "$ROW" | py "print(d['decision'] or 'undecided')")" '^undecided$'
L1=$(echo "$ROW" | py "print(d['lines'][0]['id'])")
L2=$(echo "$ROW" | py "print(d['lines'][1]['id'])")
L3=$(echo "$ROW" | py "print(d['lines'][2]['id'])")
# A half-answered decision would send a bag to the counter with a garment nobody had ruled on.
check "leaving a garment undecided is refused" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"}]}")" 'Decide every garment'
check "an id that is not on the request is refused" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"rl_nonsense\",\"decision\":\"approved\"}]}")" "doesn.t match the request"
# "accepted" is the word for a whole request; a garment is "approved". Conflating the two would let
# a typo through as a decision nobody made.
check "the request-level word is not a line's word" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"accepted\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"approved\"}]}")" 'Approve or decline each garment'
check "a declined garment needs a reason" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\"}]}")" 'Pick a reason for each garment'
check "and it has to be one of the reasons" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\",\"reason\":\"Because\"}]}")" 'Pick a reason for each garment'
check "none of that decided anything" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['status'])")" '^awaiting$'
DEC=$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\",\"reason\":\"Over allowance\"}]}")
check "two approved, one declined" "$DEC" '"status":"accepted"'
check " summarised in one line" "$DEC" '"summary":"2 of 3 approved"'
check "and the decision is only made once" "$(smut request.decline "$M" "{\"id\":\"$MLID\",\"reason\":\"Over allowance\"}")" 'already been decided'
ROW2=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$MLID'][0]))")
check "one surviving garment makes the request accepted" "$(echo "$ROW2" | py "print(d['status'])")" '^accepted$'
check " the record still holds all three" "$(echo "$ROW2" | py "print(d['lineCount'])")" '^3$'
check " the bag holds two" "$(echo "$ROW2" | py "print(len(d['bag']))")" '^2$'
check " three garments to pick" "$(echo "$ROW2" | py "print(d['garments'])")" '^3$'
no " and the fleece is not one of them" "$(echo "$ROW2" | py "print(json.dumps(d['bag']))")" 'Fleece jacket'
check "the refused line says what it was" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['status'])")" '^declined$'
check " and carries its own reason" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['declineReason'])")" '^Over allowance$'
check " and is worded for the wearer" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['statusLabel'])")" '^Declined$'
check " while the approved lines carry no reason" "$(echo "$ROW2" | py "print(len([l for l in d['bag'] if l['declineReason']]))")" '^0$'
# One refusal out of three is not a refusal of the request, so no reason is invented for it.
check "the request itself is given no reason" "$(echo "$ROW2" | py "print(d['declineReason'] or 'none')")" '^none$'
MLORDER=$(curl -s -b "$Y" "$B/my/orders/$MLID")
check "the wearer's order names the refused garment" "$MLORDER" 'Fleece jacket'
check " with the reason against it" "$MLORDER" 'Over allowance'
check " and still lists what is coming" "$MLORDER" 'Navy trousers'
echo "== only what was approved leaves the shelf"
TUNIC_BEFORE=$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$IID' and i['sizeIndex']==1))")
check "the linen room picks the bag" "$(mut request.pick "{\"id\":\"$MLID\"}")" '"status":"picking"'
check "and holds it at the counter" "$(mut request.hold "{\"id\":\"$MLID\",\"holdUntil\":\"Tue 2pm\"}")" '"status":"ready"'
MLCODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['collectCode'])")
check "one collection code, for the whole request" "$MLCODE" '^[0-9][0-9][0-9][0-9]$'
READY=$(curl -s -b "$Y" "$B/my/orders/$MLID")
check " and the wearer is pointed at the counter" "$READY" 'Show at the counter'
check " under the code the linen room is holding" "$READY" "$MLCODE"
# Three garments, two lines, one bag. The screen says so in words, because somebody who asked for
# three things and is given one code will otherwise assume the rest is coming separately.
# React's server renderer puts an empty comment between a literal and an interpolated value, so
# this sentence reaches the browser as `All <!-- -->3<!-- --> garments...`. Read it with the
# separators taken out, or the check hunts for a string the server has never once sent.
READY_TEXT=$(printf '%s' "$READY" | sed 's/<!-- -->//g')
check " said to cover the whole bag" "$READY_TEXT" 'All 3 garments are in one bag'
check " covering the tunics" "$READY" 'Navy tunic'
check " and the trousers with them" "$READY" 'Navy trousers'
check "the linen room hands the bag over" "$(mut request.collected "{\"id\":\"$MLID\"}")" '"status":"collected"'
BK=$(curl -s -b "$C" "$B/api/backup")
check "both tunics came off the shelf" "$(echo "$BK" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$IID' and i['sizeIndex']==1) - $TUNIC_BEFORE)")" '^2$'
check "and the trousers with them" "$(echo "$BK" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$TID'))")" '^1$'
check "the fleece never moved" "$(echo "$BK" | py "print(len([i for i in d['issues'] if i['itemId']=='$FID']))")" '^0$'
check "nor was one ordered to replace it" "$(echo "$BK" | py "print(len([l for o in d['orders'] for l in o['lines'] if l['itemId']=='$FID']))")" '^0$'
check " while the trousers were" "$(echo "$BK" | py "print(sum(l['qty'] for o in d['orders'] for l in o['lines'] if l['itemId']=='$TID'))")" '^1$'
check "the hand-over was one event, under the one code" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['events'][-1]['meta'])")" "^Code $MLCODE\$"
echo "== two bags at the counter never share a code"
# The whole transaction at the counter is somebody reading four digits off their phone and the
# coordinator finding the bag with that number on it, so two bags waiting at once under the same
# number is somebody being handed the wrong uniform. Uniqueness is only asked of the bags actually
# out there — a code goes back in the pot once its bag has gone home — which is why both of these
# are held before either is checked.
CA=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
CB=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
check "the manager approves the first" "$(smut request.approve "$M" "{\"id\":\"$CA\"}")" '"status":"accepted"'
check " and the second" "$(smut request.approve "$M" "{\"id\":\"$CB\"}")" '"status":"accepted"'
check "the first is picked" "$(mut request.pick "{\"id\":\"$CA\"}")" '"status":"picking"'
check " and held at the counter" "$(mut request.hold "{\"id\":\"$CA\",\"holdUntil\":\"Thu 5pm\"}")" '"status":"ready"'
check "the second is picked" "$(mut request.pick "{\"id\":\"$CB\"}")" '"status":"picking"'
check " and held beside it" "$(mut request.hold "{\"id\":\"$CB\",\"holdUntil\":\"Thu 5pm\"}")" '"status":"ready"'
# Looked up by id, one field each, rather than filtered into a list: a bag that never got as far as
# the counter has to read as missing. Filtering shortened the list instead, and a lone code beside
# an empty second field then compared as two codes that differ, which is the one answer this pair
# of lines exists to rule out.
PAIR=$(curl -s -b "$C" "$B/api/requests" | py "codes={r['id']: (r['collectCode'] or 'none') for r in d['requests']}; print(codes.get('$CA','missing'), codes.get('$CB','missing'))")
check "both bags carry a code" "$PAIR" '^[0-9][0-9][0-9][0-9] [0-9][0-9][0-9][0-9]$'
check " and the two are not the same one" "$(echo "$PAIR" | awk '{ if ($1 !~ /^[0-9][0-9][0-9][0-9]$/ || $2 !~ /^[0-9][0-9][0-9][0-9]$/) print "not two codes: " $0; else if ($1 == $2) print "the same"; else print "different" }')" '^different$'
check "the first goes home" "$(mut request.collected "{\"id\":\"$CA\"}")" '"status":"collected"'
check " and the second after it" "$(mut request.collected "{\"id\":\"$CB\"}")" '"status":"collected"'
echo "== the emailed link renders, the button decides"
# Two garments on this one deliberately. The email has no room for a garment-by-garment answer —
# it is one button — so approving through it has to settle every line on the request. A link that
# moved the request to accepted while its lines sat at awaiting would hand the linen room a pick
# list with nothing on it.
R3=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Damaged\"}" | py "print(d['result']['id'])")
TOKEN=$(node scripts/approval-mint.cjs "$R3" "$MID" 2>/dev/null)
check "a token can be minted for the test" "$TOKEN" '.'
PAGE=$(curl -s "$B/my/approve?t=$TOKEN")
check "the link opens a page, signed out" "$PAGE" 'needs your approval'
check " listing every garment on the ask" "$PAGE" 'Navy trousers'
check " which says nothing is decided yet" "$PAGE" 'Nothing has been decided yet'
STILL=$(curl -s -b "$C" "$B/api/requests")
check " and rendering it decided nothing" "$(echo "$STILL" | py "print([r for r in d['requests'] if r['id']=='$R3'][0]['status'])")" '^awaiting$'
check "a garbage token is refused" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d '{"token":"nope.nope","action":"approve"}')" 'expired'
check "the POST decides" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TOKEN\",\"action\":\"approve\"}")" '"status":"accepted"'
check "and the same link will not decide twice" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TOKEN\",\"action\":\"decline\",\"reason\":\"Over allowance\"}")" 'already been decided'
R3ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$R3'][0]))")
check "the one button settled both garments" "$(echo "$R3ROW" | py "print(len([l for l in d['lines'] if l['status']=='approved']))")" '^2$'
check " so the whole ask is in the bag" "$(echo "$R3ROW" | py "print(len(d['bag']))")" '^2$'
check " and it reads as approved outright" "$(echo "$R3ROW" | py "print(d['decision'])")" '^All 2 approved$'
echo "== the ward round, and who signed"
# Signing for a bag on the ward is the moment its garments leave the linen room's shelf, exactly as
# collecting at the counter is, so the whole approved ask has to move — not just the first line on
# it. Counted before the round and after, because the shelf is what the linen room reconciles.
ROUND_BEFORE=$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId'] in ('$IID','$TID')))")
check "sent on the round" "$(mut request.pick "{\"id\":\"$R3\"}")" '"status":"picking"'
check " routed to the ward" "$(mut request.round "{\"id\":\"$R3\"}")" '"status":"round"'
check "another ward cannot sign for it" "$(smut round.sign "$O" "{\"id\":\"$R3\"}")" 'another ward'
check "anyone on the ward can sign" "$(smut round.sign "$D" "{\"id\":\"$R3\"}")" '"ok":true'
check " and both garments came off the shelf with it" "$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId'] in ('$IID','$TID')) - $ROUND_BEFORE)")" '^2$'
check "and not twice" "$(smut round.sign "$D" "{\"id\":\"$R3\"}")" 'No such bag'
SIGNED=$(curl -s -b "$Z" "$B/my/orders/$R3")
check "the requester is told who signed" "$SIGNED" 'Ade Clerk'
# Signing is where the linen room's job ends, not where the bag does: it then sits on the desk
# until somebody says it was picked up, and the desk's unclaimed pile only grows until they do.
# The requester, or the clerk standing next to the pile, may say so — and nobody else.
check "a stranger cannot mark it collected" "$(smut round.claim "$O" "{\"id\":\"$R3\"}")" 'somebody else'
check "the desk marks it collected" "$(smut round.claim "$D" "{\"id\":\"$R3\"}")" '"ok":true'
check " and the requester is told who did" "$(curl -s -b "$Z" "$B/my/orders/$R3")" 'Marked by Ade Clerk'
# The requester tapping "I've got it" after the desk has already marked it must leave one claim
# and one line on the timeline, not a second one under the other name.
check "claiming it again changes nothing" "$(smut round.claim "$Z" "{\"id\":\"$R3\"}")" '"ok":true'
# One row, and still the desk's name on it: an implementation that overwrote the first claim with
# the second caller's name would also leave exactly one row, so counting them proves half of it.
check " and the timeline says it once, in the desk's name" "$(curl -s -b "$C" "$B/api/requests" | py "e=[x for x in [r for r in d['requests'] if r['id']=='$R3'][0]['events'] if x['label']=='Collected from the ward']; print(len(e), e[0]['meta'] if e else '')")" '^1 Marked by Ade Clerk$'
R3CODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$R3'][0]['code'])")
ROUNDPAGE=$(curl -s -b "$D" "$B/my/round")
check "the desk's round screen is still there" "$ROUNDPAGE" 'Ward round'
no " and the collected bag is off it" "$ROUNDPAGE" "$R3CODE"
echo "== the ward desk raises for nobody"
# The desk used to raise for anyone on its own ward, on the grounds that half a ward would never
# install anything. That door is shut: the only person who may put a request in somebody else's
# name is their own manager. Ade is on the desk and on Jamila's ward, and between them those two
# facts now buy her exactly what they buy an ordinary staff member.
DR=$(smut request.create "$D" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Extra for shifts\"}")
check "the desk cannot raise for its own ward" "$DR" "Only somebody.s own manager"
no " and nothing is created by it" "$DR" '"code"'
check "nor for another ward" "$(smut request.create "$D" "{\"subjectId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
check "a non-desk staff member cannot either" "$(smut request.create "$W" "{\"subjectId\":\"$DID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
# The widest version of that door was a blank ward: two people with nothing recorded read as
# members of the same one, which handed a clerk with an empty ward the run of every other
# ward-less person in the facility. Nia is that clerk, and Pat reports to Dele, not to her.
BLANK=$(smut request.create "$N" "{\"subjectId\":\"$PID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")
check "nor a desk clerk with no ward recorded" "$BLANK" "Only somebody.s own manager"
no " and nothing is created by that either" "$BLANK" '"code"'
# The flag itself stays — it is what signs for a bag on the round — but the screen it used to open
# has gone from the product, for the clerk who holds the flag as much as for anybody else.
check "and the desk screen went with the door" "$(curl -s -o /dev/null -w '%{http_code}' -b "$D" "$B/my/desk")" '404'
echo "== a manager raises for their own report, and never approves it"
# New in this round: a manager may raise for the people who report to them. The catch is that the
# manager is also the person who would approve it, so the request goes a level up instead — and
# when there is nobody above, it is created with no approver at all and waits on the linen room.
#
# The screen is scoped to the reporting line, not to a ward, and it exists only for somebody who
# actually has one — an empty version of it would tell a nurse they might have a team.
refused "a wearer with nobody under them has no such screen" "$W" "/my/raise" "Who is it for"
refused "nor the ward clerk, who manages nobody either" "$D" "/my/raise" "Who is it for"
TEAM=$(curl -s -b "$M" "$B/my/raise")
check "the manager has one" "$TEAM" 'Raise for your team'
check " listing the people who report to them" "$TEAM" 'Jamila Wearer'
no " and nobody who doesn.t" "$TEAM" 'Otto Elsewhere'
check "and home points them at it" "$(curl -s -b "$M" "$B/my")" 'Raise for someone you manage'
SELFR=$(smut request.create "$M" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "the manager can raise for their report" "$SELFR" '"code"'
check " and is told it was moved on" "$SELFR" '"escalated":true'
check " with nobody above them, it has no approver" "$SELFR" '"manager":""'
SRID=$(echo "$SELFR" | py "print(d['result']['id'])")
check " and the subject sees whose name it is in" "$(curl -s -b "$W" "$B/my/orders/$SRID")" 'Raised for you by Dele Manager'
check "so the raiser cannot approve it" "$(smut request.approve "$M" "{\"id\":\"$SRID\"}")" 'No such request'
check "nor decline it" "$(smut request.decline "$M" "{\"id\":\"$SRID\",\"reason\":\"Over allowance\"}")" 'No such request'
SROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$SRID'][0]))")
check "the linen room sees it waiting" "$(echo "$SROW" | py "print(d['status'])")" '^awaiting$'
check " with nobody's name against it" "$(echo "$SROW" | py "print(d['managerName'] or 'nobody')")" '^nobody$'
check " and the timeline says why" "$(echo "$SROW" | py "print(d['events'][0]['meta'])")" 'the linen room will address it'
check "the linen room addresses it to somebody who can decide" "$(mut request.reassign "{\"id\":\"$SRID\",\"managerId\":\"$CID\"}")" '"manager":"Chidi Director"'
check "and that manager can" "$(smut request.approve "$X" "{\"id\":\"$SRID\"}")" '"status":"accepted"'
check "the manager is given a manager of their own" "$(mut staff.patch "{\"id\":\"$MID\",\"managerId\":\"$CID\"}")" '"ok":true'
UPR=$(smut request.create "$M" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "now the same raise goes up a level" "$UPR" '"manager":"Chidi Director"'
check " still marked as moved on" "$UPR" '"escalated":true'
no " and never back to the raiser" "$UPR" 'Dele Manager'
UPID=$(echo "$UPR" | py "print(d['result']['id'])")
check "the raiser still cannot decide it" "$(smut request.approve "$M" "{\"id\":\"$UPID\"}")" 'No such request'
# Not deciding it is not the same as losing sight of it: whoever raised a request can follow it.
check "but can still follow it" "$(curl -s -o /dev/null -w '%{http_code}' -b "$M" "$B/my/orders/$UPID")" '200'
check "the level above declines it" "$(smut request.decline "$X" "{\"id\":\"$UPID\",\"reason\":\"Over allowance\"}")" '"status":"declined"'
check "and the wearer is told who did" "$(curl -s -b "$W" "$B/my/orders/$UPID")" 'Chidi Director'
# Somebody who does not report to you is nobody's to raise for — there is no route left that
# reaches them, so a manager gets the same sentence back as the desk does.
check "a manager still cannot raise for a stranger" "$(smut request.create "$M" "{\"subjectId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
# A request raised for somebody else sits on nobody's own order list, so without a place of its own
# it would go quiet on the person who typed it in. Read off what was drawn rather than off what was
# sent: the screen is handed all three lists whichever tab is showing, so the subject's name is in
# the bytes of both URLs either way. "for Jamila Wearer" is composed as a row is drawn, so it only
# appears on the list the tab actually opened.
RAISED=$(curl -s -b "$M" "$B/my/orders?tab=raised")
check "what they raised for others has its own list" "$RAISED" 'for Jamila Wearer'
OWN=$(curl -s -b "$M" "$B/my/orders")
check " and their own list opens with nothing in it" "$OWN" 'Nothing open'
no " and it is not folded in with their own orders" "$OWN" 'for Jamila Wearer'
echo "== anybody may be their own manager"
# The owner's decision, 12 September 2026. On a facility where one person is the whole register
# there is nobody else to name, and the staff app refuses every request from somebody with no manager
# set — so anybody may be recorded as their own, and approve their own requests and signed forms.
# None of it passes unseen: every such approval is marked self-approved on the record. This replaced
# a rule that allowed it only to somebody with at least one other person reporting to them, which is
# why Kofi below has nobody under him.
KID=$(mut staff.save '{"num":"K1","first":"Kofi","last":"Solo","group":"Registered Nurse","dept":"Linden Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
check "somebody with nobody under them" "$KID" '.'
check " can be recorded as their own manager" "$(mut staff.patch "{\"id\":\"$KID\",\"managerId\":\"$KID\"}")" '"ok":true'
check " and the register holds them as it" "$(curl -s -b "$C" "$B/api/backup" | py "print([s['managerId'] for s in d['staff'] if s['id']=='$KID'][0] == '$KID')")" '^True$'
check "Kofi claims an account" "$(claim "$KID" "$K" "k$TS@example.com")" '"ok":true'
# The complaint that started this: the staff app kept telling him no manager was set. Otto still has
# none, so the same sentence on his home is what proves this line is looking at the right words.
no "the staff app no longer says he has no manager" "$(curl -s -b "$K" "$B/my")" 'manager isnt recorded yet'
check " while somebody with none is still told" "$(curl -s -b "$O" "$B/my")" 'manager isnt recorded yet'
# Being your own manager is not having a team: the raise-for-others screen lists the people who
# report to you, and he is not one of them.
refused "being his own manager gives him no team to raise for" "$K" "/my/raise" "Who is it for"
KR=$(smut request.create "$K" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "he raises a request for himself" "$KR" '"code"'
check " addressed to himself" "$KR" '"manager":"Kofi Solo"'
check " and is told it is his to approve" "$KR" '"selfApproves":true'
no " and it was not moved on anywhere" "$KR" '"escalated":true'
KRID=$(echo "$KR" | py "print(d['result']['id'])")
KROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$KRID'][0]))")
check "the linen room sees it awaiting his own approval" "$(echo "$KROW" | py "print(d['status'], d['managerId']=='$KID')")" '^awaiting True$'
check "somebody else still cannot decide it" "$(smut request.approve "$O" "{\"id\":\"$KRID\"}")" 'No such request'
# The not-found page's words ride along in every staff-app page's data, so a page that refused him
# is told apart by its status, not by its text: a refusal is a 404, the queue is a 200.
check "his approvals queue opens" "$(curl -s -o /dev/null -w '%{http_code}' -b "$K" "$B/my/approvals")" '^200$'
KQ=$(curl -s -b "$K" "$B/my/approvals")
check " and is the queue" "$KQ" 'Approvals'
KAP=$(smut request.approve "$K" "{\"id\":\"$KRID\"}")
check "he approves it" "$KAP" '"status":"accepted"'
check " and is told it was a self-approval" "$KAP" '"selfApproved":true'
KROW2=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$KRID'][0]))")
check "the request records him as its manager" "$(echo "$KROW2" | py "print(d['managerName'], d['managerId']=='$KID')")" '^Kofi Solo True$'
check " and its timeline reads as his own" "$(echo "$KROW2" | py "print(d['events'][-1]['label'])")" '^Approved by Kofi Solo .* their own request$'
check " and says self-approved in so many words" "$(echo "$KROW2" | py "print(d['events'][-1]['meta'])")" '^Self-approved'
# Read off Jamila's first request, decided by Dele: the marking belongs to self-approvals only, and
# a timeline that said it of every approval would say nothing.
RIDTL=$(curl -s -b "$C" "$B/api/requests" | py "print(' | '.join(e['label']+' / '+(e['meta'] or '') for e in [r for r in d['requests'] if r['id']=='$RID'][0]['events']))")
check "an ordinary approval's timeline" "$RIDTL" 'Approved by Dele Manager'
no " never reads as a self-approval" "$RIDTL" 'their own request\|Self-approved'
# A signed order form for his own kit, with him as the approver. The pair staffId === byStaffId is
# the whole of the marking; a form countersigned by somebody else, beside it, must not carry it.
KA=$(mut approval.add "{\"staffId\":\"$KID\",\"byStaffId\":\"$KID\",\"sets\":\"2\",\"fte\":\"1.0\"}")
check "a signed form approved by himself is recorded" "$KA" '"id"'
check " and handed back as self-approved" "$KA" '"selfApproved":true'
KAID=$(echo "$KA" | py "print(d['result']['id'])")
KB=$(mut approval.add "{\"staffId\":\"$KID\",\"byStaffId\":\"$MID\",\"sets\":\"1\"}")
check "one countersigned by somebody else is recorded" "$KB" '"id"'
check " and is not" "$KB" '"selfApproved":false'
KBID=$(echo "$KB" | py "print(d['result']['id'])")
check "the record holds him as his own approver, and only on his own form" "$(curl -s -b "$C" "$B/api/backup" | py "a={x['id']: x for x in d['approvals']}; s=a.get('$KAID'); o=a.get('$KBID'); print(bool(s) and s['staffId']=='$KID' and s['byStaffId']=='$KID', bool(o) and o['byStaffId']=='$KID')")" '^True False$'
echo "== nobody approves a raise they made for somebody else"
# The one rule that stays. Lena is her own manager and Remy reports to her, so a raise of hers for
# Remy would be addressed to Lena — and goes up a level instead. The level up is Lena again, which is
# nobody above: it lands with no approver, in Needs an approver, as a raise with nobody above does.
LDID=$(mut staff.save '{"num":"L1","first":"Lena","last":"Lead","group":"Registered Nurse","dept":"Linden Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
RMID=$(mut staff.save '{"num":"R1","first":"Remy","last":"Report","group":"Registered Nurse","dept":"Linden Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
check "a lead and her report are on the register" "$(printf '%s\n' "$LDID" "$RMID" | grep -c .)" '^2$'
check " she is her own manager" "$(mut staff.patch "{\"id\":\"$LDID\",\"managerId\":\"$LDID\"}")" '"ok":true'
check " and his" "$(mut staff.patch "{\"id\":\"$RMID\",\"managerId\":\"$LDID\"}")" '"ok":true'
check "Lena claims an account" "$(claim "$LDID" "$L" "l$TS@example.com")" '"ok":true'
LTEAM=$(curl -s -b "$L" "$B/my/raise")
check "she has a team to raise for" "$LTEAM" 'Remy Report'
# Her own request, beside the one she raises for Remy: the difference between the two is the rule.
LOWN=$(smut request.create "$L" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "her own request lands on her" "$LOWN" '"manager":"Lena Lead"'
LOWNID=$(echo "$LOWN" | py "print(d['result']['id'])")
check " and she may approve it" "$(smut request.approve "$L" "{\"id\":\"$LOWNID\"}")" '"selfApproved":true'
LR=$(smut request.create "$L" "{\"subjectId\":\"$RMID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "she can raise for Remy" "$LR" '"code"'
check " and it is moved on" "$LR" '"escalated":true'
check " with nobody above her but herself, it has no approver" "$LR" '"manager":""'
no " and it is never hers" "$LR" 'Lena Lead'
LRID=$(echo "$LR" | py "print(d['result']['id'])")
check "so she cannot approve it" "$(smut request.approve "$L" "{\"id\":\"$LRID\"}")" 'No such request'
check "nor decline it" "$(smut request.decline "$L" "{\"id\":\"$LRID\",\"reason\":\"Over allowance\"}")" 'No such request'
LROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$LRID'][0]))")
check "the linen room sees it waiting on nobody" "$(echo "$LROW" | py "print(d['status'], d['managerId'] or 'nobody', d['managerName'] or 'nobody')")" '^awaiting nobody nobody$'
check " and the timeline says why" "$(echo "$LROW" | py "print(d['events'][0]['meta'])")" 'the linen room will address it'
check "the linen room cannot hand it back to her" "$(mut request.reassign "{\"id\":\"$LRID\",\"managerId\":\"$LDID\"}")" 'Lena raised this request'
# Sending it to Remy himself would make him its approver, and he isn't his own manager — his app
# couldn't let him decide it — so that is refused too. Only the rule for self-addressing can refuse
# this: Remy isn't the one who raised it.
check " nor send it to Remy, who isn't his own manager" "$(mut request.reassign "{\"id\":\"$LRID\",\"managerId\":\"$RMID\"}")" 'own manager'
check " but can address it to somebody who can decide" "$(mut request.reassign "{\"id\":\"$LRID\",\"managerId\":\"$CID\"}")" '"manager":"Chidi Director"'
check " and that manager can" "$(smut request.approve "$X" "{\"id\":\"$LRID\"}")" '"status":"accepted"'
# Somebody who manages only themselves strands nobody by leaving the register; somebody who is also
# named by a report still does.
check "somebody who is only their own manager can leave the register" "$(mut staff.patch "{\"id\":\"$KID\",\"inactive\":true}")" '"ok":true'
check " while one with a report still can't" "$(mut staff.patch "{\"id\":\"$LDID\",\"inactive\":true}")" 'still names Lena'
# The CSV import links managers in a second pass. A row naming its own staff number used to be
# skipped with an error; it is kept now, and a number nobody has is still an error beside it.
IMP=$(mut import.rows '{"kind":"staff","rows":[{"num":"Q1","first":"Quinn","last":"Self","group":"Registered Nurse","manager":"Q1"},{"num":"Q2","first":"Quade","last":"Nobody","group":"Registered Nurse","manager":"ZZ9"}]}')
check "an import naming a row as its own manager goes through" "$IMP" '"created":2'
check " a manager number nobody has is still an error" "$IMP" 'no staff member with number ZZ9'
check " and the self-named row is its own manager" "$(curl -s -b "$C" "$B/api/backup" | py "s=[s for s in d['staff'] if s['num']=='Q1']; print(bool(s) and s[0]['managerId']==s[0]['id'])")" '^True$'
echo "== the counter raises one over the desk"
# Somebody walks into the linen room without a phone, and the coordinator raises it for them. It
# takes the same list of garments the app sends, and it still goes to that person's own manager —
# a counter that could raise and approve in one move would make the approval a formality.
CR=$(mut request.raise "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$TID\",\"si\":0,\"qty\":2}],\"reason\":\"Worn out\"}")
check "the linen room raises it" "$CR" '"code"'
check " addressed to the wearer's own manager" "$CR" 'Dele Manager'
CRID=$(echo "$CR" | py "print(d['result']['id'])")
CRROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$CRID'][0]))")
check " carrying both garments" "$(echo "$CRROW" | py "print(d['lineCount'])")" '^2$'
check " three between them" "$(echo "$CRROW" | py "print(d['garments'])")" '^3$'
check " and the timeline says where it came from" "$(echo "$CRROW" | py "print(d['events'][0]['meta'])")" 'Raised at the counter'
check "an unknown garment is refused here too" "$(mut request.raise "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"nope\",\"si\":0,\"qty\":1}]}")" "isn't available"
check "so is a request with nothing on it" "$(mut request.raise "{\"staffId\":\"$WID\",\"lines\":[]}")" 'at least one garment'
check "and one for somebody with no manager" "$(mut request.raise "{\"staffId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" 'no manager recorded'
check "the manager decides it like any other" "$(smut request.approve "$M" "{\"id\":\"$CRID\"}")" '"status":"accepted"'
check " and the wearer is told who raised it" "$(curl -s -b "$W" "$B/my/orders/$CRID")" 'Raised for you by Sal Linen'
echo "== a bag for somebody with no ward stays at the counter"
# The round delivers to a ward, so a wearer whose ward was never filled in has nowhere for one to
# go. The check that a ward has somebody who can sign counted staff on `dept`, which matched every
# ward-less clerk in the building against every ward-less wearer — Nia against Pat — and let the
# bag out onto a round that would then be signed for by a stranger.
NR=$(mut request.raise "{\"staffId\":\"$PID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")
check "the counter raises one for the ward-less nurse" "$NR" '"code"'
NRID=$(echo "$NR" | py "print(d['result']['id'])")
check " her manager approves it" "$(smut request.approve "$M" "{\"id\":\"$NRID\"}")" '"status":"accepted"'
check " and the linen room picks it" "$(mut request.pick "{\"id\":\"$NRID\"}")" '"status":"picking"'
check "but it cannot be sent on the round" "$(mut request.round "{\"id\":\"$NRID\"}")" 'no ward recorded'
check " and the counter is what is left" "$(mut request.hold "{\"id\":\"$NRID\"}")" '"status":"ready"'
echo "== messages hang off one order"
check "the wearer asks" "$(smut request.message "$W" "{\"id\":\"$RID\",\"body\":\"Any chance of a 14?\"}")" '"id"'
check "the linen room replies" "$(mut request.reply "{\"id\":\"$RID\",\"body\":\"We have one put by.\"}")" '"id"'
THREAD=$(curl -s -b "$W" "$B/my/orders/$RID/messages")
check "both appear in the thread" "$THREAD" 'Any chance of a 14'
check " including the reply" "$THREAD" 'We have one put by'
check "a stranger cannot post to it" "$(smut request.message "$O" "{\"id\":\"$RID\",\"body\":\"hello\"}")" 'No such request'
refused "nor read it" "$O" "/my/orders/$RID/messages" "Send"
echo "== the record query with no order behind it"
check "the wearer raises one" "$(smut dispute.raise "$W" '{"body":"I handed two tunics back in August"}')" '"id"'
QUERIES=$(curl -s -b "$C" "$B/api/requests")
check "the linen room sees it" "$QUERIES" 'handed two tunics back'
DQ=$(echo "$QUERIES" | py "print(d['disputes'][0]['id'])")
check "and can mark it sorted" "$(mut dispute.resolve "{\"id\":\"$DQ\"}")" '"ok":true'
no "after which it is off the queue" "$(curl -s -b "$C" "$B/api/requests")" 'handed two tunics back'
echo "== the waitlist"
check "joining a size that is on the shelf is allowed" "$(smut waitlist.join "$W" "{\"itemId\":\"$IID\",\"si\":0}")" '"id"'
check "but only once" "$(smut waitlist.join "$W" "{\"itemId\":\"$IID\",\"si\":0}")" 'already on the list'
WL=$(curl -s -b "$W" "$B/my/waitlist?item=$IID&si=0")
check "the screen shows a position" "$WL" 'in queue'
check " and says waiting needs no approval" "$WL" "doesnt need approval"
WLID=$(curl -s -b "$C" "$B/api/requests" | py "print(d['waiting'][0]['id'])")
check "the linen room offers it when stock lands" "$(mut waitlist.offer "{\"id\":\"$WLID\"}")" '"ok":true'
ACC=$(smut waitlist.accept "$W" "{\"id\":\"$WLID\"}")
check "accepting raises a request" "$ACC" '"request"'
ARID=$(echo "$ACC" | py "print(d['result']['request']['id'])")
check " which still needs the manager" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$ARID'][0]['status'])")" '^awaiting$'
check "leaving a list you are not on is refused" "$(smut waitlist.leave "$W" '{"id":"nope"}')" 'Not on that list'
echo "== the kit check"
check "answering with no cycle open is refused" "$(smut kit.answer "$W" "{\"itemId\":\"$IID\",\"si\":1,\"onRecord\":2,\"confirmed\":2}")" 'No kit check is open'
refused "the phone screen does not exist between rounds" "$W" "/my/kitcheck" "Still have"
check "the linen room opens a round" "$(mut kitcheck.open '{"dueBy":"2026-12-01"}')" '"id"'
check "and cannot open a second" "$(mut kitcheck.open '{"dueBy":"2026-12-01"}')" 'already running'
# Give them something to count. The fleece is what the answers below are checked against: the tunic
# has been handed to Jamila more than once already in this file — at the counter, and again here —
# so the figure on her record for it is nothing this script chose. The fleece has never been near
# her, so three is three.
# One tunic, not three: counting the request lines a manager has already approved for her, she holds
# four tops, and three more would take her past the six anyone may hold. The tunic only has to be on
# her record for the screen to list it.
check "the wearer is issued garments" "$(mut issue.create "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" '"ok":true'
check " and a garment nobody has handed them before" "$(mut issue.create "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"$FID\",\"si\":0,\"qty\":3}]}")" '"ok":true'
KC=$(curl -s -b "$W" "$B/my/kitcheck")
# The heading moves with the copy, so the checks sit on the things that cannot: the screen names
# the garments the record claims and promises nothing is chargeable. The last one is the rule this
# product keeps getting wrong — a wearer takes their uniform home and launders it themselves, so a
# screen that asks them to go and look in a locker is asking about a locker they do not have, and
# gets answered from imagination or not at all.
check "the screen appears" "$KC" 'Kit check'
check " listing what the record says they hold" "$KC" 'Navy tunic'
check " counted off against the record" "$KC" 'on your record'
check " and says nothing is chargeable" "$KC" 'chargeable'
no " and never sends them to a locker" "$KC" '[Ll]ocker'
# The onRecord in the payload came off the phone and is worth nothing as evidence, so the 99 here
# is a deliberate lie: a shortfall of 2 is only possible if the server threw it away and re-read
# the record. Taking the client's word for it would report a shortfall of 98.
SHORT=$(smut kit.answer "$W" "{\"itemId\":\"$FID\",\"si\":0,\"onRecord\":99,\"confirmed\":1}")
check "a shortfall is recorded" "$SHORT" '"short":2'
check " against the record's figure, not the phone's" "$(curl -s -b "$C" "$B/api/requests" | py "print([s for s in d['shortfalls'] if s['staffId']=='$WID' and s['item']=='Fleece jacket'][0]['onRecord'])")" '^3$'
# Confirming more than the record says is clamped, not carried through as a negative shortfall:
# without the clamp this answer reports -6 and the linen room reconciles against it.
check "confirming more than the record is clamped" "$(smut kit.answer "$W" "{\"itemId\":\"$FID\",\"si\":0,\"onRecord\":3,\"confirmed\":9}")" '"short":0'
CYC=$(curl -s -b "$C" "$B/api/requests" | py "print(d['cycle']['id'])")
check "the linen room closes the round" "$(mut kitcheck.close "{\"id\":\"$CYC\"}")" '"ok":true'
echo "== a wearer still cannot reach the register"
check "no coordinator mutation" "$(curl -s -b "$W" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -d '{"op":"request.pick","payload":{"id":"x"}}')" 'Not signed in'
check "no backup" "$(curl -s -b "$W" "$B/api/backup")" 'Not signed in'
check "no linen-room request list" "$(curl -s -b "$W" "$B/api/requests")" 'Not signed in'
refused "no approvals queue without reports" "$W" "/my/approvals" "Approve"
refused "no ward view without reports" "$W" "/my/ward" "On the ward"
refused "no ward round without the flag" "$W" "/my/round" "Sign for"
# The flag is not enough on its own: the round is one ward's bags, and a clerk whose ward was never
# recorded has none. Left as a plain match on `dept`, which defaults to an empty string, Nia's
# round would have been every ward-less person's bags in the facility.
refused "nor with the flag but no ward" "$N" "/my/round" "Sign for"
check "the manager does get an approvals queue" "$(curl -s -b "$M" "$B/my/approvals")" 'Approvals'
check "and a ward view" "$(curl -s -b "$M" "$B/my/ward")" 'Items held'
echo "== the website's Log in box opens the staff app too"
# A wearer reaches the product the way anybody else does — the home page, then Log in — and types
# the details they set up in the app. So the one box asks the coordinator table first and the
# register only when that address has no coordinator account.
#
# It matters that this is a lookup and not a second attempt. "Try the coordinator, and if that fails
# try the staff one" would score a failure against every staff sign-in, and those ceilings count
# failures: behind one hospital's NAT address at shift change that is a locked-out ward.
#
# Both markers are read off the record: the name says whose session it is, and the static label says
# the screen actually rendered. Either alone would pass on a page that came back for the wrong
# reason.
P="$T/tc-sa-web.txt"; rm -f "$P"
check "the wearer signs in at the website's Log in box" "$(curl -s -c "$P" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"wearerpass1\"}")" '"staff":true'
WEB=$(curl -s -b "$P" "$B/my")
check " and the cookie it set opens her own record" "$WEB" 'Jamila'
check " which is the staff app, not a web page" "$WEB" 'Request an item'
check " a wrong password there is refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"nope\"}")" 'Email or password doesn'
# The app's own door, which the printed slip and the Play app use. The two share one implementation
# now, and nothing else in this file signs in through it — a refactor that broke it would otherwise
# ship green.
Q="$T/tc-sa-door.txt"; rm -f "$Q"
check "the staff app's own door still signs her in" "$(curl -s -c "$Q" -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"wearerpass1\"}")" '"ok":true'
check " and that cookie opens the same record" "$(curl -s -b "$Q" "$B/my")" 'Jamila'
check " a wrong password at that door is refused too" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"nope\"}")" 'Email or password doesn'
# A coordinator is still a coordinator at the same box, and is sent to the counter rather than the
# staff app.
R="$T/tc-sa-coordweb.txt"; rm -f "$R"
CW=$(curl -s -c "$R" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"password123\"}")
check "the linen room signs in at the same box" "$CW" '"ok":true'
no " and is not sent to the staff app" "$CW" '"staff":true'
# One address, one destination. Where both exist the coordinator account wins — which also means the
# staff password on that address opens nothing, and that person reaches their record from inside the
# app. That is the cost of the rule, so it is written down here rather than discovered later.
BID=$(mut staff.save '{"num":"B1","first":"Bo","last":"Both","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
check "somebody claims a staff account on the linen room's own address" "$(claim "$BID" "$T/tc-sa-both.txt" "$CO")" '"ok":true'
BW=$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"password123\"}")
check " the coordinator password still opens the counter" "$BW" '"ok":true'
no " and does not open the staff app" "$BW" '"staff":true'
check " while the staff password on that address opens nothing" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"wearerpass1\"}")" 'Email or password doesn'
echo "== unknown ops are refused, not ignored"
check "made-up staff op" "$(smut nonsense.thing "$W" '{}')" 'Unknown action'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# ThreadCount update-wave smoke test against a local dev server. Exercises every new op end-to-end.
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"
J=${TMP:-/tmp}/tc-cj.txt; rm -f "$J"
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}"; }
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$out"; fi; }
jget() { python3 -c "import sys,json; d=json.load(sys.stdin); print(eval('d$1'))"; }
TS=$(date +%s); EMAIL="e2e$TS@example.com"
echo "== signup"
R=$(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\":\"E2E Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")
check "signup" "$R" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
echo "== setup: depts, suppliers, catalog, staff, opening"
check "dept A" "$(mut dept.save '{"name":"Willow Ward","cc":"RGH-3010"}')" '"ok":true'
check "dept B" "$(mut dept.save '{"name":"Security","cc":"RGH-5090"}')" '"ok":true'
check "dept dup rejected" "$(mut dept.save '{"name":"willow ward","cc":"1"}')" 'already'
R=$(mut supplier.add '{"name":"Northline Workwear"}'); check "supplier add" "$R" '"id"'; SUP=$(echo "$R" | jget '["result"]["id"]')
check "supplier update lead" "$(mut supplier.update "{\"id\":\"$SUP\",\"lead\":\"10\",\"contact\":\"Dana\",\"account\":\"ACC-1\"}")" '"ok":true'
check "supplier dup rejected" "$(mut supplier.add '{"name":"northline workwear"}')" 'already'
# Scrub Pant is for every group. Sam, in Security, has a pair ordered in further down to prove an
# order-in takes the product's own supplier; tagged for nurses only, it would be refused at the counter
# as outside his staff group before that was ever looked at.
R=$(mut import.rows '{"kind":"catalog","rows":[{"item":"RN Scrub Top","gender":"Unisex","sku":"TOP1","supplier":"Northline Workwear","cost":"30","group":"Registered Nurse","sizes":"S|M|L"},{"item":"Scrub Pant","gender":"Unisex","sku":"PANT1","supplier":"Harbour Embroidery","cost":"25","group":"All","sizes":"S|M|L"},{"item":"Security Shirt","gender":"Male","sku":"SEC1","supplier":"Northline Workwear","cost":"40","group":"Security","sizes":"M|L"}]}')
check "catalog import 3" "$R" '"created":3'
check "supplier auto-created from import" "$(mut supplier.add '{"name":"Harbour Embroidery"}')" 'already'
R=$(mut import.rows '{"kind":"staff","rows":[{"num":"1001","first":"Nina","last":"Nurse","group":"Registered Nurse","dept":"Willow Ward","top":"M","pants":"M"},{"num":"2002","first":"Sam","last":"Guard","group":"Security","dept":"Security","ent":"2","top":"L","pants":"L"}]}')
check "staff import 2" "$R" '"created":2'
check "opening import" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"TOP1","size":"M","opening":"10","reorder":"3"},{"sku":"PANT1","size":"M","opening":"10"},{"sku":"SEC1","size":"L","opening":"1","reorder":"2"},{"sku":"TOP1","size":"S","opening":"0","reorder":"2"}]}')" '"created":4'
# Footwear is not issued in this linen room, so a shoe column on the staff template would have wards
# filling in sizes for something nobody can ever hand them. Read the template's own header row out of
# csv.ts and look at the columns themselves: pinning one long literal header string, as this used to,
# stopped matching anything the day a manager column was added between cc and top, and from then on
# the check passed whatever the file said.
STAFF_HDR=$(sed -n 's/^[[:space:]]*staff:.*headers: "\([^"]*\)".*$/\1/p' "$(dirname "$0")/../lib/csv.ts")
check "staff CSV template header row found" "$STAFF_HDR" '^num,'
check "staff CSV template has no shoe column" "$(echo ",$STAFF_HDR," | grep -c ',[^,]*[Ss]hoe[^,]*,' || true)" '^0$'
echo "== fetch ids via backup"
BK=$(curl -s -b "$J" "$B/api/backup")
check "backup v2" "$BK" 'threadcount-backup-v2'
NINA=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['id'] for s in d['staff'] if s['num']=='1001'][0])")
SAM=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['id'] for s in d['staff'] if s['num']=='2002'][0])")
TOP=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='TOP1'][0])")
PANT=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='PANT1'][0])")
SEC=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='SEC1'][0])")
check "lastBackup stamped" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; print(json.load(sys.stdin)['facility']['lastBackup'])")" '^20'
echo "== manager approvals + nursing issue"
R=$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J. Barnes, Ward 3A\",\"sets\":\"3\",\"fte\":\"1.0\"}"); check "approval add" "$R" '"id"'; AP=$(echo "$R" | jget '["result"]["id"]')
check "approval needs by" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"\",\"sets\":\"3\"}")" 'required'
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":2,\"src\":\"stock\"},{\"itemId\":\"$PANT\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")
check "nursing issue (no limit, 4 items)" "$R" '"stock":4'
check "approval deducted 2, 1 remaining" "$R" '"apDeducted":2,"apRemaining":1'
check "second approval" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"K. Lee\",\"sets\":\"2\"}")" '"id"'
# Ask for fewer sets than are open — 1 left on Barnes, 2 on Lee — so the split shows. Draining both
# in one go proves nothing about order; taking 1+1 does, and both approvals were raised the same day,
# so the only thing deciding which one moves first is the order they were entered in.
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
check "deduct of 2 rolls across approvals (1+1)" "$R" '"apDeducted":2,"apRemaining":1'
check "oldest approval drained first" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print(' '.join(a['byName']+'='+str(a['used']) for a in sorted(d['approvals'], key=lambda a: a['byName'])))")" '^J\. Barnes, Ward 3A=3 K\. Lee=1$'
# Asking for more sets than the manager has signed for takes what is open and stops there.
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":5,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
check "deduct clamped to the 1 set still open" "$R" '"apDeducted":1,"apRemaining":0'
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":1,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
check "no open approval -> 0 deducted" "$R" '"apDeducted":0'
echo "== the six-set ceiling + order-in supplier from product"
# Seven shirts ordered in for somebody holding nothing is one past the six tops anyone may hold.
# Sam's own figure of 2 on the register is a yearly reporting number now, so it is the ceiling that
# has to refuse this — and refusing it leaves no order behind to move the reorder figures below.
R=$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":0,\"qty\":7,\"src\":\"order\"}]}")
check "server refuses a seventh top without an override (six sets held is the ceiling)" "$R" 'the most anyone holds is 6 sets'
# One Security Shirt L on the shelf and two cart lines asking for one each: either line would pass on
# its own, so only a check that adds the lines up first refuses this. Asking 2+2, as this used to, is
# refused just as fast by a naive per-line check, which left the rule the label names untested.
R=$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"},{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
check "cumulative shelf check (1 on hand, 1+1 asked)" "$R" 'Not enough on the shelf'
R=$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"},{\"itemId\":\"$PANT\",\"si\":0,\"qty\":1,\"src\":\"order\",\"supplier\":\"WRONG\"}]}")
check "issue stock + order-in" "$R" '"stock":1,"ordered":1'
check "order-in cc uses staff dept (no override)" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['cc'] for o in d['orders'] if o['orderFor']=='Staff Member'][0])")" 'Security'
BK=$(curl -s -b "$J" "$B/api/backup")
check "order-in used product supplier (Harbour), not client value" "$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['supplier'] for o in d['orders'] if o['orderFor']=='Staff Member'])")" 'Harbour'
check "replenish drafts per supplier" "$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(sorted(o['supplier'] for o in d['orders'] if o['replenish']))")" "Harbour Embroidery', 'Northline Workwear"
echo "== order flagged (2x reorder, net of on-order) + duplicate"
R=$(mut stock.orderFlagged '{}'); check "order flagged added lines" "$R" '"added":[1-9]'
# "added" is the count of need lines the merge looked at, not the count it changed, so it reads the
# same on the second run whether the merge tops a line up or piles onto it. The draft's own line count
# and total are the only things that tell those two apart.
qn(){ curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0]; print(len(o['lines']), sum(l['qty'] for l in o['lines']))"; }
Q1=$(qn)
check "the draft the merge writes into can be read" "$Q1" '^[0-9][0-9]* [0-9][0-9]*$'
mut stock.orderFlagged '{}' >/dev/null
check "order flagged idempotent (max merge)" "$(qn)" "^$Q1\$"
BK=$(curl -s -b "$J" "$B/api/backup")
ORD=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['id'] for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0])")
SECQ=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0]; print([l['qty'] for l in o['lines'] if l['itemId']=='$SEC' and l['size']=='L'][0])")
check "SEC L topped to 2x reorder (draft line itself not netted: 4-0=4)" "$SECQ" '^4$'
R=$(mut order.duplicate "{\"id\":\"$ORD\"}"); check "duplicate order" "$R" '"code":"ORD-'
DUP=$(echo "$R" | jget '["result"]["id"]')
check "receive on a draft rejected" "$(mut order.receive "{\"id\":\"$DUP\",\"lines\":[]}")" 'Mark the order as ordered'
check "dup is Draft with notes" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['id']=='$DUP'][0]; print(o['status'], o['notes'])")" 'Draft Duplicated'
echo "== staff patch / alterations / catalog addSize / discontinue"
check "staff deactivate" "$(mut staff.patch "{\"id\":\"$SAM\",\"inactive\":true}")" '"ok":true'
# ok:true only says a row was written. The flag is there to stop the next issue at the counter, so ask
# for one — without this, dropping the write to `inactive` leaves the section green.
check "an inactive staff member can't be issued to" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" 'inactive'
check "staff cc override" "$(mut staff.patch "{\"id\":\"$NINA\",\"ccOverride\":\"RGH-5090\"}")" '"ok":true'
check "cc override recorded on the staff row" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['ccOverride'] for s in d['staff'] if s['num']=='1001'][0])")" '^RGH-5090$'
check "staff num immutable" "$(mut staff.save "{\"id\":\"$NINA\",\"num\":\"9999\",\"first\":\"Nina\",\"last\":\"Nurse\"}")" "can't be changed"
R=$(mut alteration.add "{\"staffId\":\"$NINA\",\"garment\":\"Scrub pants (M)\",\"desc\":\"hem 4cm\"}"); check "alteration add" "$R" '"id"'; ALT=$(echo "$R" | jget '["result"]["id"]')
check "alteration advance" "$(mut alteration.advance "{\"id\":\"$ALT\"}")" '"ok":true'
check "alteration at tailor" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['alterations'][0]['status'])")" 'At tailor'
check "add size" "$(mut catalog.update "{\"id\":\"$TOP\",\"addSize\":\"XL\"}")" '"ok":true'
check "dup size rejected" "$(mut catalog.update "{\"id\":\"$TOP\",\"addSize\":\"XL\"}")" 'already'
check "discontinue" "$(mut catalog.update "{\"id\":\"$SEC\",\"archived\":true}")" '"ok":true'
check "delete item with history blocked" "$(mut catalog.delete "{\"id\":\"$SEC\"}")" 'Discontinue'
echo "== settings + logo"
check "settings finance" "$(mut settings.update '{"glAccount":"631020","journalDesc":"Uniform issues","exceptionHigh":"3"}')" '"ok":true'
check "logo reject non-image" "$(mut settings.update '{"logoData":"data:text/html;base64,PGI+"}')" 'must be'
check "logo rejects svg" "$(mut settings.update '{"logoData":"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="}')" 'must be'
check "logo accept png" "$(mut settings.update '{"logoData":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="}')" '"ok":true'
check "logo route serves png" "$(curl -s -b "$J" -o /dev/null -w '%{content_type}' "$B/api/logo")" 'image/png'
check "supplier remove blocked (in use)" "$(mut supplier.remove "{\"id\":\"$SUP\"}")" "can't be removed"
echo "== stocktake"
R=$(mut stocktake.apply "{\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"counted\":3},{\"itemId\":\"$PANT\",\"si\":1,\"counted\":8}]}")
check "stocktake apply: 2 counted, 1 variance filed" "$R" '"counted":2,"variances":1'
# The tally above is paperwork. Ten RN tops came in and five have been issued, so the shelf believes 5
# and the counter found 3 — unless the count writes that gap onto the level, the same two garments go
# missing again next month. -2 is the adjustment the count wrote, not the number counted.
check "stocktake moved the shelf (5 on hand, counted 3)" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['adj'] for s in d['stock'] if s['itemId']=='$TOP' and s['sizeIndex']==1][0])")" '^-2$'
R=$(mut stocktake.apply "{\"lines\":[{\"itemId\":\"$PANT\",\"si\":1,\"counted\":8}]}")
check "clean count can be filed (0 variances)" "$R" '"counted":1,"variances":0'
echo "== pages render (200)"
for p in /app /app/stock "/app/stock/$TOP" /app/issue /app/stocktake /app/orders "/app/orders/$ORD" /app/report /app/staff "/app/staff/$NINA" /app/settings "/print?type=collection&staffName=Nina"; do
C=$(curl -s -b "$J" -o /dev/null -w '%{http_code}' "$B$p"); [ "$C" = "200" ] && ok "GET $p" || fail "GET $p" "$C"
done
check "slip prints logo img" "$(curl -s -b "$J" "$B/print?type=collection")" 'data:image/png'
echo "== cost at issue, lead time, back-order parent, admin gates"
BK=$(curl -s -b "$J" "$B/api/backup")
check "issues carry cost at issue (30.0 for TOP)" "$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(sorted(set(i['cost'] for i in d['issues'] if i['itemId']=='$TOP')))")" '\[30'
check "replenish draft expected = today+10 (Northline lead 10)" "$(echo "$BK" | python3 -c "import sys,json,datetime; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0]; print((datetime.date.fromisoformat(o['expected'])-datetime.date.fromisoformat(o['date'])).days)")" '^10$'
# The other branch, read off the order it belongs to: Harbour was created by the catalogue import and
# nobody has given it a lead time, so the order-in raised for a staff member falls back to a fortnight.
check "order-in expected = today+14 (Harbour has no lead)" "$(echo "$BK" | python3 -c "import sys,json,datetime; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['orderFor']=='Staff Member'][0]; print((datetime.date.fromisoformat(o['expected'])-datetime.date.fromisoformat(o['date'])).days)")" '^14$'
check "mark dup ordered" "$(mut order.status "{\"id\":\"$DUP\",\"status\":\"Ordered\"}")" '"ok":true'
LINE=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['id']=='$DUP'][0]; l=o['lines'][0]; print(l['id'], l['qty'])")
LID=${LINE% *}; LQ=${LINE#* }
R=$(mut order.receive "{\"id\":\"$DUP\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1,\"dest\":\"shelf\"}]}")
check "short receive ok" "$R" '"ok":true'
check "back order links to parent via parentId" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['parentId']=='$DUP' for o in d['orders'] if o['status']=='Back Order'])")" 'True'
check "second receive rejected" "$(mut order.receive "{\"id\":\"$DUP\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1}]}")" 'receiv'
R=$(mut users.add "{\"email\":\"issuer$TS@example.com\",\"password\":\"password123\",\"first\":\"Iss\",\"last\":\"Uer\",\"role\":\"ISSUER\"}"); check "add issuer" "$R" '"id"'
J2=${TMP:-/tmp}/tc-cj2.txt; rm -f "$J2"
curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"issuer$TS@example.com\",\"password\":\"password123\"}" >/dev/null
check "issuer cannot set reorder" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"stock.reorder\",\"payload\":{\"itemId\":\"$TOP\",\"si\":0,\"reorder\":9}}")" 'Admin only'
check "issuer cannot bind barcode" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"barcode.bind\",\"payload\":{\"code\":\"123\",\"itemId\":\"$TOP\",\"si\":0}}")" 'Admin only'
check "issuer can still issue" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"issue.create\",\"payload\":{\"staffId\":\"$NINA\",\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}}")" '"stock":1'
echo "== backup restore round-trip"
BK=$(curl -s -b "$J" "$B/api/backup")
R=$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$BK}")
check "restore ok" "$R" '"ok":true'
BK2=$(curl -s -b "$J" "$B/api/backup")
# Both approvals are fully spent by now. A row count still passes if the restore drops `used`, and a
# facility restored that way hands the wearer five manager-approved sets they have already been given —
# so read the balances back, not the tally.
check "restore kept suppliers/approvals/alterations" "$(echo "$BK2" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['suppliers']), sorted((a['sets'], a['used']) for a in d['approvals']), len(d['alterations']), [s['lead'] for s in d['suppliers'] if s['name']=='Northline Workwear'][0])")" '^2 \[(2, 2), (3, 3)\] 1 10$'
echo "== the revision the screens watch"
# Every open screen polls /api/rev and reloads only when the number moves, so two things have to
# hold: a mutation must move it, and the mutation must hand back the number it produced — without
# that, the screen that made the change refreshes itself a second time when it next polls.
REV1=$(curl -s -b "$J" "$B/api/rev" | jget '["rev"]')
check "a signed-in coordinator can read it" "$REV1" '^[0-9][0-9]*$'
R=$(mut dept.save '{"name":"Rev Ward","cc":"RGH-3030"}')
check "a mutation hands back the new revision" "$R" '"rev":[0-9]'
REV2=$(curl -s -b "$J" "$B/api/rev" | jget '["rev"]')
check "and the number it hands back is the one on the facility" "$(echo "$R" | jget '["rev"]')" "^$REV2\$"
check "which has moved" "$(python3 -c "print('up' if $REV2 > $REV1 else 'stuck')")" '^up$'
# A read is not a change: polling must not make the poll look busy.
curl -s -b "$J" "$B/api/backup" > /dev/null
check "reading changes nothing" "$(curl -s -b "$J" "$B/api/rev" | jget '["rev"]')" "^$REV2\$"
check "and nobody signed in gets a number at all" "$(curl -s "$B/api/rev")" 'Not signed in'
echo "== wipe"
check "wipe" "$(mut data.wipeActivity '{"confirm":"WIPE"}')" '"ok":true'
check "wipe cleared approvals" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['approvals']), len(d['issues']))")" '^0 0$'
echo; echo "PASS=$PASS FAIL=$FAIL"
[ $FAIL -eq 0 ]
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Generate the ThreadCount Staff launcher icon.
python3 scripts/make-staff-icon.py
The two apps have to be the same family and still be telling apart on a home screen, where the
only other clue is a truncated label. So this is the counter app's mark exactly — four stepped
bars in the 60/48/34/22 proportions from tc-icon.svg inverted onto ink: the leading bar keeps
the accent, the rest go from ink to paper, and the ground goes from paper to ink.
Light tile versus dark tile is the strongest difference available at 48px, it costs no new
artwork, and it stays on-brand: the app's own splash is already ink.
The geometry is measured from the counter app's own PNGs rather than guessed, and expressed as
fractions of the canvas so every density comes out identical rather than merely similar. Bars are
drawn at 4x and downsampled, which is what keeps a 43-pixel-wide bar from landing half a pixel
off at mdpi.
"""
import os
from PIL import Image
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RES = os.path.join(ROOT, "android-staff/app/src/main/res")
INK = (32, 30, 29, 255)
PAPER = (243, 242, 242, 255)
ACCENT = (236, 48, 19, 255)
# Measured from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png (432px):
# bars start at x=129, widths 174/139/98/64, first at y=147, pitch 40, height 23.
FG = {
"x0": 129 / 432,
"widths": [174 / 432, 139 / 432, 98 / 432, 64 / 432],
"y0": 147 / 432,
"pitch": 40 / 432,
"height": 23 / 432,
}
# Measured from .../mipmap-xxxhdpi/ic_launcher.png (192px): the legacy tile crops tighter, so the
# mark sits larger in frame than it does inside the adaptive icon's safe zone.
LEGACY = {
"x0": 38 / 192,
"widths": [116 / 192, 93 / 192, 66 / 192, 43 / 192],
"y0": 47 / 192,
"pitch": 27 / 192,
"height": 21 / 192,
}
SS = 4 # supersample factor
def draw(size, geom, background):
"""The mark at `size` px. `background` None gives a transparent adaptive foreground."""
n = size * SS
im = Image.new("RGBA", (n, n), background or (0, 0, 0, 0))
px = Image.new("RGBA", (1, 1)) # placeholder so the linter sees px used below
del px
for i, wfrac in enumerate(geom["widths"]):
x0 = round(geom["x0"] * n)
y0 = round((geom["y0"] + i * geom["pitch"]) * n)
w = round(wfrac * n)
h = round(geom["height"] * n)
# The leading bar carries the accent; the rest are paper, because the ground is now ink.
colour = ACCENT if i == 0 else PAPER
im.paste(colour, (x0, y0, x0 + w, y0 + h))
return im.resize((size, size), Image.LANCZOS)
DENSITIES = {"mdpi": (48, 108), "hdpi": (72, 162), "xhdpi": (96, 216), "xxhdpi": (144, 324), "xxxhdpi": (192, 432)}
def main():
if not os.path.isdir(RES):
raise SystemExit(f"no android-staff res dir at {RES}")
for density, (legacy_px, fg_px) in DENSITIES.items():
d = os.path.join(RES, f"mipmap-{density}")
os.makedirs(d, exist_ok=True)
draw(fg_px, FG, None).save(os.path.join(d, "ic_launcher_foreground.png"))
tile = draw(legacy_px, LEGACY, INK)
tile.save(os.path.join(d, "ic_launcher.png"))
# The launcher applies its own mask, so the round variant is the same tile — which is what
# the counter app ships too.
tile.save(os.path.join(d, "ic_launcher_round.png"))
print(f" {density}: foreground {fg_px}px, launcher {legacy_px}px")
bg = os.path.join(RES, "values/ic_launcher_background.xml")
with open(bg, "w", encoding="utf-8") as f:
f.write('<?xml version="1.0" encoding="utf-8"?>\n<resources>\n'
' <!-- Ink, where the counter app is paper. This is what tells the two apps\n'
' apart on a home screen. -->\n'
' <color name="ic_launcher_background">#201E1D</color>\n</resources>\n')
print(f" background -> #201E1D")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
/* Move existing photos out of the database and onto disk.
*
* node scripts/photos-to-disk.cjs # report only
* node scripts/photos-to-disk.cjs --write # actually move them
*
* Safe to run repeatedly: it only touches rows that still have base64 in `data` and no `path`,
* and it clears `data` only after the file is on disk and has been read back and verified byte
* for byte. A crash halfway leaves rows that are readable from either place, which is why the
* serving route handles both.
*/
require("dotenv/config");
const path = require("path");
const { mkdir, writeFile, readFile } = require("fs/promises");
const { PrismaClient } = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
const WRITE = process.argv.includes("--write");
const ROOT = process.env.PHOTO_DIR || path.join(process.cwd(), ".photos");
const EXT = { "image/jpeg": "jpg", "image/png": "png" };
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
function parse(data) {
const m = /^data:(image\/(?:jpeg|png));base64,([A-Za-z0-9+/=]+)$/.exec(data || "");
return m ? { mime: m[1], bytes: Buffer.from(m[2], "base64") } : null;
}
(async () => {
const rows = await prisma.photo.findMany({
where: { path: "", NOT: { data: "" } },
select: { id: true, facilityId: true, data: true },
});
console.log(`${rows.length} photo(s) still in the database`);
if (!rows.length) { await prisma.$disconnect(); return; }
const total = rows.reduce((n, r) => n + r.data.length, 0);
console.log(`about ${(total / 1024 / 1024).toFixed(1)} MB of base64`);
console.log(`destination: ${ROOT}`);
if (!WRITE) { console.log("\ndry run — pass --write to move them"); await prisma.$disconnect(); return; }
let moved = 0, skipped = 0;
for (const r of rows) {
const p = parse(r.data);
if (!p) { skipped++; continue; }
const rel = `${r.facilityId}/${r.id}.${EXT[p.mime] || "bin"}`;
const full = path.join(ROOT, rel);
await mkdir(path.dirname(full), { recursive: true });
await writeFile(full, p.bytes);
// Read it back before dropping the only other copy.
const back = await readFile(full);
if (!back.equals(p.bytes)) { console.error("verify failed for", r.id); skipped++; continue; }
await prisma.photo.update({
where: { id: r.id },
data: { path: rel, mime: p.mime, bytes: p.bytes.length, data: "" },
});
moved++;
}
console.log(`moved ${moved}, skipped ${skipped}`);
await prisma.$disconnect();
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
+39
View File
@@ -0,0 +1,39 @@
/* Mint a reset token for the e2e suite and print the RAW value.
*
* The app deliberately never stores the raw token only its SHA-256 so there is no way to read
* one back out of the database, which is the property the whole design rests on. To test the
* happy path end to end without a live mailbox, the suite therefore mints its own: it writes a row
* exactly as /api/auth/forgot would and keeps the raw half.
*
* Refuses to run in production. It needs DATABASE_URL, so anyone who could use it already owns the
* database but a guard costs nothing and states the intent.
*
* node scripts/reset-mint.cjs <email> [ttlMs] -> prints the raw token
*/
require("dotenv/config");
const { createHash, randomBytes } = require("crypto");
const { PrismaClient } = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
if (process.env.NODE_ENV === "production") {
console.error("refusing to run in production");
process.exit(2);
}
const email = process.argv[2];
const ttl = Number(process.argv[3] || 60 * 60 * 1000);
if (!email) { console.error("usage: reset-mint.cjs <email> [ttlMs]"); process.exit(2); }
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
(async () => {
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (!user) { console.error("no such user"); process.exit(1); }
const token = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(token).digest("hex");
await prisma.passwordReset.create({
data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + ttl), requestIp: "e2e" },
});
console.log(token);
await prisma.$disconnect();
})().catch(async (e) => { console.error(e.message); await prisma.$disconnect(); process.exit(1); });
+27
View File
@@ -0,0 +1,27 @@
/* Read the newest reset row for an address, for the e2e suite only.
*
* The suite needs to prove a token is single-use and expires, which means holding one something
* an attacker cannot do, since the table stores only the SHA-256 and the raw token exists solely
* in the email. So this prints the hash, and the suite drives the API with a token it mints itself
* against a row it inserts. Nothing here weakens the running app.
*
* node scripts/reset-token.cjs <email> -> prints "<id> <tokenHash> <expiresAt> <usedAt>"
*/
require("dotenv/config");
const { PrismaClient } = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
const email = process.argv[2];
if (!email) { console.error("usage: reset-token.cjs <email>"); process.exit(2); }
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
(async () => {
const row = await prisma.passwordReset.findFirst({
where: { user: { email } },
orderBy: { createdAt: "desc" },
select: { id: true, tokenHash: true, expiresAt: true, usedAt: true },
});
if (row) console.log(`${row.id} ${row.tokenHash} ${row.expiresAt.toISOString()} ${row.usedAt ? row.usedAt.toISOString() : "-"}`);
await prisma.$disconnect();
})().catch(async (e) => { console.error(e.message); await prisma.$disconnect(); process.exit(1); });
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Print the SQL that would bring the current dev database up to prisma/schema.prisma.
#
# bash scripts/schema-diff.sh > prisma/migrations/<timestamp>_<name>/migration.sql
#
# `prisma migrate dev` is the usual way to do this, but it needs a clean shadow database and the
# local `prisma dev` server's shadow carries residue that makes it fail on the first migration.
# Diffing the live dev database against the schema needs no shadow at all.
set -eu
cd "$(dirname "$0")/.."
set -a
. ./.env
set +a
npx prisma migrate diff \
--from-config-datasource \
--to-schema prisma/schema.prisma \
--script
+14
View File
@@ -0,0 +1,14 @@
/* Print the current six-digit code for a base32 TOTP secret.
*
* TOTP_SECRET=JBSWY3DPEHPK3PXP npx tsx scripts/totp-code.ts
*
* For the end-to-end suites, which enrol a second factor through the real routes and then need a
* live code to sign in with. The secret comes from the environment, never an argument argv is
* visible to every process on the box. Uses the product's own implementation (lib/totp.ts), which
* scripts/check-totp.ts proves against RFC 6238's published vectors.
*/
import { base32Decode, totp } from "../lib/totp";
const secret = process.env.TOTP_SECRET || "";
if (!secret) { console.error("TOTP_SECRET must be set in the environment"); process.exit(1); }
process.stdout.write(totp(base32Decode(secret)) + "\n");