← back to George Gmail
test/test-protected-survival.sh
119 lines
#!/usr/bin/env bash
# test-protected-survival.sh — negative test for the TK-11552 protected-draft
# protection in delete-old-drafts-info.js.
#
# CLAUDE.md TK-11431 amendment 3: a check ships with a negative test proving it goes RED
# on an injected fault, or it does not ship. A positive-only test on a detector confirms
# the happy path and leaves the entire purpose of the component unverified.
#
# CASE 6 is the one that matters: it breaks isExempt() on purpose and proves the
# keep-list-derived census disagrees with it and ABORTS BEFORE any delete. The first
# version of this fix built the census with isExempt() itself and would have passed
# every other case here while still permanently deleting a protected draft.
#
# Runs against an ISOLATED COPY of the drain + a mock George on loopback. Never reaches
# a real mailbox: GEORGE_BASE points at the mock and the copy lives in a temp dir.
set -u
REPO="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d /tmp/tk11552-survival-XXXXXX)"
PORT=9871
PASS=0; FAIL=0
ok(){ echo " PASS $1"; PASS=$((PASS+1)); }
no(){ echo " FAIL $1"; FAIL=$((FAIL+1)); }
mkdir -p "$WORK/data"
cp "$REPO/test/mock-george.js" "$WORK/mock-george.js"
cat > "$WORK/keep.json" <<'JSON'
{ "draft-protected": "fixture: must never be deleted at any age" }
JSON
cat > "$WORK/keep-stale.json" <<'JSON'
{ "msg-rotated-away-1a09ba5cc706157b": "fixture: id no longer resolves to any live draft" }
JSON
hb(){ python3 -c "import json,sys;d=json.load(open('$WORK/data/drain-old-drafts-latest.json'));print(d.get(sys.argv[1]))" "$1" 2>/dev/null; }
run_case(){ # name keepfile sabotage_after ndrafts break_isexempt
local name="$1" keep="$2" sab="$3" nd="$4" brk="${5:-0}"
rm -f "$WORK/data/drain-old-drafts-latest.json"
cp "$REPO/delete-old-drafts-info.js" "$WORK/drain-under-test.js"
if [ "$brk" = "1" ]; then
# INJECTED FAULT: isExempt stops recognizing one keep-listed draft (the realistic
# partial false-negative — "8 of 11 recognized"). The keep-list still protects it,
# so the two paths must disagree and the run must stop before deleting.
perl -0pi -e "s/^const isExempt = .*\$/const isExempt = (d) => !!d && d.id !== 'draft-protected' && (KEEP_IDS.has(d.message && d.message.id) || KEEP_IDS.has(d.id));/m" "$WORK/drain-under-test.js"
grep -q "d.id !== 'draft-protected'" "$WORK/drain-under-test.js" || { no "$name: fault injection did not apply — case would measure nothing"; return 1; }
fi
MOCK_PORT=$PORT SABOTAGE_AFTER_LIST="$sab" SABOTAGE_ID=draft-protected MOCK_NDRAFTS="$nd" \
node "$WORK/mock-george.js" >/dev/null 2>&1 &
local mp=$!
# NB: probe a route that does NOT increment the mock's listing counter, or the probe
# itself consumes list #1 and every sabotage offset is off by one.
for _ in 1 2 3 4 5 6 7 8 9 10; do curl -fs "http://127.0.0.1:$PORT/api/messages" >/dev/null 2>&1 && break; sleep 0.3; done
CONFIRM=1 GEORGE_BASE="http://127.0.0.1:$PORT" DRAIN_KEEP_LIST="$keep" \
DEL_SLEEP=0 PAGE_SLEEP=0 GEORGE_AUTH="admin:x" \
node "$WORK/drain-under-test.js" > "$WORK/$name.out" 2>&1
RC=$?
kill $mp 2>/dev/null; wait $mp 2>/dev/null
# Guard the harness itself: rc=127 or a missing heartbeat means the case never ran,
# which must not read as a pass — a crashing process prints nothing, and "no rows
# matched" looks exactly like "clean run".
if [ "$RC" = "127" ]; then no "$name: runner exited 127 — case never executed"; return 1; fi
if [ ! -s "$WORK/data/drain-old-drafts-latest.json" ]; then no "$name: no heartbeat — case never executed"; sed -n '1,15p' "$WORK/$name.out"; return 1; fi
return 0
}
echo "== CASE 1 (positive): protected draft survives a normal drain =="
if run_case c1 "$WORK/keep.json" 0 0; then
[ "$(hb verdict)" = "PASS" ] && ok "verdict PASS" || no "verdict=$(hb verdict) want PASS"
[ "$(hb protected_verified)" = "True" ] && ok "protected_verified true" || no "protected_verified=$(hb protected_verified) want True"
[ "$(hb protected_before)" = "1" ] && ok "censused 1 live protected draft" || no "protected_before=$(hb protected_before) want 1"
[ "$(hb deleted)" = "1" ] && ok "deleted the 1 unprotected old draft" || no "deleted=$(hb deleted) want 1"
[ "$(hb protected_lost_count)" = "0" ] && ok "lost 0" || no "protected_lost_count=$(hb protected_lost_count) want 0"
fi
echo "== CASE 2 (NEGATIVE): protected draft disappears mid-drain -> must ABORT red =="
if run_case c2 "$WORK/keep.json" 1 0; then
[ "$RC" = "1" ] && ok "exited 1 (aborted)" || no "rc=$RC want 1"
[ "$(hb verdict)" = "FAIL" ] && ok "verdict FAIL" || no "verdict=$(hb verdict) want FAIL"
[ "$(hb protected_verified)" = "False" ] && ok "protected_verified false" || no "protected_verified=$(hb protected_verified) want False"
grep -q "draft-protected" <<<"$(hb protected_lost_ids)" && ok "names the lost draft" || no "protected_lost_ids=$(hb protected_lost_ids)"
fi
echo "== CASE 3 (NEGATIVE): protected draft disappears AFTER the loop -> final assertion red =="
if run_case c3 "$WORK/keep.json" 3 0; then
[ "$RC" = "0" ] && ok "ran to completion (final-assertion path, not the mid-drain abort)" || no "rc=$RC want 0"
[ "$(hb verdict)" = "FAIL" ] && ok "verdict FAIL from the final re-read" || no "verdict=$(hb verdict) want FAIL"
[ "$(hb protected_verified)" = "False" ] && ok "protected_verified false" || no "protected_verified=$(hb protected_verified) want False"
fi
echo "== CASE 4 (NEGATIVE): keep-list ids resolve to nothing live -> not-measured, never clean PASS =="
if run_case c4 "$WORK/keep-stale.json" 0 0; then
[ "$(hb verdict)" = "WARN" ] && ok "verdict WARN not PASS" || no "verdict=$(hb verdict) want WARN"
[ "$(hb protected_before)" = "0" ] && ok "censused 0 live protected" || no "protected_before=$(hb protected_before)"
[ "$(hb protected_verified)" = "None" ] && ok "protected_verified null (not-measured)" || no "protected_verified=$(hb protected_verified) want None"
[ "$(hb keep_list_unresolved_count)" = "1" ] && ok "reports the unresolved keep-list id" || no "keep_list_unresolved_count=$(hb keep_list_unresolved_count) want 1"
fi
echo "== CASE 5 (NEGATIVE): listing truncated at 500 while a keep-list is in force -> refuse to delete blind =="
if run_case c5 "$WORK/keep.json" 0 600; then
[ "$RC" = "1" ] && ok "exited 1 (aborted before any delete)" || no "rc=$RC want 1"
[ "$(hb verdict)" = "FAIL" ] && ok "verdict FAIL" || no "verdict=$(hb verdict) want FAIL"
[ "$(hb census_truncated)" = "True" ] && ok "census_truncated true" || no "census_truncated=$(hb census_truncated) want True"
[ "$(hb deleted)" = "0" ] && ok "deleted nothing" || no "deleted=$(hb deleted) want 0"
fi
echo "== CASE 6 (NEGATIVE, the headline): isExempt() false-negatives a keep-listed draft =="
echo " -> the keep-list-derived census must DISAGREE and abort BEFORE the delete."
echo " -> this is the case the first version of the fix silently passed while deleting."
if run_case c6 "$WORK/keep.json" 0 0 1; then
[ "$RC" = "1" ] && ok "exited 1 (aborted)" || no "rc=$RC want 1"
[ "$(hb verdict)" = "FAIL" ] && ok "verdict FAIL" || no "verdict=$(hb verdict) want FAIL"
[ "$(hb contradiction_count)" = "1" ] && ok "contradiction detected" || no "contradiction_count=$(hb contradiction_count) want 1"
grep -q "draft-protected" <<<"$(hb contradiction_ids)" && ok "names the draft isExempt mis-classified" || no "contradiction_ids=$(hb contradiction_ids)"
[ "$(hb deleted)" = "0" ] && ok "LOSS PREVENTED — deleted 0, not detected-after-the-fact" || no "deleted=$(hb deleted) want 0"
fi
echo
echo "TOTAL: $PASS passed, $FAIL failed (workdir $WORK)"
[ "$FAIL" = "0" ] || exit 1