ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -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}`);
|
||||
Executable
+99
@@ -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"
|
||||
Executable
+126
@@ -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
|
||||
@@ -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);
|
||||
Executable
+48
@@ -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"
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# The operations console must never touch the data plane. This is the grep that says so.
|
||||
#
|
||||
# There is no linter and no unit-test runner in this repository, and deploy.sh runs none of the
|
||||
# guard scripts — so this, like scripts/check-identity.sh, is a habit rather than an enforcement.
|
||||
# The enforcement is the ops_ro database role. This script exists so a mistake is caught before it
|
||||
# reaches the role, and so the reasoning is written down where the next person will read it.
|
||||
#
|
||||
# Rules:
|
||||
# 1. Nothing under app/ops/ imports the main database client or names `prisma.` directly. Pages
|
||||
# and routes there call lib/ops/* functions, which decide which client is right.
|
||||
# 2. Nothing under app/ops/ or lib/ops/ imports buildSnapshot, exportBackup or useSnap — those
|
||||
# three ARE the data plane: one facility's whole register, history and stock in one call.
|
||||
# 3. lib/ops/projections.ts, the one module that reads across facilities, uses opsDb() and never
|
||||
# the main client.
|
||||
# 4. revealDb() — the role that can read a coordinator's contacts — is called from
|
||||
# lib/ops/reveal.ts and nowhere else, and that module reads only the three contact columns.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
bad=0
|
||||
|
||||
if grep -rn --include='*.ts' --include='*.tsx' -E "from \"@/lib/db\"|from \"\.\./db\"|from \"\.\./\.\./db\"|\bprisma\." app/ops/ 2>/dev/null; then
|
||||
echo "check-ops: app/ops/ must not reach the main database client directly (rule 1)"; bad=1
|
||||
fi
|
||||
|
||||
if grep -rn --include='*.ts' --include='*.tsx' -E "buildSnapshot|exportBackup|useSnap|SnapshotProvider" app/ops/ lib/ops/ 2>/dev/null; then
|
||||
echo "check-ops: the console must not import the data plane (rule 2)"; bad=1
|
||||
fi
|
||||
|
||||
if [ -f lib/ops/projections.ts ] && grep -n -E "from \"\.\./db\"|from \"@/lib/db\"|\bprisma\." lib/ops/projections.ts; then
|
||||
echo "check-ops: projections.ts must read through opsDb() only (rule 3)"; bad=1
|
||||
fi
|
||||
|
||||
if grep -rn --include='*.ts' --include='*.tsx' -E "\brevealDb\(" app/ lib/ 2>/dev/null | grep -v -E "^lib/ops/(db|reveal)\.ts:"; then
|
||||
echo "check-ops: revealDb() may only be called from lib/ops/reveal.ts (rule 4)"; bad=1
|
||||
fi
|
||||
if [ -f lib/ops/reveal.ts ] && grep -n -P "revealDb\(\)\.(?!facility\b)" lib/ops/reveal.ts 2>/dev/null; then
|
||||
echo "check-ops: reveal.ts may read the Facility table only (rule 4)"; bad=1
|
||||
fi
|
||||
|
||||
if [ "$bad" -eq 0 ]; then echo "check-ops: clean"; fi
|
||||
exit "$bad"
|
||||
@@ -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);
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Create an operator for the operations console.
|
||||
*
|
||||
* OPERATOR_PASSWORD='…' node scripts/create-operator.cjs kyle@threadcount.tech "Kyle" OWNER
|
||||
*
|
||||
* The password comes from the environment and never from an argument: argv is visible to every
|
||||
* process on the box through `ps`, and this is the break-glass credential for the whole platform.
|
||||
*
|
||||
* Seeds, never resets. It refuses if the address already has an operator, so re-running it cannot
|
||||
* quietly change a password. TOTP is enrolled at first sign-in; nothing here sets it. The address
|
||||
* must be one the Cloudflare Access policy on ops.threadcount.tech allows, or the row is an
|
||||
* operator who cannot reach the door.
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
|
||||
const [emailArg, nameArg, roleArg] = process.argv.slice(2);
|
||||
const email = String(emailArg || "").trim().toLowerCase();
|
||||
const name = String(nameArg || "").trim();
|
||||
const role = String(roleArg || "SUPPORT").trim().toUpperCase();
|
||||
const password = process.env.OPERATOR_PASSWORD || "";
|
||||
|
||||
function die(msg) { console.error(msg); process.exit(1); }
|
||||
|
||||
if (!email || !email.includes("@")) die("usage: OPERATOR_PASSWORD='…' node scripts/create-operator.cjs <email> <name> [OWNER|SUPPORT]");
|
||||
if (!name) die("a display name is required");
|
||||
if (role !== "OWNER" && role !== "SUPPORT") die("role must be OWNER or SUPPORT");
|
||||
// Eight, the product's own minimum for a coordinator account (components/AuthForm.tsx). Not a
|
||||
// stricter number invented here: the operator's real protection is the second factor enrolled at
|
||||
// first sign-in and Cloudflare Access in front of the door, not the length of the fire-escape key.
|
||||
if (password.length < 8) die("OPERATOR_PASSWORD must be set in the environment and be at least 8 characters");
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
|
||||
|
||||
(async () => {
|
||||
const existing = await prisma.operator.findUnique({ where: { email }, select: { id: true } });
|
||||
if (existing) die(`an operator already exists for ${email} — this script seeds, it does not reset`);
|
||||
// Cost 12, the same as a coordinator account at signup.
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const o = await prisma.operator.create({ data: { email, name, role, passwordHash }, select: { id: true, email: true, role: true } });
|
||||
console.log(`created operator ${o.email} (${o.role}) id=${o.id}`);
|
||||
console.log("TOTP is enrolled at first sign-in.");
|
||||
})()
|
||||
.catch((e) => die(e && e.message ? e.message : String(e)))
|
||||
.finally(() => prisma.$disconnect());
|
||||
Executable
+304
@@ -0,0 +1,304 @@
|
||||
#!/bin/bash
|
||||
# Deploy ThreadCount: update the code and nothing else.
|
||||
#
|
||||
# This exists because /root/deploy-setup.sh is a PROVISIONING script — it rewrites the nginx site
|
||||
# and re-points /opt/threadcount/.env at /etc/threadcount/secrets.env. Running it as a deploy on
|
||||
# 2026-09-06 destroyed the live .env and dropped nginx's TLS block. Use this instead.
|
||||
#
|
||||
# sudo /usr/local/sbin/threadcount-deploy.sh
|
||||
set -euo pipefail
|
||||
|
||||
APP=/opt/threadcount
|
||||
SEC=/etc/threadcount/secrets.env
|
||||
USER=threadcount
|
||||
# Hardlink copies of the build and the dependencies that were serving the linen room when this
|
||||
# deploy started. They live inside $APP because hardlinks cannot cross filesystems, and a deploy
|
||||
# that ends healthy deletes them. The leading dot is not decoration: tsconfig.json includes
|
||||
# **/*.ts and excludes only the top-level node_modules, and TypeScript skips dot-directories when
|
||||
# it expands a wildcard — a snapshot called anything else would drag a second copy of every .ts
|
||||
# file in node_modules into the build's type check.
|
||||
PREV=$APP/.deploy-prev
|
||||
# Written last, removed last. Its presence is what makes $PREV a way back: the hardlink copies
|
||||
# below take long enough to be interrupted halfway, and half a build put back over a deployment
|
||||
# that still works is the one outcome worse than no rollback at all.
|
||||
MARKER=$PREV/.complete
|
||||
DUMP=""
|
||||
|
||||
log() { echo "==> $*"; }
|
||||
|
||||
# The secrets file is the one thing nothing here may touch, and the one thing everything needs.
|
||||
[ -s "$SEC" ] || { echo "FATAL: $SEC is missing or empty — refusing to deploy."; exit 1; }
|
||||
grep -q '^DATABASE_URL=' "$SEC" || { echo "FATAL: no DATABASE_URL in $SEC — refusing to deploy."; exit 1; }
|
||||
|
||||
# Turnstile, checked here for the same reason SESSION_SECRET is checked below: instrumentation.ts
|
||||
# refuses to start production without both, so leaving them out turns a five-second grep into a
|
||||
# build, a migration and a restart that ends in a failed health check with the app already down.
|
||||
# NEXT_PUBLIC_TURNSTILE_SITEKEY in particular is compiled into the bundle, so it has to be present
|
||||
# before the build rather than before the restart.
|
||||
grep -q '^TURNSTILE_SECRET=' "$SEC" || { echo "FATAL: no TURNSTILE_SECRET in $SEC — the app refuses to start without it."; exit 1; }
|
||||
grep -q '^NEXT_PUBLIC_TURNSTILE_SITEKEY=' "$SEC" || { echo "FATAL: no NEXT_PUBLIC_TURNSTILE_SITEKEY in $SEC — it is compiled into the build."; exit 1; }
|
||||
|
||||
# A missing or placeholder SESSION_SECRET is the failure this script used to sail straight past.
|
||||
# `next build` needs no secrets, so the build succeeds; proxy.ts then treats every session cookie
|
||||
# as invalid and redirects the whole app to /auth, and every sign-in throws out of lib/session.ts
|
||||
# as an opaque 500. Nothing about that shows up in an HTTP status, so it is checked here instead.
|
||||
# The values are read in a subshell: nothing after this line should carry the database password
|
||||
# in its environment.
|
||||
( set -a; . "$SEC"; set +a
|
||||
[ -n "${SESSION_SECRET:-}" ] || { echo "FATAL: no SESSION_SECRET in $SEC — nobody could sign in."; exit 1; }
|
||||
[ "$SESSION_SECRET" != "change-me" ] || { echo "FATAL: SESSION_SECRET is still the .env.example placeholder."; exit 1; }
|
||||
[ "${#SESSION_SECRET}" -ge 32 ] || { echo "FATAL: SESSION_SECRET is under 32 characters — generate a real one."; exit 1; }
|
||||
[ -n "${DATABASE_URL:-}" ] || { echo "FATAL: DATABASE_URL in $SEC is empty."; exit 1; }
|
||||
# Present but empty is the same as absent to the application, and the greps above only prove the
|
||||
# line exists.
|
||||
[ -n "${TURNSTILE_SECRET:-}" ] || { echo "FATAL: TURNSTILE_SECRET in $SEC is empty."; exit 1; }
|
||||
[ -n "${NEXT_PUBLIC_TURNSTILE_SITEKEY:-}" ] || { echo "FATAL: NEXT_PUBLIC_TURNSTILE_SITEKEY in $SEC is empty."; exit 1; }
|
||||
# The documented escape hatch for a local `next start`. On the production box it would switch bot
|
||||
# protection off across every unauthenticated form, silently.
|
||||
[ -z "${TURNSTILE_OPTIONAL:-}" ] || { echo "FATAL: TURNSTILE_OPTIONAL is set in $SEC — that is for local smoke tests only."; exit 1; }
|
||||
) || exit 1
|
||||
|
||||
# The probe asks /api/health, which runs a SELECT 1 through Prisma and answers 503 when it cannot.
|
||||
# The marketing home page was useless for this: it is prerendered at build time and answers 200 out
|
||||
# of .next even when the server behind it is dead, so a deploy with an unreachable database still
|
||||
# reported healthy. /app was better — it goes through proxy.ts inside the running server — but its
|
||||
# redirect to /auth is produced without ever touching the database, so the one failure that takes
|
||||
# the whole product down is still the one it cannot see. Asking the database is the only probe that
|
||||
# distinguishes "serving" from "working". The rollback path below uses it too, so it is a function:
|
||||
# a rollback nobody probed is only a guess that the old build came back.
|
||||
code=""
|
||||
body=""
|
||||
healthy() {
|
||||
code=""
|
||||
for _ in $(seq 1 20); do
|
||||
sleep 1
|
||||
body=$(curl -s --max-time 5 -w '\n%{http_code}' http://127.0.0.1:3000/api/health || true)
|
||||
code=$(printf '%s' "$body" | tail -1)
|
||||
[ "$code" = "200" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# True only of a snapshot that finished being written. Anything else in $PREV is wreckage from a
|
||||
# deploy that died while copying, and nothing may be restored out of it.
|
||||
snapshot_is_complete() { [ -f "$MARKER" ]; }
|
||||
|
||||
# Called once the snapshot has given up everything it was holding. The marker goes first, so $PREV
|
||||
# stops advertising itself as a way back the moment it stops being one; the rmdir is deliberately
|
||||
# not an rm -rf, so anything unexpected in there survives for somebody to look at.
|
||||
clear_snapshot() {
|
||||
rm -f "$MARKER"
|
||||
rmdir "$PREV" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Put back the build and the node_modules that were serving before this deploy touched anything.
|
||||
# The checkout is deliberately left at the new commit: `next start` reads nothing but .next and
|
||||
# node_modules at runtime, so the restored build serves exactly what it served this morning
|
||||
# whatever the source files now say, and an operator can still look at the failing commit in place.
|
||||
# Answers false when there was no complete build to go back to, so nothing downstream claims a
|
||||
# rollback that did not happen.
|
||||
restore_previous_build() {
|
||||
# An unfinished snapshot is thrown away rather than served. Restoring a partly-linked .next would
|
||||
# replace a site that works with one that does not, and leave nothing to undo it with.
|
||||
if ! snapshot_is_complete; then
|
||||
rm -rf "$PREV"
|
||||
return 1
|
||||
fi
|
||||
# The dependencies go back whether or not there is a build to go with them: the alternative is
|
||||
# whatever half-installed tree an npm ci that died partway left behind.
|
||||
if [ -d "$PREV/node_modules" ]; then
|
||||
rm -rf "$APP/node_modules"
|
||||
mv "$PREV/node_modules" "$APP/node_modules"
|
||||
fi
|
||||
if [ ! -d "$PREV/.next" ]; then
|
||||
clear_snapshot
|
||||
return 1
|
||||
fi
|
||||
log "restoring the build that was running before this deploy"
|
||||
rm -rf "$APP/.next"
|
||||
mv "$PREV/.next" "$APP/.next"
|
||||
# Each mv above is a rename within one filesystem, so it either happened or it did not: a restore
|
||||
# cut short by a reboot leaves whole directories behind, still marked, and the next run carries on
|
||||
# from where this one stopped. That is why the marker is cleared here and not on the way in.
|
||||
clear_snapshot
|
||||
systemctl restart threadcount || true
|
||||
}
|
||||
|
||||
# Every failure from the moment npm ci starts deleting node_modules comes through here. Before this
|
||||
# existed, a build that failed on a clean .next (a type error that only appears without the cache,
|
||||
# an OOM on this small box) aborted the script on `set -e` with .next already deleted: no restart,
|
||||
# no rollback, and the rollback text at the bottom never printed, so the linen room stayed down
|
||||
# until somebody worked out unaided that they had to rewind the checkout and deploy again.
|
||||
fail() {
|
||||
# A Ctrl-C while this is putting the old build back would leave the linen room with neither.
|
||||
trap '' INT TERM
|
||||
echo "FATAL: $*"
|
||||
journalctl -u threadcount -n 30 --no-pager || true
|
||||
if ! restore_previous_build; then
|
||||
echo "There was no previous build to restore — the site stays down until a deploy succeeds."
|
||||
elif healthy; then
|
||||
echo "Rolled back: $BEFORE is serving again. The checkout is still at $AFTER."
|
||||
else
|
||||
echo "ROLLBACK DID NOT COME BACK HEALTHY (last status from /api/health was ${code:-none})."
|
||||
journalctl -u threadcount -n 30 --no-pager || true
|
||||
fi
|
||||
echo "To put the code back where the running build came from:"
|
||||
echo " cd $APP && sudo -u $USER git reset --hard $BEFORE && sudo $0"
|
||||
if [ -n "$DUMP" ]; then
|
||||
# The restored build carries the Prisma client generated against the OLD schema. If a migration
|
||||
# dropped a column before the build failed, that client throws on every query naming it, and
|
||||
# only the dump puts the column back.
|
||||
echo "If a migration landed first, restore the schema from the dump taken above:"
|
||||
echo " gzip -dc $DUMP | sudo -u $USER psql \"\$DATABASE_URL\""
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd "$APP"
|
||||
BEFORE=$(runuser -u "$USER" -- git rev-parse --short HEAD)
|
||||
log "at $BEFORE, pulling main"
|
||||
runuser -u "$USER" -- git pull -q --ff-only origin main
|
||||
AFTER=$(runuser -u "$USER" -- git rev-parse --short HEAD)
|
||||
log "now at $AFTER"
|
||||
|
||||
# Take the way back BEFORE npm ci removes node_modules. `cp -al` links rather than copies, so it
|
||||
# costs a couple of seconds and almost no disk, and the running server keeps reading the originals
|
||||
# untouched: npm ci and next build both delete and recreate files rather than writing through them,
|
||||
# so nothing below can reach back into the snapshot through a shared inode.
|
||||
#
|
||||
# A snapshot is used here instead of the tidier "build in a scratch directory and swap it in", which
|
||||
# would also close the degraded window described below, because .next/required-server-files.json
|
||||
# records the absolute path the build ran in — a build made anywhere but $APP is not safely
|
||||
# relocatable. Closing that window properly means making $APP a symlink to a release directory,
|
||||
# which is a change to the box, not to this script.
|
||||
#
|
||||
# `cp -a` also preserves ownership, which matters on the way back: next writes its cache inside
|
||||
# .next as the service user, and a restored tree owned by root would fail at the first write.
|
||||
log "snapshotting the running build so a failure can be undone"
|
||||
# A deploy killed mid-run — Ctrl-C, a dropped SSH session, the OOM killer, a reboot — leaves last
|
||||
# time's snapshot holding the only intact build. Put it back before wiping it, or this deploy
|
||||
# deletes the one copy that still works and a second failure has nothing to fall back to.
|
||||
#
|
||||
# Being marked complete is the test, not being there. Every way this script can end takes $PREV
|
||||
# with it: fail() moves the snapshot back out and clears it, and the healthy path rm -rf's it. So a
|
||||
# marked snapshot still sitting here at the start of a run can only mean the last run died without
|
||||
# unwinding — there is no "normal" state in which it is present. An unmarked one means the last run
|
||||
# died while it was still copying: that is not a build, and it must not be restored as one. (A
|
||||
# snapshot left by the version of this script that predates the marker looks exactly the same, so
|
||||
# the first deploy after this change discards it and simply rebuilds.)
|
||||
#
|
||||
# It used to also require that $APP/.next be missing, which made the branch very nearly
|
||||
# unreachable: `next build` recreates .next and starts writing trace and cache into it within
|
||||
# seconds, so a build killed partway — the exact case this recovery exists for — leaves a
|
||||
# half-written .next sitting there, the branch was skipped, and the rm -rf below then destroyed the
|
||||
# only good build on the box. Restoring when $APP/.next happens to be fine costs a service restart
|
||||
# and nothing else: the snapshot was hardlinked off that same build, so it is either byte-identical
|
||||
# to it or one deploy older and known to have served the linen room, and either way this run
|
||||
# rebuilds from the new checkout a couple of minutes later.
|
||||
if [ -d "$PREV" ]; then
|
||||
if snapshot_is_complete; then
|
||||
log "an earlier deploy was interrupted before it finished; putting its build back first"
|
||||
restore_previous_build || true
|
||||
else
|
||||
log "an earlier deploy died while taking its snapshot; discarding it rather than restoring half a build"
|
||||
rm -rf "$PREV"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Armed before the first write to $PREV, because the hardlink copies below are thousands of links
|
||||
# and take real time to make. Nothing in $APP has been touched yet, so there is nothing to roll back
|
||||
# and fail() would only announce a rollback that never happened: clearing away the half-made
|
||||
# snapshot and stopping is the whole job here. The marker written afterwards covers what no trap
|
||||
# can — a SIGKILL, the OOM killer, the power going out.
|
||||
trap 'trap "" INT TERM; rm -rf "$PREV"; echo "FATAL: interrupted while snapshotting — nothing on this box was changed."; exit 1' INT TERM
|
||||
|
||||
rm -rf "$PREV"
|
||||
mkdir -p "$PREV"
|
||||
# A box with nothing built yet (a rebuild, a fresh provision) has nothing to roll back to. That is
|
||||
# worth saying out loud, but it is not a reason to refuse: the site is already not serving.
|
||||
if [ -d "$APP/.next" ]; then
|
||||
cp -al "$APP/.next" "$PREV/.next" || { rm -rf "$PREV"; echo "FATAL: could not snapshot .next — refusing to deploy with no way back."; exit 1; }
|
||||
else
|
||||
log "no existing build to snapshot — this deploy has nothing to roll back to"
|
||||
fi
|
||||
if [ -d "$APP/node_modules" ]; then
|
||||
cp -al "$APP/node_modules" "$PREV/node_modules" || { rm -rf "$PREV"; echo "FATAL: could not snapshot node_modules — refusing to deploy with no way back."; exit 1; }
|
||||
fi
|
||||
# Both copies have returned, and only now is $PREV a build anyone may go back to. A marker that
|
||||
# cannot be written means the disk is full, which is a reason to stop before npm ci starts deleting
|
||||
# things rather than after.
|
||||
touch "$MARKER" || { rm -rf "$PREV"; echo "FATAL: could not mark the snapshot complete — refusing to deploy with no way back."; exit 1; }
|
||||
|
||||
# From here on an interrupt has to unwind rather than just stop, for the same reason a failed build
|
||||
# does: node_modules and .next are about to stop being a working install. There is a complete
|
||||
# snapshot behind us now, so fail() has something real to put back.
|
||||
trap 'fail "interrupted."' INT TERM
|
||||
|
||||
# From here until the restart the site is degraded, not down: npm ci removes the node_modules the
|
||||
# running server loads lazily from, and the build below removes the .next it serves page bundles and
|
||||
# /_next/static chunks out of. Expect 500s and unstyled pages for the two to four minutes that takes,
|
||||
# which is why deploys belong outside stocktake hours.
|
||||
log "installing dependencies"
|
||||
runuser -u "$USER" -- npm ci --no-audit --no-fund || fail "npm ci failed."
|
||||
|
||||
# A dump before anything can touch the schema. `prisma migrate deploy` runs below, the migration
|
||||
# history already contains column drops, and the build after it can still fail — so a deploy can
|
||||
# leave a changed schema behind a rolled-back binary. The rollback printed at the bottom only
|
||||
# rewinds the code, which is no use without this file. A dump that fails stops the deploy: there
|
||||
# is no version of "migrate anyway" that is worth the alternative.
|
||||
command -v pg_dump >/dev/null || fail "pg_dump is not installed — refusing to migrate with no way back."
|
||||
DUMPS=/var/backups/threadcount
|
||||
DUMP="$DUMPS/pre-deploy-$(date +%Y%m%d-%H%M%S)-$AFTER.sql.gz"
|
||||
install -d -m 700 "$DUMPS"
|
||||
log "dumping the database to $DUMP"
|
||||
if ! runuser -u "$USER" -- bash -c "set -a; . '$SEC'; set +a; exec pg_dump --no-owner --clean --if-exists \"\$DATABASE_URL\"" | gzip -9 > "$DUMP"; then
|
||||
rm -f "$DUMP"
|
||||
# Cleared so the rollback advice cannot point an operator at a dump that was never written.
|
||||
DUMP=""
|
||||
fail "pg_dump failed — refusing to migrate."
|
||||
fi
|
||||
# A gzip of nothing is still ~20 bytes, so the floor goes on the uncompressed size: anything under
|
||||
# a kilobyte is a dump that connected and then produced nothing worth restoring.
|
||||
if [ "$(gzip -dc "$DUMP" | wc -c)" -lt 1024 ]; then
|
||||
echo "The dump at $DUMP is empty."
|
||||
DUMP=""
|
||||
fail "the database dump is empty — refusing to migrate."
|
||||
fi
|
||||
log "dump is $(du -h "$DUMP" | cut -f1)"
|
||||
# Ten deploys' worth is plenty of history for a rollback, and this is a disk nobody watches.
|
||||
ls -1t "$DUMPS"/pre-deploy-*.sql.gz 2>/dev/null | tail -n +11 | xargs -r rm -f || true
|
||||
|
||||
# Migrate before build, so a schema the new code needs is there when the build type-checks it.
|
||||
# A destructive migration can therefore land while a build later fails: the rollback below puts the
|
||||
# old build back, but only the dump above undoes the migration. The three steps run separately so
|
||||
# the failure names the one an operator has to go and read.
|
||||
log "migrating, generating, building"
|
||||
runuser -u "$USER" -- bash -c "set -a; . '$SEC'; set +a; npx prisma migrate deploy" || fail "prisma migrate deploy failed."
|
||||
runuser -u "$USER" -- bash -c "set -a; . '$SEC'; set +a; npx prisma generate" || fail "prisma generate failed."
|
||||
# The cd is spelled out because this is the one line here that deletes a directory by relative path,
|
||||
# and a deploy run from anywhere but $APP must not be able to aim it somewhere else.
|
||||
# The deployed version, written down before the build so the build can carry it: .release.json is
|
||||
# what the operations console reads (lib/ops/version.ts), and NEXT_PUBLIC_RELEASE is compiled into
|
||||
# the bundle so error reports (lib/glitchtip.ts) name the release instead of leaving it blank.
|
||||
# Not in secrets.env — that file is the operator's, and this script never edits it.
|
||||
DEPLOYED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
printf '{"sha":"%s","from":"%s","at":"%s"}\n' "$AFTER" "$BEFORE" "$DEPLOYED_AT" | runuser -u "$USER" -- tee "$APP/.release.json" > /dev/null || fail "could not write .release.json"
|
||||
runuser -u "$USER" -- bash -c "set -a; . '$SEC'; set +a; export NEXT_PUBLIC_RELEASE='$AFTER'; cd '$APP' && rm -rf .next && npm run build" || fail "npm run build failed."
|
||||
|
||||
log "restarting"
|
||||
systemctl restart threadcount || fail "systemctl restart threadcount failed."
|
||||
|
||||
if healthy; then
|
||||
log "healthy on $AFTER (was $BEFORE)"
|
||||
# Only now does the old build stop being the way back. The dump stays either way: it is the only
|
||||
# way back for a schema change, and a deploy can look healthy and still turn out to be wrong.
|
||||
trap - INT TERM
|
||||
# Unmark before deleting, the same order clear_snapshot uses and for the same reason: rm -rf
|
||||
# walks the tree as it pleases, so an interrupt partway through this must not be able to leave
|
||||
# the marker standing over a half-deleted build for the next run to trust and restore.
|
||||
rm -f "$MARKER"
|
||||
rm -rf "$PREV"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail "not healthy after 20s (last status from /api/health was ${code:-none})."
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# Two-factor: enrolment, that the password alone stops working once it is on, recovery codes,
|
||||
# single use, and that nobody can turn it off without the password.
|
||||
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-2fa-cj.txt"; rm -f "$J"
|
||||
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; }
|
||||
post() { curl -s -b "$J" -c "$J" -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"; }
|
||||
|
||||
TS=$(date +%s)
|
||||
EMAIL="tfa$TS@example.com"
|
||||
|
||||
echo "== setup"
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.19.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Tess\",\"last\":\"Two\",\"facility\":\"TwoFactor Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "starts off" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":false'
|
||||
|
||||
echo "== enrolment"
|
||||
SETUP=$(post /api/2fa '{"action":"setup"}')
|
||||
check "setup returns a secret" "$SETUP" '"secret"'
|
||||
check "and a QR svg" "$SETUP" '<svg'
|
||||
SECRET=$(echo "$SETUP" | py "print(d['secret'])")
|
||||
check "still not enabled until proven" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":false'
|
||||
check "a wrong code is refused" "$(post /api/2fa '{"action":"enable","code":"000000"}')" "isn't right"
|
||||
|
||||
# Generated with the app's own TOTP code — the same code RFC 6238's vectors validate in
|
||||
# scripts/check-totp.ts, so this is exercising the real algorithm rather than a stub.
|
||||
CODE=$(npx tsx -e "import { base32Decode, totp } from './lib/totp'; console.log(totp(base32Decode('$SECRET')));" 2>/dev/null | tail -1)
|
||||
check "a code was generated for the test" "$CODE" '^[0-9]\{6\}$'
|
||||
|
||||
ENABLED=$(post /api/2fa "{\"action\":\"enable\",\"code\":\"$CODE\"}")
|
||||
check "the right code turns it on" "$ENABLED" '"ok":true'
|
||||
check "and hands back recovery codes" "$ENABLED" '"codes"'
|
||||
RCODE=$(echo "$ENABLED" | py "print(d['codes'][0])")
|
||||
# Distinct, and shaped like the codes the person is told to write down. A bare count is just as
|
||||
# happy with the same string handed back ten times, which is one recovery code, not ten.
|
||||
check "ten of them, all different" "$(echo "$ENABLED" | py "import re; print(len({c for c in d['codes'] if re.fullmatch(r'[0-9A-F]{5}-[0-9A-F]{5}', c)}))")" '^10$'
|
||||
STATE=$(curl -s -b "$J" "$B/api/2fa")
|
||||
check "now enabled" "$STATE" '"enabled":true'
|
||||
check "and ten of them are stored" "$STATE" '"recoveryLeft":10'
|
||||
|
||||
echo "== the password alone no longer signs in"
|
||||
# Its own jar, because what matters here is what the first step does NOT hand out. A route that
|
||||
# set the session cookie before returning need2fa — the password alone letting you in, which is the
|
||||
# whole thing this section is named for — would answer with exactly the same body, so the body is
|
||||
# no evidence. The cookie is.
|
||||
NJ="$T/tc-2fa-nosess.txt"; rm -f "$NJ"
|
||||
LOGIN=$(curl -s -c "$NJ" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$EMAIL\",\"password\":\"password123\"}")
|
||||
check "login asks for a second factor" "$LOGIN" '"need2fa":true'
|
||||
no "and issues no session cookie" "$(cat "$NJ" 2>/dev/null || true)" 'tc_session'
|
||||
check "and grants no session" "$(curl -s -b "$NJ" "$B/api/2fa")" 'Not signed in'
|
||||
TICKET=$(echo "$LOGIN" | py "print(d['ticket'])")
|
||||
# Payload, dot, and a 43-character MAC. Checked because the forgery below is built by mangling this
|
||||
# string, and a mangled empty string is refused for reasons of its own.
|
||||
check "a ticket was handed to the second step" "$TICKET" '^[A-Za-z0-9_-]\{8,\}\.[A-Za-z0-9_-]\{43\}$'
|
||||
|
||||
echo "== the second step"
|
||||
check "a wrong code is refused" "$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$TICKET\",\"code\":\"000000\"}")" "isn't right"
|
||||
# Not "bogus.ticket": that is malformed, and readTicket throws it out on a length mismatch before
|
||||
# any signature is compared, so it is refused just as readily by a server that checks no signature
|
||||
# at all. A forgery is well formed and wrong only in its MAC — letters rotated, length kept — so
|
||||
# the MAC comparison is the thing under test. Delete that comparison and this ticket is accepted,
|
||||
# the account is found, and the answer becomes "that code isn't right" instead.
|
||||
FORGED="${TICKET%.*}.$(printf %s "${TICKET##*.}" | tr 'A-Za-z' 'N-ZA-Mn-za-m')"
|
||||
check "a forged ticket is refused" "$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$FORGED\",\"code\":\"123456\"}")" 'expired'
|
||||
|
||||
echo "== a recovery code works, once"
|
||||
R1=$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$TICKET\",\"code\":\"$RCODE\"}")
|
||||
check "recovery code signs in" "$R1" '"ok":true'
|
||||
check "and is reported as used" "$R1" '"usedRecovery":true'
|
||||
check "nine left" "$R1" '"recoveryLeft":9'
|
||||
|
||||
LOGIN2=$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$EMAIL\",\"password\":\"password123\"}")
|
||||
TICKET2=$(echo "$LOGIN2" | py "print(d['ticket'])")
|
||||
check "the same recovery code is refused a second time" "$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$TICKET2\",\"code\":\"$RCODE\"}")" "isn't right"
|
||||
|
||||
echo "== turning it off needs the password"
|
||||
check "wrong password refused" "$(post /api/2fa '{"action":"disable","password":"nope"}')" "isn't right"
|
||||
check "still on" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":true'
|
||||
check "right password turns it off" "$(post /api/2fa '{"action":"disable","password":"password123"}')" '"ok":true'
|
||||
check "now off" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":false'
|
||||
check "password alone signs in again" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true'
|
||||
|
||||
echo "== signed out cannot manage it"
|
||||
check "unauthenticated refused" "$(curl -s "$B/api/2fa")" 'Not signed in'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+184
@@ -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 ]
|
||||
@@ -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 ]
|
||||
@@ -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 ]
|
||||
@@ -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 ]
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/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; the ops
|
||||
# hostname is the product; 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'
|
||||
check "the demo is off" "$(code "$B/api/auth/demo?as=admin")" '404'
|
||||
check "the ops hostname is the product" "$(code -H "Host: ops.threadcount.tech" "$B/")" '30[78]'
|
||||
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 ]
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Public contact form: validation, honeypot, rate limiting, storage, and the same-origin gate.
|
||||
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"
|
||||
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; }
|
||||
# Same-origin JSON gating means the Origin header has to look like a real browser post.
|
||||
post() { curl -s -X POST "$B/api/contact" -H 'content-type: application/json' -H "origin: $B" -H "x-forwarded-for: $2" -d "$1"; }
|
||||
TS=$(date +%s)
|
||||
# A fresh /24 per run. The limiter allows five messages an hour per address and remembers them for
|
||||
# that hour, so fixed addresses would start answering 429 on the fifth run of this suite inside an
|
||||
# hour — reported as a validation failure, which is a lie about what broke.
|
||||
NET=10.$((RANDOM%250)).$((RANDOM%250))
|
||||
|
||||
echo "== validation"
|
||||
check "name required" "$(post '{"email":"a@b.com","message":"Hello there, this is long enough."}' "$NET.1")" 'Add your name'
|
||||
check "email required" "$(post '{"name":"A","message":"Hello there, this is long enough."}' "$NET.2")" 'Add an email'
|
||||
check "bad email caught" "$(post '{"name":"A","email":"nope","message":"Hello there, this is long enough."}' "$NET.3")" 'Add an email'
|
||||
check "short message caught" "$(post '{"name":"A","email":"a@b.com","message":"hi"}' "$NET.4")" 'Say a little more'
|
||||
|
||||
echo "== honeypot"
|
||||
# The payload carries nothing but the honeypot field, and would be refused on the missing name if it
|
||||
# ever reached validation — that is the point. A well-formed message with `company` added on top
|
||||
# answers {"ok":true} whether the honeypot runs, has been deleted, or has been inverted into storing
|
||||
# the spam, so it can only ever prove that the route is up.
|
||||
check "honeypot short-circuits before validation" "$(post '{"company":"spam co"}' "$NET.5")" '"ok":true'
|
||||
|
||||
echo "== a real message"
|
||||
check "accepted" "$(post "{\"name\":\"Real Person\",\"email\":\"real$TS@example.com\",\"role\":\"Coordinator\",\"facility\":\"Test Hospital\",\"topic\":\"A question\",\"message\":\"Does this handle nursing entitlements the way we do them?\"}" "$NET.6")" '"ok":true'
|
||||
|
||||
echo "== cross-origin is refused"
|
||||
# CSRF is a browser-only attack: a browser always sends Sec-Fetch-Site, and Origin on a cross-origin
|
||||
# POST, and a cross-site page cannot suppress either. Those are the shapes worth refusing.
|
||||
check "cross-site fetch refused" "$(curl -s -X POST "$B/api/contact" -H 'content-type: application/json' -H 'sec-fetch-site: cross-site' -H "origin: $B" -d '{"name":"X","email":"a@b.com","message":"aaaaaaaaaaaa"}')" 'Cross-site request refused'
|
||||
check "form-encoded post refused" "$(curl -s -X POST "$B/api/contact" -H 'content-type: application/x-www-form-urlencoded' -H "origin: $B" -d 'name=X')" 'Expected JSON'
|
||||
# The words the origin comparison itself produces, rather than the bare string "error": every
|
||||
# refusal this route can make is shaped {"error":"…"}, so that matched a rate limit, a parse failure
|
||||
# or a Turnstile error just as happily as the check this line is named after.
|
||||
check "foreign origin refused" "$(curl -s -X POST "$B/api/contact" -H 'content-type: application/json' -H 'origin: https://evil.example' -H "x-forwarded-for: $NET.7" -d '{"name":"X","email":"a@b.com","message":"aaaaaaaaaaaa"}')" 'Cross-site request refused'
|
||||
|
||||
echo "== rate limit"
|
||||
IP=$NET.9
|
||||
# Both edges of the limit, because five is a quantity rather than a yes/no. Watching only for a
|
||||
# refusal stays green on a limiter tightened to one message an hour — and every nurse in a hospital
|
||||
# reaches this form from behind the same NAT address, so that shape turns a whole site away silently.
|
||||
FIFTH=""
|
||||
for i in 1 2 3 4 5; do FIFTH=$(post "{\"name\":\"Flood $i\",\"email\":\"f$i-$TS@x.com\",\"message\":\"Message number $i for the flood test.\"}" "$IP"); done
|
||||
check "fifth from one address still accepted" "$FIFTH" '"ok":true'
|
||||
check "sixth from one address blocked" "$(post "{\"name\":\"Flood 6\",\"email\":\"f6-$TS@x.com\",\"message\":\"Message number six for the flood test.\"}" "$IP")" 'few messages in a short time'
|
||||
|
||||
echo "== the form page carries the form"
|
||||
PAGE=$(curl -s "$B/contact")
|
||||
# Strings only the live form emits. The old alternation ended in a bare "Message", which matches the
|
||||
# word anywhere on the page, so every field could have been deleted and this still passed.
|
||||
check "contact page has the form" "$PAGE" 'What’s this about'
|
||||
check "contact page has the message box" "$PAGE" 'What are you trying to do, and what’s in the way?'
|
||||
# The form is a client component, so its fetch("/api/contact") is compiled into a JS chunk and never
|
||||
# reaches the page HTML — grepping the page for "contact" matched the URL, the nav and the mailto
|
||||
# address instead, and would have gone on passing with the form pointed at the wrong endpoint.
|
||||
# -g because chunk filenames in dev carry brackets, which curl would otherwise read as a glob.
|
||||
CHUNKS=$(echo "$PAGE" | grep -o 'static/chunks/[^"\\]*\.js' | sort -u)
|
||||
check "form posts to the api" "$(for c in $CHUNKS; do curl -sg "$B/_next/$c"; done)" '"/api/contact"'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
@@ -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 ]
|
||||
Executable
+92
@@ -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 ]
|
||||
@@ -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 ]
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env bash
|
||||
# The operations console: its fence, and its break-glass door.
|
||||
#
|
||||
# ops.threadcount.tech is served by the same Next process as the product, so the only thing
|
||||
# separating them is the host branch at the top of proxy.ts. The first half of this file is that
|
||||
# branch's test. The second half signs an operator in through the password door and proves the
|
||||
# three session kinds are not interchangeable.
|
||||
#
|
||||
# Two of the fence checks are regression guards rather than features, and they are the reason the
|
||||
# file exists: /my/signin and /m/login must keep answering on the product host. proxy.ts tests
|
||||
# `pathname.startsWith("/m")` and "/my" starts with "/m", so the staff app is safe only while no
|
||||
# matcher pattern matches /my. Widen the matcher to a catch-all and every wearer — and the
|
||||
# Play-shipped staff app — is redirected to the coordinator sign-in with no session they could
|
||||
# ever obtain. That failure is silent, total, and aimed at the surface with the most users.
|
||||
set -u
|
||||
B=${BASE:-http://127.0.0.1:3111}
|
||||
OPS=ops.threadcount.tech
|
||||
T=${TMP:-/tmp}
|
||||
OJ="$T/tc-ops-op.txt" # the operator's cookie jar
|
||||
CJ="$T/tc-ops-coord.txt" # a coordinator's, for the doctrine test
|
||||
FJ="$T/tc-ops-fake.txt" # the coordinator's cookie wearing the operator's name
|
||||
rm -f "$OJ" "$CJ" "$FJ"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
|
||||
where() { curl -s -o /dev/null -w '%{redirect_url}' "$@"; }
|
||||
is() { local name=$1 want=$2 got=$3; if [ "$got" = "$want" ]; then ok "$name"; else fail "$name" "wanted $want, got $got"; fi; }
|
||||
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; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
# Requests to the console carry the Host the proxy branches on, and an Origin that agrees with it
|
||||
# so lib/csrf.ts is exercised rather than sidestepped.
|
||||
# A fresh address per run: the sign-in route counts failures per address for fifteen minutes, in
|
||||
# the server's memory, and this file deliberately fails several sign-ins. Without this, a second
|
||||
# run inside the window trips the limiter — which is the limiter working, not the door failing.
|
||||
# The other suites do the same for signup; clientIp() takes the last forwarded entry.
|
||||
XFF="10.99.$((RANDOM%250)).$((RANDOM%250))"
|
||||
opost() { curl -s -b "$OJ" -c "$OJ" -X POST "$B$1" -H "Host: $OPS" -H "origin: http://$OPS" -H "x-forwarded-for: $XFF" -H 'content-type: application/json' -d "$2"; }
|
||||
|
||||
echo "== the ops hostname serves nothing but its own door"
|
||||
is "the root goes to the console" 307 "$(code -H "Host: $OPS" "$B/")"
|
||||
is " and every product api is refused" 404 "$(code -H "Host: $OPS" "$B/api/health")"
|
||||
# Unconditional today, and on this hostname it would declare both Android apps authorised for
|
||||
# credential sharing — so a tapped ops link could open in the staff app.
|
||||
is " and .well-known/assetlinks.json" 404 "$(code -H "Host: $OPS" "$B/.well-known/assetlinks.json")"
|
||||
# This one mints a coordinator session and redirects to /app.
|
||||
is " and the demo door" 404 "$(code -H "Host: $OPS" "$B/api/auth/demo?as=admin")"
|
||||
is " and robots.txt" 404 "$(code -H "Host: $OPS" "$B/robots.txt")"
|
||||
|
||||
echo "== the console's paths do not exist on the product hostname"
|
||||
is "/ops is refused" 404 "$(code "$B/ops")"
|
||||
is " and anything under it" 404 "$(code "$B/ops/facilities")"
|
||||
is " and the sign-in page" 404 "$(code "$B/ops/login")"
|
||||
is " and the sign-in route" 404 "$(code -X POST "$B/api/ops/auth/login" -H 'content-type: application/json' -d '{}')"
|
||||
|
||||
echo "== the product is untouched"
|
||||
# Everything below passed before the host branch existed and must still pass. A 200 on the home
|
||||
# page also proves adding "/" to the matcher did not turn the marketing site into a redirect.
|
||||
is "the home page still answers" 200 "$(code "$B/")"
|
||||
is "health still answers" 200 "$(code "$B/api/health")"
|
||||
is "assetlinks still answers" 200 "$(code "$B/.well-known/assetlinks.json")"
|
||||
is "robots still answers" 200 "$(code "$B/robots.txt")"
|
||||
is "the coordinator sign-in answers" 200 "$(code "$B/m/login")"
|
||||
|
||||
echo "== the staff app is untouched — the regression this file exists for"
|
||||
is "the wearer sign-in still answers" 200 "$(code "$B/my/signin")"
|
||||
MY=$(where "$B/my/signin")
|
||||
if [ -z "$MY" ]; then ok " and does not redirect anywhere"; else fail " and does not redirect anywhere" "redirects to $MY"; fi
|
||||
|
||||
echo "== an unknown host is treated as the product, not as ops"
|
||||
# The safe default: the console is opt-in by exact match. A stray Host header must not open it,
|
||||
# and must not close the product either.
|
||||
is "an unknown host still gets the product" 200 "$(code -H "Host: example.invalid" "$B/")"
|
||||
is " and a near-miss host does too" 200 "$(code -H "Host: opsXthreadcountYtech" "$B/")"
|
||||
# The near-miss above is the exact string Next's `has: [{type:'host'}]` matcher would have
|
||||
# admitted, because it compiles its value as an unescaped regular expression.
|
||||
|
||||
echo "== the break-glass door"
|
||||
TS=$(date +%s)
|
||||
OE="ops-$TS@example.com"
|
||||
# The seed script takes the password from the environment, never argv.
|
||||
check "an operator is seeded" "$(OPERATOR_PASSWORD='e2e-operator-password-1' node scripts/create-operator.cjs "$OE" "Ops Test" OWNER 2>&1)" "created operator $OE"
|
||||
is "the console redirects a stranger to sign in" 307 "$(code -H "Host: $OPS" "$B/ops")"
|
||||
check " to the sign-in page" "$(where -H "Host: $OPS" "$B/ops")" '/ops/login'
|
||||
is "the sign-in page answers" 200 "$(code -H "Host: $OPS" "$B/ops/login")"
|
||||
check "a wrong password is refused" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"wrong\"}")" 'match'
|
||||
is " with no cookie set" 307 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops")"
|
||||
check "the right password signs in" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\"}")" '"ok":true'
|
||||
check " and the jar holds tc_ops" "$(cat "$OJ")" 'tc_ops'
|
||||
R=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops")
|
||||
check " and the console opens" "$R" 'Signed in'
|
||||
check " naming the operator" "$R" 'Ops Test'
|
||||
check " who has no second factor yet" "$R" 'not yet enrolled'
|
||||
is "signing out answers with a redirect" 303 "$(code -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" "$B/api/ops/auth/logout")"
|
||||
is " after which the console is closed" 307 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops")"
|
||||
|
||||
echo "== a coordinator's cookie is not an operator's"
|
||||
# The doctrine in lib/ops/session.ts: three cookies, three keys, three claim names. A coordinator
|
||||
# session presented under the operator cookie's name must fail the signature — and if it somehow
|
||||
# didn't, it has no `oid`. This test takes a real tc_session value and renames it.
|
||||
check "a coordinator signs up" "$(curl -s -c "$CJ" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.12.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Ops\",\"last\":\"Doctrine\",\"facility\":\"Doctrine Hospital $TS\",\"email\":\"doctrine$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check " and holds tc_session" "$(cat "$CJ")" 'tc_session'
|
||||
sed 's/\ttc_session\t/\ttc_ops\t/' "$CJ" > "$FJ"
|
||||
check " which is renamed to tc_ops" "$(cat "$FJ")" 'tc_ops'
|
||||
is "the console refuses it" 307 "$(code -b "$FJ" -H "Host: $OPS" "$B/ops")"
|
||||
check " and sends it to sign in" "$(where -b "$FJ" -H "Host: $OPS" "$B/ops")" '/ops/login'
|
||||
# And the reverse: the operator's cookie opens nothing on the product.
|
||||
check "the operator signs in again" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\"}")" '"ok":true'
|
||||
is " and their cookie does not open the counter" 307 "$(code -b "$OJ" "$B/app")"
|
||||
|
||||
echo "== the console shows counts, never a person"
|
||||
# The coordinator signed up above has a name and an email on their facility. The facility must
|
||||
# appear on the console by name; the person must not appear anywhere on it. This tests the
|
||||
# projection at the rendering layer — it holds even where the database cannot enforce roles.
|
||||
CN="Doctrine Hospital $TS"
|
||||
# Signup sets the coordinator's NAME on the facility but not an email, so a "no email on the
|
||||
# console" check would pass with nothing to catch. Give the facility a real one first — the
|
||||
# console must then leave it out on purpose, not by accident of the data.
|
||||
check "the coordinator records their email on the facility" "$(curl -s -b "$CJ" -c "$CJ" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"settings.update\",\"payload\":{\"coordinatorEmail\":\"doctrine$TS@example.com\"}}")" '"ok":true'
|
||||
FL=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities")
|
||||
check "the facilities page lists the new facility" "$FL" "$CN"
|
||||
no " without the coordinator's email" "$FL" "doctrine$TS@example.com"
|
||||
no " or the coordinator's name" "$FL" 'Ops Doctrine'
|
||||
check " and calls it never set up" "$FL" 'Never set up'
|
||||
FID=$(echo "$FL" | grep -o "/ops/facilities/[a-z0-9]*\"[^>]*>$CN" | head -1 | sed 's#/ops/facilities/\([a-z0-9]*\).*#\1#')
|
||||
check " with a link to its page" "$FID" '^[a-z0-9]\{20,\}$'
|
||||
FD=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FID")
|
||||
check "the facility page opens" "$FD" "$CN"
|
||||
check " with the contacts masked" "$FD" 'cannot read these'
|
||||
no " and no email on it" "$FD" "doctrine$TS@example.com"
|
||||
no " and no name on it" "$FD" 'Ops Doctrine'
|
||||
check " and the coordinator counted" "$FD" '1 admin'
|
||||
is "a made-up facility id is not found" 404 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/nope")"
|
||||
OV=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops")
|
||||
check "the overview lists it as needing attention" "$OV" "$CN"
|
||||
check " because it has no staff groups" "$OV" 'no staff groups named'
|
||||
check " and shows the migration count" "$OV" 'applied /'
|
||||
no " and no drift" "$OV" 'DRIFT'
|
||||
|
||||
echo "== the second factor"
|
||||
# The operator is signed in from the section above. Enrolment goes through the real routes with a
|
||||
# live code computed by the product's own TOTP implementation (scripts/totp-code.ts).
|
||||
SETUP=$(opost /api/ops/auth/totp '{"action":"setup"}')
|
||||
check "setup returns a QR" "$SETUP" '<svg'
|
||||
SECRET=$(echo "$SETUP" | py "print(d['secret'])")
|
||||
check " and a secret" "$SECRET" '^[A-Z2-7]\{16,\}$'
|
||||
check " but the password is still enough for now" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/api/ops/auth/totp")" '"enabled":false'
|
||||
CODE=$(TOTP_SECRET="$SECRET" npx tsx scripts/totp-code.ts)
|
||||
check "a wrong code does not enable it" "$(opost /api/ops/auth/totp '{"action":"enable","code":"000000"}')" 'That code isn'
|
||||
ENABLE=$(opost /api/ops/auth/totp "{\"action\":\"enable\",\"code\":\"$CODE\"}")
|
||||
check "a live code enables it" "$ENABLE" '"codes"'
|
||||
RC=$(echo "$ENABLE" | py "print(d['codes'][0])")
|
||||
check " handing back recovery codes" "$RC" '^[A-F0-9]\{5\}-[A-F0-9]\{5\}$'
|
||||
check " and it now reports on" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/api/ops/auth/totp")" '"enabled":true'
|
||||
no " and the landing page stops nagging" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops")" 'not yet enrolled'
|
||||
is "signing out" 303 "$(code -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" "$B/api/ops/auth/logout")"
|
||||
check "the password alone is no longer enough" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\"}")" 'needCode'
|
||||
is " and sets no cookie" 307 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops")"
|
||||
check "a wrong code is refused" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\",\"code\":\"000000\"}")" 'That code isn'
|
||||
CODE=$(TOTP_SECRET="$SECRET" npx tsx scripts/totp-code.ts)
|
||||
check "password and a live code sign in" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\",\"code\":\"$CODE\"}")" '"ok":true'
|
||||
is " opening the console" 200 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops")"
|
||||
is "signing out again" 303 "$(code -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" "$B/api/ops/auth/logout")"
|
||||
check "password and a recovery code sign in" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\",\"code\":\"$RC\"}")" '"ok":true'
|
||||
is "signing out once more" 303 "$(code -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" "$B/api/ops/auth/logout")"
|
||||
# Spent in the same conditional update that found it: the second use must fail.
|
||||
check "the same recovery code is refused the second time" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\",\"code\":\"$RC\"}")" 'already been used'
|
||||
is " and the console stays closed" 307 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops")"
|
||||
|
||||
echo "== revealing a coordinator's contacts"
|
||||
# Sign back in with a live code, then walk the reveal: refused without a reason, granted with one,
|
||||
# the contacts on the page, one grant row and one trail row behind it, and masked again the moment
|
||||
# the grant expires — expiry is forced in the database, because the window is the row, not a
|
||||
# cookie. The database queries below use the app role on purpose: they are the test's view of the
|
||||
# console's own tables, not the console's.
|
||||
CODE=$(TOTP_SECRET="$SECRET" npx tsx scripts/totp-code.ts)
|
||||
check "the operator signs in for the reveal" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\",\"code\":\"$CODE\"}")" '"ok":true'
|
||||
sql() { node -e 'require("dotenv/config");const{Client}=require("pg");const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(()=>c.query(process.argv[1],process.argv.slice(2))).then(r=>{console.log(r.rows.map(x=>Object.values(x).join(" ")).join("\n"));return c.end()}).catch(e=>{console.error(e.message);process.exit(1)})' "$@"; }
|
||||
check "no reason is refused" "$(opost /api/ops/reveal "{\"facilityId\":\"$FID\"}")" 'Give a reason'
|
||||
check " and a short one too" "$(opost /api/ops/reveal "{\"facilityId\":\"$FID\",\"reason\":\"why\"}")" 'Give a reason'
|
||||
check " and a made-up facility" "$(opost /api/ops/reveal '{"facilityId":"abcdefghijklmnopqrstuvwxy","reason":"testing a facility that is not there"}')" 'No such facility'
|
||||
no " and nothing was granted" "$(sql 'SELECT count(*) FROM "RevealGrant" WHERE "facilityId"=$1' "$FID")" '[1-9]'
|
||||
FD=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FID")
|
||||
check "the page still masks the contacts" "$FD" 'cannot read these'
|
||||
check " and offers the reveal" "$FD" 'Reveal contacts'
|
||||
RV=$(opost /api/ops/reveal "{\"facilityId\":\"$FID\",\"reason\":\"e2e: checking the reveal path end to end\"}")
|
||||
check "a typed reason opens the window" "$RV" '"ok":true'
|
||||
check " for thirty minutes" "$RV" '"minutes":30'
|
||||
is "one grant row was written" 1 "$(sql 'SELECT count(*)::int FROM "RevealGrant" WHERE "facilityId"=$1' "$FID")"
|
||||
check " carrying the reason" "$(sql 'SELECT reason FROM "RevealGrant" WHERE "facilityId"=$1' "$FID")" 'checking the reveal path'
|
||||
is "one trail row was written" 1 "$(sql 'SELECT count(*)::int FROM "OperatorEvent" WHERE "facilityId"=$1 AND action=$2' "$FID" 'ops:reveal')"
|
||||
check " naming the facility, not the person" "$(sql 'SELECT subject FROM "OperatorEvent" WHERE "facilityId"=$1 AND action=$2' "$FID" 'ops:reveal')" "$CN"
|
||||
FD=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FID")
|
||||
check "the page now shows the email" "$FD" "doctrine$TS@example.com"
|
||||
check " and the coordinator's name" "$FD" 'Ops Doctrine'
|
||||
check " and says until when" "$FD" 'revealed until'
|
||||
check " and why" "$FD" 'checking the reveal path'
|
||||
no " and no longer offers the button" "$FD" 'Reveal contacts'
|
||||
no "the facilities list still masks it" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities")" "doctrine$TS@example.com"
|
||||
# Force the expiry: the window is a server-side row, so ending it is one update. A literal past
|
||||
# timestamp rather than now() - interval: the local PGlite server's now() is not on UTC.
|
||||
check "the window is ended in the database" "$(sql 'UPDATE "RevealGrant" SET "expiresAt" = $2 WHERE "facilityId"=$1 RETURNING 1' "$FID" '2000-01-01T00:00:00Z')" '1'
|
||||
FD=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FID")
|
||||
check "after expiry the contacts are masked again" "$FD" 'cannot read these'
|
||||
no " with no email on the page" "$FD" "doctrine$TS@example.com"
|
||||
no " and no name" "$FD" 'Ops Doctrine'
|
||||
check " and the reveal is offered again" "$FD" 'Reveal contacts'
|
||||
is "the trail row is still there" 1 "$(sql 'SELECT count(*)::int FROM "OperatorEvent" WHERE "facilityId"=$1 AND action=$2' "$FID" 'ops:reveal')"
|
||||
is "signing out after the reveal" 303 "$(code -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" "$B/api/ops/auth/logout")"
|
||||
|
||||
echo "== the switches"
|
||||
# The sign-up and demo doors used to be environment variables; now they are one row the console
|
||||
# flips, and the product's own routes read that row. Close, prove the product refuses, reopen.
|
||||
CODE=$(TOTP_SECRET="$SECRET" npx tsx scripts/totp-code.ts)
|
||||
check "the operator signs in for the controls" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\",\"code\":\"$CODE\"}")" '"ok":true'
|
||||
CP=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/controls")
|
||||
check "the controls page opens" "$CP" 'Switches'
|
||||
check " with sign-ups open" "$CP" 'Close sign-ups'
|
||||
check "closing sign-ups" "$(opost /api/ops/controls '{"action":"switch","key":"signupsDisabled","value":true}')" '"ok":true'
|
||||
check " and the sign-up route refuses" "$(curl -s -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.13.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"No\",\"last\":\"Body\",\"facility\":\"Shut $TS\",\"email\":\"shut$TS@example.com\",\"password\":\"password123\"}")" 'sign-ups are closed'
|
||||
no " and the sign-in page hides the form" "$(curl -s "$B/auth")" 'Create your facility'
|
||||
check " and the trail says who" "$(sql 'SELECT detail FROM "OperatorEvent" WHERE action=$1 AND subject=$2 ORDER BY at DESC LIMIT 1' 'ops:switch' 'signupsDisabled')" 'closed'
|
||||
check "reopening sign-ups" "$(opost /api/ops/controls '{"action":"switch","key":"signupsDisabled","value":false}')" '"ok":true'
|
||||
DJ="$T/tc-ops-del.txt"; rm -f "$DJ"
|
||||
DN="Deletable Hospital $TS"
|
||||
check " and a facility can sign up again" "$(curl -s -c "$DJ" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.14.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Del\",\"last\":\"Etable\",\"facility\":\"$DN\",\"email\":\"deletable$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "taking the demo out of service" "$(opost /api/ops/controls '{"action":"switch","key":"demoDisabled","value":true}')" '"ok":true'
|
||||
is " and the demo entry answers 404" 404 "$(code "$B/api/auth/demo?as=admin")"
|
||||
check " and the demo page says so" "$(curl -s "$B/demo")" 'closed for the moment'
|
||||
check "putting the demo back" "$(opost /api/ops/controls '{"action":"switch","key":"demoDisabled","value":false}')" '"ok":true'
|
||||
is " and the demo entry opens again" 303 "$(code "$B/api/auth/demo?as=admin")"
|
||||
check "resetting the demo on demand" "$(opost /api/ops/controls '{"action":"demo.reset"}')" '"ok":true'
|
||||
check " is in the trail" "$(sql 'SELECT count(*)::int FROM "OperatorEvent" WHERE action=$1' 'ops:demo.reset')" '[1-9]'
|
||||
check "an unknown switch is refused" "$(opost /api/ops/controls '{"action":"switch","key":"everything","value":true}')" 'Unknown switch'
|
||||
|
||||
echo "== the billing scaffold"
|
||||
DFID=$(sql 'SELECT id FROM "Facility" WHERE name=$1' "$DN")
|
||||
check "the new facility has an id" "$DFID" '^[a-z0-9]\{20,\}$'
|
||||
check "a plan is recorded" "$(opost /api/ops/controls "{\"action\":\"plan\",\"facilityId\":\"$DFID\",\"act\":\"set\",\"plan\":\"health_service\",\"planNote\":\"e2e pilot until the end of the quarter\"}")" '"ok":true'
|
||||
DP=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$DFID")
|
||||
check " and shown on the facility page" "$DP" 'Health Service'
|
||||
check " with its note" "$DP" 'end of the quarter'
|
||||
check " and on the list" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities")" 'grandfathered'
|
||||
# The coordinator's two views of their own facility: the app shell (the snapshot is embedded in
|
||||
# its HTML) and the backup export. The console's note about them is the console's, not theirs —
|
||||
# the snapshot carries the plan's name and state (scripts/e2e-plan.sh), never the note.
|
||||
is "the coordinator's app opens" 200 "$(code -b "$DJ" "$B/app")"
|
||||
no " without the console's note in it" "$(curl -s -b "$DJ" "$B/app")" 'end of the quarter'
|
||||
no " nor in the backup export" "$(curl -s -b "$DJ" "$B/api/backup")" 'planNote'
|
||||
|
||||
echo "== the danger zone"
|
||||
# Owner, the exact name, a live code. Each guard refuses on its own before anything is deleted.
|
||||
CODE=$(TOTP_SECRET="$SECRET" npx tsx scripts/totp-code.ts)
|
||||
check "the wrong name is refused" "$(opost /api/ops/controls "{\"action\":\"facility.delete\",\"facilityId\":\"$DFID\",\"confirm\":\"Deletable Hospital\",\"code\":\"$CODE\"}")" 'Type the facility name exactly'
|
||||
check "a wrong code is refused" "$(opost /api/ops/controls "{\"action\":\"facility.delete\",\"facilityId\":\"$DFID\",\"confirm\":\"$DN\",\"code\":\"000000\"}")" 'That code isn'
|
||||
is " and the facility is still there" 200 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$DFID")"
|
||||
check "the exact name and a live code delete it" "$(opost /api/ops/controls "{\"action\":\"facility.delete\",\"facilityId\":\"$DFID\",\"confirm\":\"$DN\",\"code\":\"$CODE\"}")" '"deleted":"'"$DN"'"'
|
||||
is " and its page is gone" 404 "$(code -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$DFID")"
|
||||
no " and the list no longer names it" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities")" "$DN"
|
||||
is " and its coordinator's session is dead" 307 "$(code -b "$DJ" "$B/app")"
|
||||
is " and the trail keeps the record" 1 "$(sql 'SELECT count(*)::int FROM "OperatorEvent" WHERE "facilityId"=$1 AND action=$2' "$DFID" 'ops:facility.delete')"
|
||||
check " naming what it had" "$(sql 'SELECT detail FROM "OperatorEvent" WHERE "facilityId"=$1 AND action=$2' "$DFID" 'ops:facility.delete')" '1 coordinators'
|
||||
is "signing out after the controls" 303 "$(code -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" "$B/api/ops/auth/logout")"
|
||||
|
||||
echo "== the version panel"
|
||||
# scripts/deploy.sh writes .release.json before the build; the overview reads it per request. Here
|
||||
# there is none, then one is written for a moment, then removed — the file is the box's, not the
|
||||
# repo's (gitignored).
|
||||
CODE=$(TOTP_SECRET="$SECRET" npx tsx scripts/totp-code.ts)
|
||||
check "the operator signs in for the version panel" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\",\"code\":\"$CODE\"}")" '"ok":true'
|
||||
check "with no release file the overview says so" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops")" 'not recorded'
|
||||
printf '{"sha":"e2e0abc","from":"e2e0aaa","at":"2026-09-12T01:02:03Z"}\n' > .release.json
|
||||
OVV=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops")
|
||||
rm -f .release.json
|
||||
check "with one it shows the deployed sha" "$OVV" 'e2e0abc'
|
||||
check " and what it replaced" "$OVV" 'was e2e0aaa'
|
||||
check " and what is running" "$OVV" 'release unknown\|STALE'
|
||||
is "the release file is gone again" 0 "$(ls .release.json 2>/dev/null | wc -l)"
|
||||
is "signing out after the version panel" 303 "$(code -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" "$B/api/ops/auth/logout")"
|
||||
|
||||
echo "== single sign-on fails closed"
|
||||
# Locally the two Access variables are unset, so there is no SSO: the route must refuse every
|
||||
# assertion — including a well-formed forged one — and the sign-in page must neither hand off nor
|
||||
# loop. With the variables set (production) the same route verifies a real assertion against
|
||||
# Cloudflare's keys; that half is exercised on the box, not here.
|
||||
FORGED="eyJhbGciOiJSUzI1NiIsImtpZCI6Im5vcGUifQ.eyJlbWFpbCI6Im9wc0BleGFtcGxlLmNvbSIsImF1ZCI6WyJ4Il0sImlzcyI6Imh0dHBzOi8veCIsImV4cCI6NDEwMjQ0NDgwMH0.c2ln"
|
||||
is "the sso route with no assertion sends to the fire escape" 303 "$(code -H "Host: $OPS" "$B/api/ops/auth/sso")"
|
||||
check " at /ops/login?sso=failed" "$(where -H "Host: $OPS" "$B/api/ops/auth/sso")" 'sso=failed'
|
||||
check "a forged assertion is refused" "$(where -H "Host: $OPS" -H "cf-access-jwt-assertion: $FORGED" "$B/api/ops/auth/sso")" 'sso=failed'
|
||||
is " and sets no cookie" 307 "$(code -H "Host: $OPS" -H "cf-access-jwt-assertion: $FORGED" "$B/ops")"
|
||||
is "the sign-in page does not hand off without SSO configured" 200 "$(code -H "Host: $OPS" -H "cf-access-jwt-assertion: $FORGED" "$B/ops/login")"
|
||||
LP=$(curl -s -H "Host: $OPS" "$B/ops/login?sso=failed")
|
||||
check "after a failure the page shows the form" "$LP" 'ops-pw'
|
||||
check " and says why" "$LP" 'could not complete'
|
||||
check " and does not loop" "$(where -H "Host: $OPS" -H "cf-access-jwt-assertion: $FORGED" "$B/ops/login?sso=failed")" '^$'
|
||||
# The landing-path rule is tested directly: locally SSO never succeeds, so a request with a bad
|
||||
# next= would be refused for the assertion before the path was ever looked at.
|
||||
NX=$(npx tsx -e 'import { safeOpsNext as s } from "./lib/ops/cfAccess"; console.log([s("https://example.com"), s("//example.com/ops"), s("/ops/login?x=1"), s("/opsx"), s(""), s(null), s("/ops/facilities/abc")].join(" "))')
|
||||
is "after sign-on, only the console's own paths are landing places" "/ops /ops /ops /ops /ops /ops /ops/facilities/abc" "$NX"
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
@@ -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 ]
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# Plans, phase 1: the promise, the ceiling, read-only, and the console's billing desk.
|
||||
#
|
||||
# What it proves, in order:
|
||||
# - with plans OFF (the default) a new facility is grandfathered: no staff ceiling at all;
|
||||
# - an owner flips plans ON from the console, and a facility created after that is Hosted Small:
|
||||
# the 61st staff record is refused, by the form and by the import, with the same words;
|
||||
# - the console can set a room read-only: every write is refused with the one refusal, the
|
||||
# backup and the plan ops still work, and "make writable" undoes it;
|
||||
# - a trial and a payment record the dates, and "set free" clears them;
|
||||
# - plans are turned OFF again at the end, so the next suite still gets grandfathered rooms.
|
||||
#
|
||||
# Needs a server in dev mode (Turnstile advisory) and DATABASE_URL in the environment for the
|
||||
# operator seed, like e2e-ops.sh. Nothing here sends mail: the invoice request is a notice to the
|
||||
# owner that swallows a missing SMTP.
|
||||
set -u
|
||||
B=${BASE:-http://127.0.0.1:3111}
|
||||
. "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B"
|
||||
T=${TMP:-/tmp}
|
||||
OPS=ops.threadcount.tech
|
||||
OJ="$T/tc-plan-op.txt"; J1="$T/tc-plan-a.txt"; J2="$T/tc-plan-b.txt"
|
||||
rm -f "$OJ" "$J1" "$J2"
|
||||
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}' "$@"; }
|
||||
mut() { local jar=$1 op=$2 payload=$3; curl -s -b "$jar" -c "$jar" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$op\",\"payload\":$payload}"; }
|
||||
opost(){ curl -s -b "$OJ" -c "$OJ" -X POST -H "Host: $OPS" -H 'content-type: application/json' "$B$1" -d "$2"; }
|
||||
control(){ opost /api/ops/controls "$1"; }
|
||||
jget() { python3 -c "import sys,json; d=json.load(sys.stdin); print(eval('d$1'))"; }
|
||||
signup(){ local jar=$1 name=$2 email=$3; curl -s -c "$jar" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.7.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Plan\",\"last\":\"Tester\",\"facility\":\"$name\",\"email\":\"$email\",\"password\":\"password123\"}"; }
|
||||
# N staff rows as an import payload: numbers 1..N.
|
||||
rows() { python3 -c "import json,sys; n=int(sys.argv[1]); print(json.dumps({'kind':'staff','rows':[{'num':str(9000+i),'first':'P%d'%i,'last':'Row','group':'Kitchen','dept':'Kitchen'} for i in range(1,n+1)]}))" "$1"; }
|
||||
TS=$(date +%s)
|
||||
|
||||
echo "== an owner at the console"
|
||||
OE="plan-ops-$TS@example.com"
|
||||
check "an operator is seeded" "$(OPERATOR_PASSWORD='e2e-operator-password-1' node scripts/create-operator.cjs "$OE" "Plan Ops" OWNER 2>&1)" "created operator $OE"
|
||||
check "and signs in" "$(opost /api/ops/auth/login "{\"email\":\"$OE\",\"password\":\"e2e-operator-password-1\"}")" '"ok":true'
|
||||
# Plans must start OFF for the first half; say so out loud if a previous run left them on.
|
||||
check "plans are off to begin with" "$(control '{"action":"switch","key":"plansLive","value":false}')" '"ok":true'
|
||||
|
||||
echo "== plans off: the website still says free"
|
||||
R=$(curl -s "$B/pricing")
|
||||
check "pricing says that is the whole page" "$R" 'That is the whole page'
|
||||
no " and nothing about a price" "$R" '>\$129<'
|
||||
check "the home page states the price" "$(curl -s "$B/")" 'No licence, no per-device charge'
|
||||
check "the footer chip says free to use" "$(curl -s "$B/")" 'Free to use</div>'
|
||||
no "the sign-in page asks nothing about plans" "$(curl -s "$B/auth?mode=signup")" 'Hosted Facility'
|
||||
check "the terms already carry the fees section" "$(curl -s "$B/terms")" '9. Fees'
|
||||
|
||||
echo "== plans off: a new facility is grandfathered"
|
||||
check "signup A" "$(signup "$J1" "Plan Grandfathered $TS" "plan-a-$TS@example.com")" '"ok":true'
|
||||
check " groups" "$(e2e_groups "$B" "$J1")" '"ok":true'
|
||||
R=$(mut "$J1" import.rows "$(rows 65)")
|
||||
check " 65 staff import with no ceiling" "$R" '"created":65'
|
||||
no " and no refusal in the errors" "$R" 'register is full'
|
||||
check " the 66th by the form" "$(mut "$J1" staff.save '{"num":"9066","first":"Sixty","last":"Six","group":"Kitchen","dept":"Kitchen"}')" '"id"'
|
||||
|
||||
echo "== plans on"
|
||||
check "the owner turns plans on" "$(control '{"action":"switch","key":"plansLive","value":true}')" '"ok":true'
|
||||
R=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/controls")
|
||||
check " and the switches page says live" "$R" 'Live'
|
||||
|
||||
echo "== plans on: the website tells the plans story"
|
||||
R=$(curl -s "$B/pricing")
|
||||
check "pricing says free to run" "$R" 'Free to run'
|
||||
check " and names the price" "$R" '>\$129<'
|
||||
check " and the health service bundle" "$R" '\$4,990'
|
||||
check " and keeps the founder's sentence" "$R" 'charging for it was never the point'
|
||||
no " and no longer says that is the whole page" "$R" 'That is the whole page'
|
||||
check " the title is still Pricing" "$R" '<title>Pricing'
|
||||
R=$(curl -s "$B/")
|
||||
check "the home page band changed" "$R" 'Paid to host'
|
||||
no " and no longer says no licence" "$R" 'No licence, no per-device charge'
|
||||
check " structured data carries the hosted offer" "$R" '"price":"1290"'
|
||||
check "the footer chip says free to run" "$R" 'Free to run</div>'
|
||||
R=$(curl -s "$B/faq")
|
||||
check "the FAQ answer changed" "$R" 'The software is. Run it on your own server'
|
||||
check "the about page's last line changed" "$(curl -s "$B/about")" 'Hosting it for you is what costs money'
|
||||
no "the support heading no longer says free" "$(curl -s "$B/support")" 'is free and maintained'
|
||||
check "the terms carry the fees section" "$(curl -s "$B/terms")" '9. Fees'
|
||||
check " and the grandfathering" "$(curl -s "$B/terms")" 'is grandfathered: hosted free'
|
||||
check "the sign-in page offers the plan choice" "$(curl -s "$B/auth?mode=signup")" 'Hosted Facility'
|
||||
check "the phone sign-up offers it too" "$(curl -s "$B/m/signup")" 'Hosted Small'
|
||||
|
||||
echo "== plans on: a trial can be chosen at sign-up"
|
||||
J4="$T/tc-plan-d.txt"; rm -f "$J4"
|
||||
check "signup D on a Hosted Facility trial" "$(curl -s -c "$J4" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.6.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Plan\",\"last\":\"Trial\",\"facility\":\"Plan Trial $TS\",\"email\":\"plan-d-$TS@example.com\",\"password\":\"password123\",\"plan\":\"hosted_facility\"}")" '"ok":true'
|
||||
check " groups" "$(e2e_groups "$B" "$J4")" '"ok":true'
|
||||
check " no ceiling on a trial" "$(mut "$J4" import.rows "$(rows 61)")" '"created":61'
|
||||
FD=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities" | grep -o "href=\"/ops/facilities/[a-z0-9]\{20,40\}\"[^>]*>Plan Trial $TS<" | head -1 | sed 's/.*facilities\/\([a-z0-9]*\)".*/\1/')
|
||||
R=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FD")
|
||||
check " the console shows Hosted Facility" "$R" 'Hosted Facility'
|
||||
check " on trial" "$R" '>Trial<'
|
||||
check " a nonsense plan lands on Hosted Small" "$(J5="$T/tc-plan-e.txt"; rm -f "$J5"; curl -s -c "$J5" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.5.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Plan\",\"last\":\"Odd\",\"facility\":\"Plan Odd $TS\",\"email\":\"plan-e-$TS@example.com\",\"password\":\"password123\",\"plan\":\"gold\"}" >/dev/null; e2e_groups "$B" "$J5" >/dev/null; mut "$J5" import.rows "$(rows 61)")" '"created":60'
|
||||
|
||||
echo "== plans on: a new facility is Hosted Small"
|
||||
check "signup B" "$(signup "$J2" "Plan Small $TS" "plan-b-$TS@example.com")" '"ok":true'
|
||||
check " groups" "$(e2e_groups "$B" "$J2")" '"ok":true'
|
||||
R=$(mut "$J2" staff.save '{"num":"8001","first":"First","last":"Person","group":"Kitchen","dept":"Kitchen"}')
|
||||
check " one record by the form" "$R" '"id"'; S1=$(echo "$R" | jget '["result"]["id"]')
|
||||
R=$(mut "$J2" import.rows "$(rows 65)")
|
||||
check " 65 rows import 59 more" "$R" '"created":59'
|
||||
check " and say why once" "$R" 'register is full for this plan'
|
||||
check " skipping the rest" "$R" '"skipped":6'
|
||||
check " the 61st by the form is refused" "$(mut "$J2" staff.save '{"num":"9099","first":"Sixty","last":"One","group":"Kitchen","dept":"Kitchen"}')" 'register is full'
|
||||
check " editing an existing record still works" "$(mut "$J2" staff.save "{\"id\":\"$S1\",\"num\":\"8001\",\"first\":\"First\",\"last\":\"Renamed\",\"group\":\"Kitchen\",\"dept\":\"Kitchen\"}")" "\"id\":\"$S1\""
|
||||
check " a coordinator's other writes are untouched" "$(mut "$J2" dept.save '{"name":"Willow","cc":"CC-1"}')" '"ok":true'
|
||||
check " facility A is still grandfathered" "$(mut "$J1" staff.save '{"num":"9067","first":"Sixty","last":"Seven","group":"Kitchen","dept":"Kitchen"}')" '"id"'
|
||||
|
||||
echo "== the console sees the plan"
|
||||
FB=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities" | grep -o "href=\"/ops/facilities/[a-z0-9]\{20,40\}\"[^>]*>Plan Small $TS<" | head -1 | sed 's/.*facilities\/\([a-z0-9]*\)".*/\1/')
|
||||
if [ -n "$FB" ]; then ok "facility B's id from the list"; else fail "facility B's id from the list" "not found"; fi
|
||||
R=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FB")
|
||||
check " its page says Hosted Small" "$R" 'Hosted Small'
|
||||
check " and a ceiling of 60" "$R" '>60<'
|
||||
R=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities")
|
||||
check " the list says free" "$R" 'Hosted Small · free'
|
||||
check " and grandfathered for A" "$R" 'Free · grandfathered'
|
||||
|
||||
echo "== read-only"
|
||||
check "the console sets B read-only" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"readonly\",\"on\":true}")" '"ok":true'
|
||||
check " a write is refused" "$(mut "$J2" dept.save '{"name":"Maple","cc":"CC-2"}')" 'Read-only'
|
||||
check " with a 403" "$(curl -s -o /dev/null -w '%{http_code}' -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d '{"op":"dept.save","payload":{"name":"Oak","cc":"CC-3"}}')" '403'
|
||||
check " the backup still downloads" "$(code -b "$J2" "$B/api/backup")" '200'
|
||||
check " the billing contact can still be set" "$(mut "$J2" plan.billing '{"email":"accounts@example.com"}')" '"ok":true'
|
||||
check " and an invoice requested" "$(mut "$J2" plan.invoice '{"email":"accounts@example.com","plan":"hosted_facility"}')" '"ok":true'
|
||||
check " but not twice a day, four times" "$(mut "$J2" plan.invoice '{}' >/dev/null; mut "$J2" plan.invoice '{}' >/dev/null; mut "$J2" plan.invoice '{}')" 'already been requested'
|
||||
check " the coordinator's own password op stays open" "$(mut "$J2" me.password '{"current":"password123","next":"password1234"}')" '"ok":true'
|
||||
check " the list shows it in red" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities")" 'Hosted Small · read-only'
|
||||
check "and makes it writable again" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"readonly\",\"on\":false}")" '"ok":true'
|
||||
check " a write goes through" "$(mut "$J2" dept.save '{"name":"Maple","cc":"CC-2"}')" '"ok":true'
|
||||
check " the ceiling is back too" "$(mut "$J2" staff.save '{"num":"9098","first":"Sixty","last":"One","group":"Kitchen","dept":"Kitchen"}')" 'register is full'
|
||||
|
||||
echo "== trial, payment, free"
|
||||
check "a 60-day trial starts" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"trial\",\"days\":60}")" '"ok":true'
|
||||
R=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FB")
|
||||
check " the page says Hosted Facility" "$R" 'Hosted Facility'
|
||||
check " on trial" "$R" '>Trial<'
|
||||
check " the ceiling is lifted" "$(mut "$J2" staff.save '{"num":"9098","first":"Sixty","last":"One","group":"Kitchen","dept":"Kitchen"}')" '"id"'
|
||||
check "a payment is recorded" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"paid\",\"months\":12}")" '"ok":true'
|
||||
check " the page says Paid" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FB")" '>Paid<'
|
||||
check "set free clears it" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"free\"}")" '"ok":true'
|
||||
check " and the page says Free" "$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FB")" 'Recorded as</dt><dd[^>]*>free'
|
||||
check "an owner sets Health Service and grandfathers" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"set\",\"plan\":\"health_service\",\"planNote\":\"PO 1\",\"grandfathered\":true}")" '"ok":true'
|
||||
R=$(curl -s -b "$OJ" -H "Host: $OPS" "$B/ops/facilities/$FB")
|
||||
check " the page says Health Service" "$R" 'Health Service'
|
||||
check " and grandfathered" "$R" 'grandfathered'
|
||||
check " the note is shown" "$R" 'PO 1'
|
||||
check " a bad plan code is refused" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"set\",\"plan\":\"gold\",\"planNote\":\"\"}")" 'Not a plan'
|
||||
check " a grandfathered room has no ceiling even on read-only's neighbour, free" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"set\",\"plan\":\"hosted_small\",\"planNote\":\"\",\"grandfathered\":true}" >/dev/null; mut "$J2" staff.save '{"num":"9097","first":"Sixty","last":"Two","group":"Kitchen","dept":"Kitchen"}')" '"id"'
|
||||
check " taking the promise away brings the ceiling back" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"set\",\"plan\":\"hosted_small\",\"planNote\":\"\",\"grandfathered\":false}" >/dev/null; mut "$J2" staff.save '{"num":"9096","first":"Sixty","last":"Three","group":"Kitchen","dept":"Kitchen"}')" 'register is full'
|
||||
check " read-only beats the promise" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"set\",\"plan\":\"hosted_small\",\"planNote\":\"\",\"grandfathered\":true}" >/dev/null; control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"readonly\",\"on\":true}" >/dev/null; mut "$J2" dept.save '{"name":"Elm","cc":"CC-5"}')" 'Read-only'
|
||||
|
||||
echo "== plans off again"
|
||||
check "the owner turns plans off" "$(control '{"action":"switch","key":"plansLive","value":false}')" '"ok":true'
|
||||
check " a facility created now is grandfathered" "$(J3="$T/tc-plan-c.txt"; rm -f "$J3"; signup "$J3" "Plan After $TS" "plan-c-$TS@example.com" >/dev/null; e2e_groups "$B" "$J3" >/dev/null; mut "$J3" import.rows "$(rows 61)")" '"created":61'
|
||||
check " facility B is still read-only from the owner's last act" "$(mut "$J2" dept.save '{"name":"Birch","cc":"CC-4"}')" 'Read-only'
|
||||
check " and made writable to finish" "$(control "{\"action\":\"plan\",\"facilityId\":\"$FB\",\"act\":\"readonly\",\"on\":false}")" '"ok":true'
|
||||
|
||||
echo; echo "plan: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -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"]}}'
|
||||
}
|
||||
@@ -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 ]
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# Password reset: the happy path, and the ways it must refuse.
|
||||
# Runs without SMTP configured — the token is read from the database, which is exactly what an
|
||||
# attacker cannot do, so the checks here are about the token's lifecycle rather than the email.
|
||||
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-rst-cj.txt"; rm -f "$J"
|
||||
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; }
|
||||
post() { curl -s -X POST "$B$1" -H 'content-type: application/json' -H "origin: $B" -d "$2"; }
|
||||
|
||||
TS=$(date +%s)
|
||||
EMAIL="rst$TS@example.com"
|
||||
|
||||
echo "== setup"
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.13.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Rita\",\"last\":\"Reset\",\"facility\":\"Reset Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
|
||||
echo "== asking for a link tells you nothing about the address"
|
||||
# The route would give the game away by shape long before it gave it away in words — a 404, or an
|
||||
# {"error":...} for an address it has never heard of, says "no account here" as plainly as any
|
||||
# sentence would. So the two replies are compared byte for byte, status code included, rather than
|
||||
# read for particular turns of phrase.
|
||||
forgot() { curl -s -w '\n%{http_code}' -X POST "$B/api/auth/forgot" -H 'content-type: application/json' -H "origin: $B" -d "$1"; }
|
||||
KNOWN=$(forgot "{\"email\":\"$EMAIL\"}")
|
||||
UNKNOWN=$(forgot '{"email":"nobody-at-all@example.com"}')
|
||||
check "a known address gets ok" "$KNOWN" '"ok":true'
|
||||
if [ "$KNOWN" = "$UNKNOWN" ]; then ok "an unknown address gets a byte-identical reply"
|
||||
else fail "an unknown address gets a byte-identical reply" "$KNOWN vs $UNKNOWN"; fi
|
||||
check "a malformed address is accepted silently too" "$(post /api/auth/forgot '{"email":"not-an-email"}')" '"ok":true'
|
||||
|
||||
echo "== a request actually records a row"
|
||||
# reset-token.cjs prints "<id> <tokenHash> <expiresAt> <usedAt>", with usedAt as "-" when unspent.
|
||||
# A row on its own proves nothing: /api/auth/forgot supersedes the outstanding rows and creates the
|
||||
# new one inside one transaction, and in the wrong order that would stamp the fresh row used — every
|
||||
# emailed link born dead, with a row in the table to show for it. So the row has to be a spendable one.
|
||||
ROW=$(node scripts/reset-token.cjs "$EMAIL" 2>/dev/null)
|
||||
check "asking for a link records a reset" "$ROW" '.'
|
||||
check "and the reset it records is unspent" "$ROW" ' -$'
|
||||
check "and has not already expired" "$(echo "$ROW" | awk -v now="$(date -u +%Y-%m-%dT%H:%M:%S)" '{ print ($3 > now) ? "future" : "past" }')" '^future$'
|
||||
|
||||
echo "== the happy path"
|
||||
TOKEN=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null)
|
||||
check "a token can be minted for the test" "$TOKEN" '.'
|
||||
# What the table holds for a token whose raw value we know. The design rests on a pg_dump of
|
||||
# PasswordReset being useless to whoever reads it: the row carries the SHA-256, and the raw token
|
||||
# exists only in the email. Both halves are worth saying — the stored value is that hash, and the
|
||||
# raw token appears nowhere in the row. The reader projects four columns, so a plaintext column
|
||||
# added later slips past the second half until reset-token.cjs prints the whole row.
|
||||
MINTED=$(node scripts/reset-token.cjs "$EMAIL" 2>/dev/null)
|
||||
check "the stored value is the token's SHA-256" "$MINTED" "$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)"
|
||||
# A substring test rather than a grep: a raw token can begin with "-", which grep reads as an option.
|
||||
case "$MINTED" in
|
||||
*"$TOKEN"*) fail "the raw token is never stored" "$MINTED" ;;
|
||||
*) ok "the raw token is never stored" ;;
|
||||
esac
|
||||
|
||||
check "the new password is accepted" "$(post /api/auth/reset "{\"token\":\"$TOKEN\",\"password\":\"brandnewpass456\"}")" '"ok":true'
|
||||
check "the new password signs in" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}")" '"ok":true'
|
||||
check "the old password no longer works" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"password123\"}")" 'doesn'
|
||||
|
||||
echo "== a token is single use"
|
||||
check "the same token a second time is refused" "$(post /api/auth/reset "{\"token\":\"$TOKEN\",\"password\":\"anotherpass789\"}")" 'expired or has already been used'
|
||||
check "and the password did not change again" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}")" '"ok":true'
|
||||
|
||||
echo "== an expired token is refused"
|
||||
OLD=$(node scripts/reset-mint.cjs "$EMAIL" -1000 2>/dev/null)
|
||||
check "an already-expired token is refused" "$(post /api/auth/reset "{\"token\":\"$OLD\",\"password\":\"expiredpass123\"}")" 'expired or has already been used'
|
||||
|
||||
echo "== asking again kills the previous link"
|
||||
T1=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null)
|
||||
post /api/auth/forgot "{\"email\":\"$EMAIL\"}" > /dev/null
|
||||
check "the superseded token is dead" "$(post /api/auth/reset "{\"token\":\"$T1\",\"password\":\"supersededpass1\"}")" 'expired or has already been used'
|
||||
|
||||
echo "== refusals"
|
||||
check "a made-up token is refused" "$(post /api/auth/reset '{"token":"totally-made-up","password":"newpassword123"}')" 'expired or has already been used'
|
||||
check "a short password is refused" "$(post /api/auth/reset '{"token":"whatever","password":"short"}')" 'at least 8'
|
||||
check "an empty token is refused" "$(post /api/auth/reset '{"token":"","password":"newpassword123"}')" 'incomplete'
|
||||
|
||||
echo "== a reset does not walk past the second factor"
|
||||
# The failure this covers: setting a password used to sign the account straight in, second factor or
|
||||
# not — a reset link in a stolen mailbox was a complete bypass of the very thing 2FA is for. The
|
||||
# response now has to be the ticket shape /api/auth/login uses, with no session cookie attached.
|
||||
# The resets above changed the password, and a session cookie carries a version derived from the
|
||||
# hash — so the jar from signup is already dead. Sign in again to have a session to enrol with.
|
||||
curl -s -c "$J" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}" > /dev/null
|
||||
SETUP=$(curl -s -b "$J" -c "$J" -X POST "$B/api/2fa" -H 'content-type: application/json' -H "origin: $B" -d '{"action":"setup"}')
|
||||
SECRET=$(echo "$SETUP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('secret',''))" 2>/dev/null)
|
||||
if [ -n "$SECRET" ]; then
|
||||
CODE=$(npx tsx -e "import { base32Decode, totp } from './lib/totp'; console.log(totp(base32Decode('$SECRET')));" 2>/dev/null | tail -1)
|
||||
check "two-factor turned on for the test" "$(curl -s -b "$J" -c "$J" -X POST "$B/api/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"action\":\"enable\",\"code\":\"$CODE\"}")" '"ok":true'
|
||||
TFT=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null)
|
||||
C="$T/tc-rst-2fa.txt"; rm -f "$C"
|
||||
OUT=$(curl -s -c "$C" -X POST "$B/api/auth/reset" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TFT\",\"password\":\"twofactorpass99\"}")
|
||||
check "a reset on a 2FA account asks for the code" "$OUT" '"need2fa":true'
|
||||
no "and does not report a plain sign-in" "$OUT" '"ok":true'
|
||||
no "and issues no session cookie" "$(cat "$C" 2>/dev/null || true)" 'tc_session'
|
||||
check "the new password is still set" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"twofactorpass99\"}")" '"need2fa":true'
|
||||
rm -f "$C"
|
||||
else
|
||||
fail "two-factor setup for the reset test" "no secret returned from /api/2fa"
|
||||
fi
|
||||
|
||||
echo "== the pages"
|
||||
# Anchored on the heading markup: the h1 ends in a full stop and the tab title app/reset/layout.tsx
|
||||
# sets does not, and that title goes into the head whatever the page component does — including when
|
||||
# it renders nothing but chrome.
|
||||
check "/reset renders server-side" "$(curl -s "$B/reset?token=abc")" '>Reset your password\.'
|
||||
# Someone who copied half a link out of an email should land on the reset page, not on a 404 or a
|
||||
# bounce to /auth. The sentence that tells them so ("That link is incomplete") sits inside the
|
||||
# useSearchParams boundary and is client-rendered, out of curl's reach; the page arriving under its
|
||||
# own name, with its own status, is the server's half of that promise.
|
||||
check "/reset says what it is even with no token" "$(curl -s "$B/reset")" '>Reset your password\.'
|
||||
check "and a half-copied link is not bounced away" "$(curl -s -o /dev/null -w '%{http_code}' "$B/reset")" '^200$'
|
||||
check "the phone sign-in offers a reset" "$(curl -s "$B/m/login")" 'Forgot password'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+242
@@ -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 ]
|
||||
@@ -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 ]
|
||||
Executable
+817
@@ -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 ]
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# Single sign-on for a facility's own people — the whole path, against a stand-in broker.
|
||||
#
|
||||
# The product never talks SAML itself; it talks to the Jackson broker, and scripts/mock-jackson.cjs
|
||||
# speaks enough of Jackson for these checks to go end to end: an admin connects an identity
|
||||
# provider and registers a domain, the Log in box offers single sign-on for that domain, the start
|
||||
# route sends the browser to the broker with a state cookie, the broker sends it back, the callback
|
||||
# matches the identity to an existing account and mints the ordinary session. Then the guards: a
|
||||
# forged state, an address the facility never added, an inactive account, "require SSO" refusing
|
||||
# the password for everyone but the break-glass admin, password resets going quiet, a wearer on the
|
||||
# web, and the whole feature vanishing when the broker is not configured.
|
||||
#
|
||||
# The dev server must have JACKSON_URL=http://127.0.0.1:3199 and JACKSON_API_KEY=e2e-jackson-key
|
||||
# (the runner's .env does); the mock is started here.
|
||||
set -u
|
||||
B=${BASE:-http://127.0.0.1:3111}
|
||||
T=${TMP:-/tmp}
|
||||
MOCK=http://127.0.0.1:3199
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
|
||||
where() { curl -s -o /dev/null -w '%{redirect_url}' "$@"; }
|
||||
is() { local name=$1 want=$2 got=$3; if [ "$got" = "$want" ]; then ok "$name"; else fail "$name" "wanted $want, got $got"; fi; }
|
||||
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; }
|
||||
J="$T/tc-sso-admin.txt"; SJ="$T/tc-sso-second.txt"; WJ="$T/tc-sso-wearer.txt"; BJ="$T/tc-sso-browser.txt"
|
||||
rm -f "$J" "$SJ" "$WJ" "$BJ"
|
||||
XFF="10.66.$((RANDOM%250)).$((RANDOM%250))"
|
||||
post() { local jar=$1 path=$2 body=$3; curl -s -b "$jar" -c "$jar" -X POST "$B$path" -H 'content-type: application/json' -H "x-forwarded-for: $XFF" -d "$body"; }
|
||||
patch() { local jar=$1 path=$2 body=$3; curl -s -b "$jar" -c "$jar" -X PATCH "$B$path" -H 'content-type: application/json' -H "x-forwarded-for: $XFF" -d "$body"; }
|
||||
mut() { post "$1" /api/mutate "{\"op\":\"$2\",\"payload\":$3}"; }
|
||||
identity() { curl -s -X POST "$MOCK/__mock/identity" -H 'content-type: application/json' -d "{\"email\":\"$1\",\"name\":\"$2\"}" >/dev/null; }
|
||||
# Walk the redirect dance the way a browser would: start → broker → callback, cookies in one jar.
|
||||
signin() { local jar=$1 email=$2 as=${3:-user}; rm -f "$jar"; curl -s -L -b "$jar" -c "$jar" -o /dev/null -w '%{url_effective}' -H "x-forwarded-for: $XFF" "$B/api/auth/sso/start?email=$email&as=$as"; }
|
||||
|
||||
node scripts/mock-jackson.cjs 3199 >/dev/null 2>&1 &
|
||||
MOCKPID=$!
|
||||
trap 'kill $MOCKPID 2>/dev/null' EXIT
|
||||
for i in $(seq 1 30); do curl -s -o /dev/null "$MOCK/__mock/connections" && break; sleep 0.2; done
|
||||
|
||||
TS=$(date +%s)
|
||||
DOM="sso$TS.example"
|
||||
AE="admin@$DOM"; IE="issuer@$DOM"; WE="wearer@$DOM"; OE="outsider@$DOM"
|
||||
FN="SSO Hospital $TS"
|
||||
|
||||
echo "== a facility, its people, and a wearer"
|
||||
check "the admin signs up" "$(post "$J" /api/auth/signup "{\"first\":\"Ada\",\"last\":\"Admin\",\"facility\":\"$FN\",\"email\":\"$AE\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "an issuer is added" "$(mut "$J" users.add "{\"first\":\"Ivy\",\"last\":\"Issuer\",\"email\":\"$IE\",\"password\":\"password123\",\"role\":\"ISSUER\"}")" '"ok":true'
|
||||
SID=$(mut "$J" staff.save '{"num":"W1","first":"Wynn","last":"Wearer","group":"Nursing","dept":"Ward 1"}' | python3 -c 'import sys,json; print(json.load(sys.stdin).get("result",{}).get("id",""))')
|
||||
check "a staff member is added" "$SID" '^[a-z0-9]\{20,\}$'
|
||||
CODE=$(mut "$J" staff.selfCode "{\"id\":\"$SID\"}" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("result",{}).get("code",""))')
|
||||
check " and an activation code" "$CODE" '^[A-Z0-9-]\{6,\}$'
|
||||
check "the wearer activates an account" "$(post "$WJ" /api/staff/activate "{\"code\":\"$CODE\",\"email\":\"$WE\",\"password\":\"password123\",\"agreed\":true}")" '"ok":true'
|
||||
|
||||
echo "== before anything is connected"
|
||||
check "the lookup says no" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"$AE\"}")" '"sso":false'
|
||||
check " and start sends you back" "$(where -H "x-forwarded-for: $XFF" "$B/api/auth/sso/start?email=$AE")" 'sso_unavailable'
|
||||
check "the settings say not connected" "$(curl -s -b "$J" "$B/api/sso")" '"enabled":false'
|
||||
|
||||
echo "== an admin connects the identity provider"
|
||||
check "an issuer may not" "$(post "$SJ" /api/auth/login "{\"email\":\"$IE\",\"password\":\"password123\"}" >/dev/null; post "$SJ" /api/sso "{\"metadataXml\":\"<EntityDescriptor/>\",\"domains\":\"$DOM\"}")" 'Admins only'
|
||||
check "no metadata is refused" "$(post "$J" /api/sso "{\"domains\":\"$DOM\"}")" 'metadata'
|
||||
check "an http metadata URL is refused" "$(post "$J" /api/sso "{\"metadataUrl\":\"http://idp.example/meta\",\"domains\":\"$DOM\"}")" 'https'
|
||||
check "a public mail domain is refused" "$(post "$J" /api/sso "{\"metadataXml\":\"<EntityDescriptor/>\",\"domains\":\"gmail.com\"}")" 'public mail service'
|
||||
check "bad metadata is refused by the broker" "$(post "$J" /api/sso "{\"metadataXml\":\"not xml\",\"domains\":\"$DOM\"}")" 'rejected'
|
||||
check " and SSO stays off" "$(curl -s -b "$J" "$B/api/sso")" '"enabled":false'
|
||||
check "good metadata connects" "$(post "$J" /api/sso "{\"metadataXml\":\"<EntityDescriptor entityID='x'/>\",\"domains\":\"$DOM, Extra.$DOM\"}")" '"enabled":true'
|
||||
ST=$(curl -s -b "$J" "$B/api/sso")
|
||||
check " the broker holds it" "$ST" '"connected":true'
|
||||
check " the domains are kept, lower-cased" "$ST" "extra.$DOM"
|
||||
check " and the audit trail says so" "$(curl -s -b "$J" "$B/api/activity" | head -c 4000)" 'settings.sso.connect'
|
||||
check "a second facility cannot claim the domain" "$(post "$SJ" /api/auth/signup "{\"first\":\"Bo\",\"last\":\"Other\",\"facility\":\"Other $TS\",\"email\":\"bo@other$TS.example\",\"password\":\"password123\"}" >/dev/null; post "$SJ" /api/sso "{\"metadataXml\":\"<EntityDescriptor/>\",\"domains\":\"$DOM\"}")" 'already registered'
|
||||
|
||||
echo "== the Log in box learns about it"
|
||||
check "the lookup now says yes" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"someone@$DOM\"}")" '"sso":true'
|
||||
check " naming the facility" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"someone@$DOM\"}")" "$FN"
|
||||
check " but not for other domains" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"someone@else$TS.example\"}")" '"sso":false'
|
||||
check " and not for a bare word" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"nonsense\"}")" '"sso":false'
|
||||
|
||||
echo "== signing in through the identity provider"
|
||||
identity "$AE" "Ada Admin"
|
||||
END=$(signin "$BJ" "$AE")
|
||||
check "the admin lands in the app" "$END" '/app'
|
||||
check " with a coordinator session" "$(cat "$BJ")" 'tc_session'
|
||||
is " that opens the app" 200 "$(code -b "$BJ" "$B/app")"
|
||||
check " and the trail records an sso sign-in" "$(curl -s -b "$BJ" "$B/api/activity" | head -c 4000)" 'sso'
|
||||
identity "$IE" "Ivy Issuer"
|
||||
check "the issuer too" "$(signin "$BJ" "$IE")" '/app'
|
||||
identity "$OE" "Ollie Outsider"
|
||||
check "an address the facility never added is refused" "$(signin "$BJ" "$OE")" 'sso_no_account'
|
||||
no " with no session" "$(cat "$BJ")" 'tc_session'
|
||||
LP=$(curl -s "$B/auth?error=sso_no_account")
|
||||
check " and the box explains" "$LP" 'has no account at this facility'
|
||||
check "a cross-site start is bounced" "$(where -H "sec-fetch-site: cross-site" "$B/api/auth/sso/start?email=$AE")" '/auth$'
|
||||
|
||||
echo "== a forged return"
|
||||
identity "$AE" "Ada Admin"
|
||||
rm -f "$BJ"
|
||||
LOC=$(where -c "$BJ" -H "x-forwarded-for: $XFF" "$B/api/auth/sso/start?email=$AE")
|
||||
check "start goes to the broker" "$LOC" '3199/api/oauth/authorize'
|
||||
check " with a state cookie" "$(cat "$BJ")" 'tc_sso'
|
||||
check "the broker's code with the wrong state is refused" "$(where -b "$BJ" -H "x-forwarded-for: $XFF" "$B/api/auth/sso/callback?code=abc&state=wrong")" 'sso_state'
|
||||
check "a callback with no cookie is refused" "$(where -H "x-forwarded-for: $XFF" "$B/api/auth/sso/callback?code=abc&state=abc")" 'sso_state'
|
||||
check "an error from the provider is refused" "$(where -b "$BJ" -H "x-forwarded-for: $XFF" "$B/api/auth/sso/callback?error=access_denied")" 'sso_failed'
|
||||
|
||||
echo "== requiring it"
|
||||
check "requiring with no break-glass admin is refused" "$(patch "$J" /api/sso '{"required":true}')" 'break-glass'
|
||||
AID=$(node -e 'require("dotenv/config");const{Client}=require("pg");const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(()=>c.query("SELECT id FROM \"User\" WHERE email=$1",[process.argv[1]])).then(r=>{console.log(r.rows[0]?.id||"");return c.end()})' "$AE")
|
||||
check "the admin marks themselves break-glass" "$(mut "$J" users.update "{\"id\":\"$AID\",\"ssoBreakGlass\":true}")" '"ok":true'
|
||||
check "an issuer cannot be break-glass" "$(node -e 'require("dotenv/config");const{Client}=require("pg");const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(()=>c.query("SELECT id FROM \"User\" WHERE email=$1",[process.argv[1]])).then(r=>{console.log(r.rows[0]?.id||"");return c.end()})' "$IE" | xargs -I{} sh -c "curl -s -b '$J' -c '$J' -X POST '$B/api/mutate' -H 'content-type: application/json' -d '{\"op\":\"users.update\",\"payload\":{\"id\":\"{}\",\"ssoBreakGlass\":true}}'")" 'Only an admin'
|
||||
check "now it can be required" "$(patch "$J" /api/sso '{"required":true}')" '"ssoRequired":true'
|
||||
check "the issuer's password is refused" "$(post "$SJ" /api/auth/login "{\"email\":\"$IE\",\"password\":\"password123\"}")" '"ssoRequired":true'
|
||||
is " with a 403" 403 "$(code -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "x-forwarded-for: $XFF" -d "{\"email\":\"$IE\",\"password\":\"password123\"}")"
|
||||
check "the break-glass admin's password still works" "$(post "$SJ" /api/auth/login "{\"email\":\"$AE\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "a reset for the issuer goes quiet" "$(post "$SJ" /api/auth/forgot "{\"email\":\"$IE\"}")" '"ok":true'
|
||||
is " and writes no reset row" 0 "$(node -e 'require("dotenv/config");const{Client}=require("pg");const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(()=>c.query("SELECT count(*)::int AS n FROM \"PasswordReset\" r JOIN \"User\" u ON u.id=r.\"userId\" WHERE u.email=$1",[process.argv[1]])).then(r=>{console.log(String(r.rows[0].n));return c.end()})' "$IE")"
|
||||
identity "$IE" "Ivy Issuer"
|
||||
check " while SSO still lets the issuer in" "$(signin "$BJ" "$IE")" '/app'
|
||||
|
||||
echo "== a wearer on the web"
|
||||
identity "$WE" "Wynn Wearer"
|
||||
check "with staff SSO off the wearer is refused" "$(signin "$BJ" "$WE")" 'sso_no_account'
|
||||
check "the admin lets staff use it" "$(patch "$J" /api/sso '{"staff":true}')" '"ssoStaff":true'
|
||||
check "now the wearer lands in the staff app" "$(signin "$BJ" "$WE")" '/my'
|
||||
check " with a staff session" "$(cat "$BJ")" 'tc_staff'
|
||||
no " and not a coordinator one" "$(cat "$BJ")" 'tc_session'
|
||||
check "the staff-app door does the same" "$(signin "$BJ" "$WE" staff)" '/my'
|
||||
check "the wearer's password still works (staff app)" "$(post "$WJ" /api/staff/login "{\"email\":\"$WE\",\"password\":\"password123\"}")" '"ok":true'
|
||||
|
||||
echo "== disconnecting"
|
||||
check "a wearer may not" "$(curl -s -b "$WJ" -X DELETE "$B/api/sso")" 'Admins only\|Not signed in'
|
||||
check "the admin disconnects" "$(curl -s -b "$J" -c "$J" -X DELETE "$B/api/sso" -H "x-forwarded-for: $XFF")" '"enabled":false'
|
||||
check " the broker no longer holds it" "$(curl -s "$MOCK/__mock/connections")" '^\[\]$'
|
||||
check " the lookup says no again" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"$AE\"}")" '"sso":false'
|
||||
check " and the issuer's password works again" "$(post "$SJ" /api/auth/login "{\"email\":\"$IE\",\"password\":\"password123\"}")" '"ok":true'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+218
@@ -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\"}")" 'doesn’t 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"}')" 'doesn’t 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\"}")" 'doesn’t 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 else’s 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\"}")" 'doesn’t 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 ]
|
||||
Executable
+718
@@ -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 isn’t recorded yet'
|
||||
check " while somebody with none is still told" "$(curl -s -b "$O" "$B/my")" 'manager isn’t 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" "doesn’t 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 ]
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update.V2 smoke test: demo facility, demo gates, soft-deactivated users, self profile, legal/marketing routes.
|
||||
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-v2-cj.txt"; D="$T/tc-v2-demo.txt"; rm -f "$J" "$D"
|
||||
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}"; }
|
||||
dmut() { curl -s -b "$D" -c "$D" -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" "found: $pat"; else ok "$name"; fi; }
|
||||
jget() { python3 -c "import sys,json; d=json.load(sys.stdin); print(eval('d$1'))"; }
|
||||
|
||||
echo "== public pages"
|
||||
check "marketing hero" "$(curl -s "$B/")" 'Every garment out the door'
|
||||
check "marketing OG tags for link previews" "$(curl -s "$B/")" 'property="og:title"'
|
||||
check "canonical url" "$(curl -s "$B/")" 'rel="canonical"'
|
||||
check "robots.txt served" "$(curl -s "$B/robots.txt")" 'Sitemap:'
|
||||
check "robots keeps crawlers out of the app" "$(curl -s "$B/robots.txt")" 'Disallow: /app'
|
||||
check "sitemap served" "$(curl -s "$B/sitemap.xml")" '<loc>'
|
||||
check "og image declared" "$(curl -s "$B/")" 'og:image" content="[^"]*og.png'
|
||||
check "og image actually served" "$(curl -s -o /dev/null -w '%{http_code} %{content_type}' "$B/og.png")" '200 image/png'
|
||||
check "home states the price" "$(curl -s "$B/")" 'No licence, no per-device charge'
|
||||
check "pricing page says free" "$(curl -s "$B/pricing")" 'That is the whole page'
|
||||
check "features page" "$(curl -s "$B/features")" '<title>Features'
|
||||
check "how it works page" "$(curl -s "$B/how-it-works")" '<title>How it works'
|
||||
check "pricing page" "$(curl -s "$B/pricing")" '<title>Pricing'
|
||||
check "security page" "$(curl -s "$B/security")" '<title>Security'
|
||||
check "getting started page" "$(curl -s "$B/getting-started")" '<title>Getting started'
|
||||
check "support page" "$(curl -s "$B/support")" '<title>Support'
|
||||
check "roadmap page" "$(curl -s "$B/roadmap")" '<title>Roadmap'
|
||||
check "about page" "$(curl -s "$B/about")" '<title>Who built it'
|
||||
check "roadmap marks unbuilt items Planned" "$(curl -s "$B/roadmap")" 'Planned'
|
||||
no "no android beta claim anywhere" "$(curl -s "$B/roadmap"; curl -s "$B/faq"; curl -s "$B/features")" 'In beta'
|
||||
check "old /product redirects" "$(curl -s -o /dev/null -w '%{http_code}' "$B/product")" '30[78]'
|
||||
check "old /reports redirects" "$(curl -s -o /dev/null -w '%{http_code}' "$B/reports")" '30[78]'
|
||||
check "reporting page" "$(curl -s "$B/reporting")" '<title>Reporting'
|
||||
check "faq page" "$(curl -s "$B/faq")" '<title>Questions'
|
||||
check "contact page" "$(curl -s "$B/contact")" '<title>Contact'
|
||||
check "privacy is its own page" "$(curl -s "$B/privacy")" '<title>Privacy Policy'
|
||||
check "terms is its own page" "$(curl -s "$B/terms")" '<title>Terms of Service'
|
||||
check "data security is its own page" "$(curl -s "$B/data-security")" '<title>Data Security'
|
||||
check "acceptable use is its own page" "$(curl -s "$B/acceptable-use")" '<title>Acceptable Use'
|
||||
check "old /legal still resolves" "$(curl -s -o /dev/null -w '%{http_code}' "$B/legal")" '30[78]'
|
||||
# href="/features" sits in the footer's Product column too, so it came back on every page whether
|
||||
# the nav rendered or not. tcm-navlinks is the nav's own link row and nothing else carries it.
|
||||
for p in reporting pricing faq; do check "site nav on /$p" "$(curl -s "$B/$p")" 'tcm-navlinks'; done
|
||||
check "photos are served" "$(curl -s -o /dev/null -w '%{http_code}' "$B/photos/tc-photo-ward.jpg")" '200'
|
||||
check "sitemap lists the new pages" "$(curl -s "$B/sitemap.xml")" '/acceptable-use'
|
||||
check "demo has its own title" "$(curl -s "$B/demo")" '<title>Try the working demo'
|
||||
check "footer links privacy" "$(curl -s "$B/")" 'href="/privacy"'
|
||||
check "marketing links demo" "$(curl -s "$B/")" 'href="/demo"'
|
||||
check "privacy page content carried over" "$(curl -s "$B/privacy")" 'Information Privacy Act 2009'
|
||||
check "demo picker" "$(curl -s "$B/demo")" '/api/auth/demo?as=issuer'
|
||||
|
||||
echo "== demo entry"
|
||||
H=$(curl -s -D - -o /dev/null -c "$D" "$B/api/auth/demo?as=admin")
|
||||
check "demo enter 303" "$H" 'HTTP/1.1 303'
|
||||
check "demo enter Location /app" "$(echo "$H" | tr A-Z a-z)" 'location: /app'
|
||||
check "demo enter sets cookie" "$H" 'tc_session='
|
||||
BK=$(curl -s -b "$D" "$B/api/backup")
|
||||
check "demo facility seeded" "$BK" 'Riverside General Hospital'
|
||||
NSTAFF=$(echo "$BK" | jget '["staff"].__len__()'); [ "$NSTAFF" -ge 24 ] && ok "demo staff seeded ($NSTAFF)" || fail "demo staff" "$NSTAFF"
|
||||
NISS=$(echo "$BK" | jget '["issues"].__len__()'); [ "$NISS" -ge 20 ] && ok "demo issues seeded ($NISS)" || fail "demo issues" "$NISS"
|
||||
NPK=$(echo "$BK" | jget '["pickups"].__len__()'); [ "$NPK" -ge 3 ] && ok "demo pickups seeded ($NPK)" || fail "demo pickups" "$NPK"
|
||||
NAP=$(echo "$BK" | jget '["approvals"].__len__()'); [ "$NAP" -ge 6 ] && ok "demo approvals seeded ($NAP)" || fail "demo approvals" "$NAP"
|
||||
NBO=$(echo "$BK" | python3 -c 'import sys,json; d=json.load(sys.stdin); print([o["status"] for o in d["orders"]].count("Back Order"))'); [ "$NBO" -ge 1 ] && ok "demo back order exists" || fail "demo back order" "$NBO"
|
||||
check "demo app renders banner" "$(curl -s -b "$D" "$B/app")" 'Working demo'
|
||||
check "demo blocks users.add" "$(dmut users.add '{"email":"x@y.com","password":"password123","first":"A","last":"B"}')" 'Not available in the demo'
|
||||
check "demo blocks me.password" "$(dmut me.password '{"current":"a","next":"password123"}')" 'Not available in the demo'
|
||||
check "demo blocks wipe" "$(dmut data.wipeActivity '{"confirm":"WIPE"}')" 'Not available in the demo'
|
||||
check "demo blocks restore" "$(dmut backup.restore '{}')" 'Not available in the demo'
|
||||
# coordinator is one of the fields the demo strips before writing, so sending only that came back
|
||||
# ok:true having changed nothing. Send a field the sandbox is meant to allow, and read it back.
|
||||
check "demo allows settings.update" "$(dmut settings.update '{"defaultReorder":7}')" '"ok":true'
|
||||
check "demo settings.update actually wrote" "$(curl -s -b "$D" "$B/api/backup")" '"defaultReorder": *7'
|
||||
# resetDemo hashes random bytes for both demo accounts, so no password sent from here gets past the
|
||||
# compare — the "Demo accounts can't log in here" refusal further down the login route is out of
|
||||
# reach end to end, and an alternation on it passed on the wrong-password branch every time. What
|
||||
# this can honestly say is that the published demo address is not a way in.
|
||||
check "demo email is not a way in" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -d '{"email":"demo-admin@threadcount.tech","password":"whatever"}')" 'Email or password'
|
||||
H2=$(curl -s -D - -o /dev/null -c "$D" "$B/api/auth/demo?as=issuer")
|
||||
check "demo switch 303" "$H2" 'HTTP/1.1 303'
|
||||
# The facility name is the same facility under either role and is drawn on the app chrome as well,
|
||||
# so it said nothing about who is signed in — it even survived the switch being rate-limited. The
|
||||
# role in the snapshot names it, and the admin-only export refusing says it again from the server's
|
||||
# side: an issuer session that could still take the backup would be the two roles gone.
|
||||
check "demo switch to issuer" "$(curl -s -b "$D" "$B/app")" 'role\\*":\\*"Issuer'
|
||||
check "demo issuer can't take the backup" "$(curl -s -b "$D" -o /dev/null -w '%{http_code}' "$B/api/backup")" '^403$'
|
||||
check "demo reset needs token" "$(curl -s -X POST "$B/api/auth/demo/reset")" 'Forbidden'
|
||||
check "demo reset with token" "$(curl -s -X POST "$B/api/auth/demo/reset" -H 'x-demo-token: localtest')" '"ok":true'
|
||||
curl -s -o /dev/null -c "$D" "$B/api/auth/demo?as=admin"
|
||||
# The coordinator this used to read is one of the fields the demo strips, so it said "Alex Demo"
|
||||
# whether resetDemo ran or not. defaultReorder was dirtied to 7 above and is not stripped, so its
|
||||
# seeded default coming back is the rebuild.
|
||||
check "demo settings reverted after reset" "$(curl -s -b "$D" "$B/api/backup")" '"defaultReorder": *3'
|
||||
|
||||
echo "== real facility: users"
|
||||
TS=$(date +%s); EMAIL="v2e2e$TS@example.com"
|
||||
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\":\"Test\",\"last\":\"Admin\",\"facility\":\"V2 Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "me.profile" "$(mut me.profile '{"first":"Tess","last":"Admin","title":"Coordinator"}')" '"ok":true'
|
||||
check "me.profile blank rejected" "$(mut me.profile '{"first":" "}')" 'required'
|
||||
check "profile stamped in snapshot" "$(curl -s -b "$J" "$B/app/settings")" 'Tess'
|
||||
R=$(mut users.add "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\",\"first\":\"Iss\",\"last\":\"Uer\",\"role\":\"ISSUER\"}"); check "add issuer" "$R" '"id"'; ISS=$(echo "$R" | jget '["result"]["id"]')
|
||||
R=$(mut users.add "{\"email\":\"adm$TS@example.com\",\"password\":\"password123\",\"first\":\"Second\",\"last\":\"Admin\",\"role\":\"ADMIN\"}"); ADM2=$(echo "$R" | jget '["result"]["id"]')
|
||||
# Both of the checks that turn on "this is me" need the coordinator's own id, and the snapshot on
|
||||
# the settings page is where it is written down.
|
||||
ME=$(curl -s -b "$J" "$B/app/settings" | grep -o 'userId\\*":\\*"[A-Za-z0-9_-]*' | head -1 | sed 's/.*"//')
|
||||
check "the signed-in admin's own id was found" "$ME" '^[A-Za-z0-9_-]\{8,\}$'
|
||||
# The id sent here used to be the facility's, which is no user at all: users.remove threw "Unknown
|
||||
# user" long before it looked at whose account it was, and the check took that for a pass. The
|
||||
# guard could have been deleted and nothing would have gone red.
|
||||
check "remove self refused" "$(mut users.remove "{\"id\":\"$ME\"}")" "can't remove yourself"
|
||||
check "soft remove issuer" "$(mut users.remove "{\"id\":\"$ISS\"}")" '"ok":true'
|
||||
check "deactivated login refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" 'deactivated'
|
||||
check "deactivated listed in snapshot" "$(curl -s -b "$J" "$B/app/settings")" 'inactive[^a-z]*true'
|
||||
check "reactivate" "$(mut users.update "{\"id\":\"$ISS\",\"inactive\":false}")" '"ok":true'
|
||||
check "reactivated login works" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "remove second admin ok" "$(mut users.remove "{\"id\":\"$ADM2\"}")" '"ok":true'
|
||||
# The guard counts admins who are still active, so demoting one who has already been removed is
|
||||
# allowed and should be: the facility still has a live admin. This is the pass side of the clause
|
||||
# the next check trips, and it is all this line ever tested — it used to carry the other one's name.
|
||||
check "demote an already-removed admin allowed" "$(mut users.update "{\"id\":\"$ADM2\",\"role\":\"ISSUER\"}")" '"ok":true'
|
||||
# With that done the signed-in coordinator is the only active admin left, so the facility is one
|
||||
# demotion away from having nobody who can reach settings, users or the backup. Demoting yourself
|
||||
# out of the role is the only way to get there, and it has to be refused.
|
||||
check "demote last active admin refused" "$(mut users.update "{\"id\":\"$ME\",\"role\":\"ISSUER\"}")" 'Keep at least one active admin'
|
||||
# The export is admin-only, so it still answering is the account saying it kept the role: a guard
|
||||
# that threw after writing would leave the coordinator locked out with the refusal on screen.
|
||||
check "and they are still an admin afterwards" "$(curl -s -b "$J" -o /dev/null -w '%{http_code}' "$B/api/backup")" '^200$'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
+206
@@ -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 ]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,88 @@
|
||||
/* A stand-in for the BoxyHQ Jackson broker, for scripts/e2e-sso.sh.
|
||||
*
|
||||
* node scripts/mock-jackson.cjs [port] (default 3199)
|
||||
*
|
||||
* Speaks just enough of Jackson's two surfaces for the product to complete a login:
|
||||
* management POST/GET/DELETE /api/v1/sso (Api-Key checked; connections kept in memory)
|
||||
* front door GET /api/oauth/authorize → 302 straight back to redirect_uri with ?code&state
|
||||
* POST /api/oauth/token → { access_token }
|
||||
* GET /api/oauth/userinfo → the profile for that token
|
||||
* Who "signs in" is whatever the test last told it: POST /__mock/identity {email,name}. The code
|
||||
* and token are single-use and bound to that identity, so a stale code fails as it would for real.
|
||||
* Nothing here is a broker; it exists so the product's own routes can be exercised end to end. */
|
||||
const http = require("http");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const PORT = Number(process.argv[2] || 3199);
|
||||
const API_KEY = process.env.MOCK_JACKSON_KEY || "e2e-jackson-key";
|
||||
const conns = new Map(); // tenant → { tenant, product, name, idpMetadata }
|
||||
const codes = new Map(); // code → identity
|
||||
const tokens = new Map(); // token → identity
|
||||
let identity = { email: "", name: "" };
|
||||
|
||||
function send(res, status, body, headers = {}) {
|
||||
res.writeHead(status, { "content-type": "application/json", ...headers });
|
||||
res.end(body === undefined ? "" : JSON.stringify(body));
|
||||
}
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => resolve(d)); });
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
const p = url.pathname;
|
||||
|
||||
if (p === "/__mock/identity" && req.method === "POST") {
|
||||
identity = JSON.parse((await readBody(req)) || "{}");
|
||||
return send(res, 200, { ok: true, identity });
|
||||
}
|
||||
if (p === "/__mock/connections") return send(res, 200, [...conns.values()]);
|
||||
|
||||
if (p === "/api/v1/sso") {
|
||||
if (req.headers.authorization !== `Api-Key ${API_KEY}`) return send(res, 401, { error: { message: "Unauthorized" } });
|
||||
if (req.method === "POST") {
|
||||
const form = new URLSearchParams(await readBody(req));
|
||||
const tenant = form.get("tenant"), product = form.get("product");
|
||||
if (!tenant || !product) return send(res, 400, { error: { message: "tenant and product required" } });
|
||||
if (!form.get("metadataUrl") && !form.get("encodedRawMetadata")) return send(res, 400, { error: { message: "Please provide metadata" } });
|
||||
if (form.get("encodedRawMetadata") && !Buffer.from(form.get("encodedRawMetadata"), "base64").toString().includes("EntityDescriptor")) return send(res, 400, { error: { message: "Invalid IdP metadata" } });
|
||||
const c = { tenant, product, name: form.get("name"), idpMetadata: { entityID: "https://idp.example/saml", provider: "idp.example" }, redirectUrl: JSON.parse(form.get("redirectUrl") || "[]") };
|
||||
conns.set(tenant, c);
|
||||
return send(res, 200, c);
|
||||
}
|
||||
const tenant = url.searchParams.get("tenant");
|
||||
if (req.method === "GET") return send(res, 200, conns.has(tenant) ? [conns.get(tenant)] : []);
|
||||
if (req.method === "DELETE") { const had = conns.delete(tenant); return send(res, had ? 200 : 404, had ? { ok: true } : { error: { message: "not found" } }); }
|
||||
}
|
||||
|
||||
if (p === "/api/oauth/authorize") {
|
||||
const tenant = url.searchParams.get("tenant");
|
||||
const redirect = url.searchParams.get("redirect_uri"), state = url.searchParams.get("state");
|
||||
const c = conns.get(tenant);
|
||||
if (!c) return send(res, 404, { error: "no connection for tenant" });
|
||||
if (!c.redirectUrl.includes(redirect)) return send(res, 400, { error: "redirect_uri not registered" });
|
||||
const code = crypto.randomBytes(16).toString("hex");
|
||||
codes.set(code, { ...identity });
|
||||
// The product registers its PUBLIC callback (NEXT_PUBLIC_SITE_URL); the browser in these tests
|
||||
// is talking to the local dev server, so the mock sends it back there on the registered path.
|
||||
const back = (process.env.MOCK_CALLBACK_BASE || "http://127.0.0.1:3111") + new URL(redirect).pathname;
|
||||
res.writeHead(302, { location: `${back}?code=${code}&state=${encodeURIComponent(state || "")}` });
|
||||
return res.end();
|
||||
}
|
||||
if (p === "/api/oauth/token" && req.method === "POST") {
|
||||
const form = new URLSearchParams(await readBody(req));
|
||||
const id = codes.get(form.get("code"));
|
||||
codes.delete(form.get("code"));
|
||||
if (!id) return send(res, 400, { error: "invalid_grant" });
|
||||
const t = crypto.randomBytes(16).toString("hex");
|
||||
tokens.set(t, id);
|
||||
return send(res, 200, { access_token: t, token_type: "bearer" });
|
||||
}
|
||||
if (p === "/api/oauth/userinfo") {
|
||||
const t = (req.headers.authorization || "").replace(/^Bearer /, "");
|
||||
const id = tokens.get(t);
|
||||
if (!id) return send(res, 401, { error: "invalid_token" });
|
||||
return send(res, 200, { email: id.email, name: id.name, id: id.email });
|
||||
}
|
||||
send(res, 404, { error: "not found" });
|
||||
}).listen(PORT, "127.0.0.1", () => console.log("mock jackson on", PORT));
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Prove the reveal role can read exactly four columns of one table, and nothing else.
|
||||
*
|
||||
* node scripts/ops-reveal-probe.cjs
|
||||
*
|
||||
* Connects as ops_reveal (OPS_REVEAL_DATABASE_URL). The role exists so that revealing a
|
||||
* coordinator's contacts is a narrower door with its own key rather than an exception in code:
|
||||
* lib/ops/reveal.ts selects id + coordinator + coordinatorEmail + coordinatorPhone from Facility
|
||||
* and may never select more. This probe is the fact behind that sentence — it must succeed on
|
||||
* those four and be refused on every other column and every other table, including the ones
|
||||
* ops_ro can count.
|
||||
*
|
||||
* Like ops-ro-probe.cjs it exits 2 (skipped) on the local PGlite server, which ignores roles.
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const { Client } = require("pg");
|
||||
|
||||
const url = process.env.OPS_REVEAL_DATABASE_URL;
|
||||
if (!url) { console.error("OPS_REVEAL_DATABASE_URL must be set"); process.exit(1); }
|
||||
|
||||
const MUST_SUCCEED = [
|
||||
['read the four contact columns', 'SELECT "id", "coordinator", "coordinatorEmail", "coordinatorPhone" FROM "Facility" LIMIT 1'],
|
||||
];
|
||||
const MUST_BE_REFUSED = [
|
||||
["a facility's name", 'SELECT "name" FROM "Facility" LIMIT 1'],
|
||||
["a facility's logo", 'SELECT "logoData" FROM "Facility" LIMIT 1'],
|
||||
["a facility's whole row", 'SELECT * FROM "Facility" LIMIT 1'],
|
||||
["counting staff", 'SELECT count(*) FROM "Staff"'],
|
||||
["a wearer's name", 'SELECT "first" FROM "Staff" LIMIT 1'],
|
||||
["a coordinator's email", 'SELECT "email" FROM "User" LIMIT 1'],
|
||||
["a password hash", 'SELECT "passwordHash" FROM "User" LIMIT 1'],
|
||||
["a photo", 'SELECT "data" FROM "Photo" LIMIT 1'],
|
||||
["a request's reason", 'SELECT "reason" FROM "Request" LIMIT 1'],
|
||||
["an audit event", 'SELECT "op" FROM "AuditEvent" LIMIT 1'],
|
||||
["an operator row", 'SELECT "email" FROM "Operator" LIMIT 1'],
|
||||
["a reveal grant", 'SELECT "reason" FROM "RevealGrant" LIMIT 1'],
|
||||
["the migration table", 'SELECT count(*) FROM "_prisma_migrations"'],
|
||||
["writing anything", 'UPDATE "Facility" SET "coordinator" = "coordinator" WHERE false'],
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const c = new Client({ connectionString: url });
|
||||
await c.connect();
|
||||
const who = await c.query("SELECT session_user, (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS super");
|
||||
if (who.rows[0].session_user !== "ops_reveal" || who.rows[0].super) {
|
||||
console.log(`ops-reveal-probe: SKIPPED — connected as ${who.rows[0].session_user}${who.rows[0].super ? " (superuser)" : ""}, not ops_reveal.`);
|
||||
console.log(" This server does not enforce roles (PGlite runs everything as postgres). Run on production.");
|
||||
await c.end();
|
||||
process.exit(2);
|
||||
}
|
||||
let pass = 0, fail = 0;
|
||||
for (const [name, sql] of MUST_SUCCEED) {
|
||||
try { await c.query(sql); pass++; console.log(" ✓ can " + name); }
|
||||
catch (e) { fail++; console.log(" ✗ cannot " + name + " :: " + e.message); }
|
||||
}
|
||||
for (const [name, sql] of MUST_BE_REFUSED) {
|
||||
try { await c.query(sql); fail++; console.log(" ✗ CAN READ " + name + " — the reveal role is wider than four columns"); }
|
||||
catch (e) {
|
||||
if (/permission denied/i.test(e.message)) { pass++; console.log(" ✓ refused " + name); }
|
||||
else { fail++; console.log(" ✗ " + name + " failed for the wrong reason :: " + e.message); }
|
||||
}
|
||||
}
|
||||
await c.end();
|
||||
console.log(`ops-reveal-probe: PASS=${pass} FAIL=${fail}`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
})().catch((e) => { console.error("ops-reveal-probe: could not connect as ops_reveal :: " + e.message); process.exit(1); });
|
||||
@@ -0,0 +1,78 @@
|
||||
/* Prove the operations console's database role cannot read customer content.
|
||||
*
|
||||
* node scripts/ops-ro-probe.cjs
|
||||
*
|
||||
* Connects as ops_ro (OPS_DATABASE_URL) and asks the database two questions:
|
||||
* - can it COUNT the staff register? must succeed — the console shows sizes
|
||||
* - can it READ a name from the register? must be refused — the console never shows one
|
||||
* plus the same pair for a coordinator's email and a facility's contact email.
|
||||
*
|
||||
* This is the check that makes "ops_ro is read-only and content-blind" a fact about the database
|
||||
* rather than a claim in a comment. It is run by scripts/e2e-ops.sh, and it is the right thing to
|
||||
* run by hand after any grant change. Exit 0 only when every refusal is refused and every count
|
||||
* counts.
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const { Client } = require("pg");
|
||||
|
||||
const url = process.env.OPS_DATABASE_URL;
|
||||
if (!url) { console.error("OPS_DATABASE_URL must be set"); process.exit(1); }
|
||||
|
||||
const MUST_SUCCEED = [
|
||||
['count staff', 'SELECT count(*) FROM "Staff"'],
|
||||
['count active staff', 'SELECT count(*) FROM "Staff" WHERE "inactive" = false'],
|
||||
['count issues', 'SELECT count(*) FROM "Issue"'],
|
||||
['read facility names', 'SELECT "name", "rev", "lastBackup" FROM "Facility" LIMIT 1'],
|
||||
['read the plan scaffold', 'SELECT "plan", "planNote" FROM "Facility" LIMIT 1'],
|
||||
['count users with 2FA', 'SELECT count(*) FROM "User" WHERE "totpEnabledAt" IS NOT NULL'],
|
||||
['read audit op names', 'SELECT "op", "at" FROM "AuditEvent" LIMIT 1'],
|
||||
['read applied migrations', 'SELECT count(*) FROM "_prisma_migrations"'],
|
||||
];
|
||||
const MUST_BE_REFUSED = [
|
||||
["a wearer's name", 'SELECT "first" FROM "Staff" LIMIT 1'],
|
||||
["a wearer's phone", 'SELECT "phone" FROM "Staff" LIMIT 1'],
|
||||
["a coordinator's email", 'SELECT "email" FROM "User" LIMIT 1'],
|
||||
["a password hash", 'SELECT "passwordHash" FROM "User" LIMIT 1'],
|
||||
["a facility's contact", 'SELECT "coordinatorEmail" FROM "Facility" LIMIT 1'],
|
||||
["a facility's logo", 'SELECT "logoData" FROM "Facility" LIMIT 1'],
|
||||
["a photo", 'SELECT "data" FROM "Photo" LIMIT 1'],
|
||||
["a request's reason", 'SELECT "reason" FROM "Request" LIMIT 1'],
|
||||
["a request message", 'SELECT "body" FROM "RequestMessage" LIMIT 1'],
|
||||
["an audit event's actor", 'SELECT "userName" FROM "AuditEvent" LIMIT 1'],
|
||||
["a staff account's email", 'SELECT "email" FROM "StaffAccount" LIMIT 1'],
|
||||
["an operator row", 'SELECT "email" FROM "Operator" LIMIT 1'],
|
||||
["writing anything", 'UPDATE "Facility" SET "rev" = "rev" WHERE false'],
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const c = new Client({ connectionString: url });
|
||||
await c.connect();
|
||||
// The local `prisma dev` server is PGlite — Postgres compiled to WebAssembly — and it runs every
|
||||
// connection as its one superuser whatever username the URL names. Every grant is meaningless
|
||||
// there, and a green run would be a lie. So: if the session is not actually ops_ro, this is not
|
||||
// an environment that can answer the question. Exit 2, distinct from a failure, and say so.
|
||||
// The real answer comes from running this on the box, against real Postgres, after the grants
|
||||
// migration has deployed.
|
||||
const who = await c.query("SELECT session_user, (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS super");
|
||||
if (who.rows[0].session_user !== "ops_ro" || who.rows[0].super) {
|
||||
console.log(`ops-ro-probe: SKIPPED — connected as ${who.rows[0].session_user}${who.rows[0].super ? " (superuser)" : ""}, not ops_ro.`);
|
||||
console.log(" This server does not enforce roles (PGlite runs everything as postgres). Run on production.");
|
||||
await c.end();
|
||||
process.exit(2);
|
||||
}
|
||||
let pass = 0, fail = 0;
|
||||
for (const [name, sql] of MUST_SUCCEED) {
|
||||
try { await c.query(sql); pass++; console.log(" ✓ can " + name); }
|
||||
catch (e) { fail++; console.log(" ✗ cannot " + name + " :: " + e.message); }
|
||||
}
|
||||
for (const [name, sql] of MUST_BE_REFUSED) {
|
||||
try { await c.query(sql); fail++; console.log(" ✗ CAN READ " + name + " — the role is not content-blind"); }
|
||||
catch (e) {
|
||||
if (/permission denied/i.test(e.message)) { pass++; console.log(" ✓ refused " + name); }
|
||||
else { fail++; console.log(" ✗ " + name + " failed for the wrong reason :: " + e.message); }
|
||||
}
|
||||
}
|
||||
await c.end();
|
||||
console.log(`ops-ro-probe: PASS=${pass} FAIL=${fail}`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
})().catch((e) => { console.error("ops-ro-probe: could not connect as ops_ro :: " + e.message); process.exit(1); });
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Who does the database think the console's read role is?
|
||||
*
|
||||
* node scripts/ops-ro-whoami.cjs
|
||||
*
|
||||
* The ops_ro grants are column allow-lists, and the probe (scripts/ops-ro-probe.cjs) checks them
|
||||
* by asking for things the role must not see. If every refusal comes back readable, the first
|
||||
* suspect is not the grants but the server: an embedded development Postgres may accept any
|
||||
* username and run everything as its one superuser, which makes every grant meaningless THERE
|
||||
* and says nothing about production. This prints enough to tell the two apart.
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const { Client } = require("pg");
|
||||
(async () => {
|
||||
const c = new Client({ connectionString: process.env.OPS_DATABASE_URL });
|
||||
await c.connect();
|
||||
const r = await c.query(
|
||||
"SELECT current_user, session_user, (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS superuser, " +
|
||||
"(SELECT rolsuper FROM pg_roles WHERE rolname = 'ops_ro') AS ops_ro_is_super, version() AS version"
|
||||
);
|
||||
console.log(r.rows[0]);
|
||||
await c.end();
|
||||
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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
|
||||
@@ -0,0 +1,88 @@
|
||||
/* The notice to existing rooms that plans are coming — docs/launch/plans-notice.md is the text.
|
||||
*
|
||||
* node scripts/send-plans-notice.cjs # dry run: who would get it, and the date
|
||||
* node scripts/send-plans-notice.cjs --send # send, go-live 60 days from today
|
||||
* node scripts/send-plans-notice.cjs --send --date 2026-11-14
|
||||
*
|
||||
* Runs on the production box with its .env (SMTP_* and DATABASE_URL). Goes to every active ADMIN
|
||||
* of every non-demo facility that is grandfathered — which, before plans are live, is every
|
||||
* facility. Refuses without --send. After a real send it appends a line to each facility's plan
|
||||
* note so the console shows who was told and when. One mail per administrator, plain text, with
|
||||
* replies going to hello@threadcount.tech. The date must be at least sixty days out: that is the
|
||||
* notice period the Terms promise.
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const nodemailer = require("nodemailer");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const SEND = args.includes("--send");
|
||||
const dateArg = args[args.indexOf("--date") + 1];
|
||||
const DAY = 86_400_000;
|
||||
const MIN_NOTICE_DAYS = 60;
|
||||
|
||||
const goLive = args.includes("--date") ? new Date(dateArg + "T00:00:00+10:00") : new Date(Date.now() + MIN_NOTICE_DAYS * DAY);
|
||||
if (Number.isNaN(goLive.getTime())) { console.error("--date must be YYYY-MM-DD"); process.exit(1); }
|
||||
if (goLive.getTime() - Date.now() < (MIN_NOTICE_DAYS - 1) * DAY) { console.error(`The go-live date must be at least ${MIN_NOTICE_DAYS} days out — the Terms promise sixty days' notice.`); process.exit(1); }
|
||||
const DATE = goLive.toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric", timeZone: "Australia/Brisbane" });
|
||||
const TODAY = new Date().toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric", timeZone: "Australia/Brisbane" });
|
||||
|
||||
function body(first, facility) {
|
||||
return `Hello ${first},
|
||||
|
||||
A note from the person who runs ThreadCount, sixty days ahead, because the pricing page said you would hear before the website did.
|
||||
|
||||
On ${DATE} ThreadCount will start charging new facilities for hosting. Nothing changes for ${facility}. You signed up while it was free, and it stays free for you — every feature, every report, both apps, no ceiling on staff records — for as long as your facility exists. There is nothing to do, nothing to sign, and nothing on your screens will look different on the day.
|
||||
|
||||
What is changing, for facilities created from ${DATE}:
|
||||
|
||||
- The software itself stays free. The code will be published so anyone can run it on their own server, with everything in it.
|
||||
- Hosting on threadcount.tech stays free for a room under 60 staff records.
|
||||
- A larger facility hosted on threadcount.tech will pay $1,290 a year (or $129 a month), which covers the servers, 35 days of backups, and a person who answers email the next business day.
|
||||
- Health services running several facilities will be able to buy them together, with one sign-in across sites.
|
||||
|
||||
The Terms of Service now carry a Fees section that writes the grandfathering down, so it does not depend on a promise in an email: https://threadcount.tech/terms
|
||||
|
||||
Why now: running it for other people costs money and time, and I would rather charge new rooms plainly than let the thing quietly stop being maintained. Charging the rooms that trusted it first was never on the table.
|
||||
|
||||
If you would prefer to pay anyway, or your health service wants the multi-site arrangement, reply to this email. Otherwise, carry on exactly as you are.
|
||||
|
||||
Kyle
|
||||
ThreadCount · hello@threadcount.tech
|
||||
`;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
|
||||
|
||||
(async () => {
|
||||
const facilities = await prisma.facility.findMany({
|
||||
where: { isDemo: false, grandfathered: true },
|
||||
select: { id: true, name: true, planNote: true, users: { where: { role: "ADMIN", inactive: false }, select: { email: true, first: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
console.log(`${SEND ? "Sending" : "Dry run"} · go-live ${DATE} · ${facilities.length} facilities`);
|
||||
for (const f of facilities) console.log(` ${f.name}: ${f.users.length} administrator${f.users.length === 1 ? "" : "s"}${f.planNote.includes("Plans notice sent") ? " · ALREADY NOTIFIED" : ""}`);
|
||||
if (!SEND) { console.log("\nNothing sent. Add --send to send."); await prisma.$disconnect(); return; }
|
||||
|
||||
if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) { console.error("SMTP is not configured in this environment."); process.exit(1); }
|
||||
const port = parseInt(process.env.SMTP_PORT || "587", 10);
|
||||
const t = nodemailer.createTransport({ host: process.env.SMTP_HOST, port, secure: port === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } });
|
||||
const subject = `ThreadCount is introducing plans on ${DATE}. Yours stays free.`;
|
||||
let sent = 0, failed = 0;
|
||||
for (const f of facilities) {
|
||||
if (f.planNote.includes("Plans notice sent")) { console.log(` skip ${f.name} — already notified`); continue; }
|
||||
for (const u of f.users) {
|
||||
try {
|
||||
await t.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: u.email, replyTo: "hello@threadcount.tech", subject, text: body(u.first || "there", f.name) });
|
||||
sent++;
|
||||
} catch (e) {
|
||||
failed++; console.error(` FAILED ${f.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
const line = `Plans notice sent ${TODAY}, go-live ${DATE}`;
|
||||
await prisma.facility.update({ where: { id: f.id }, data: { planNote: (f.planNote ? f.planNote + "\n" : "") + line } });
|
||||
}
|
||||
console.log(`\nSent ${sent}, failed ${failed}. Go-live ${DATE}: turn Switches › Plans on that day.`);
|
||||
await prisma.$disconnect();
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user