← 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