#!/usr/bin/env bash # CourtMitra — adversarial end-to-end loop smoke test. # Drives the full happy path with real Idempotency-Keys and probes the # consent/proof/idempotency gates. Prints a PASS/FAIL table. # # Usage: API=http://localhost:3001/api bash scripts/smoke-loop.sh # Requires: curl, jq. Needs API (3001) + worker (3002) + Inngest dev (8288) # running, and an LLM key for intake/route-plan steps. set -uo pipefail API="${API:-http://localhost:3001/api}" PASS=0; FAIL=0; SKIP=0 note() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; SKIP=$((SKIP+1)); } idk() { echo "smoke-$(date +%s)-$RANDOM"; } # req METHOD PATH [JSON_BODY] [IDEMPOTENCY_KEY] -> sets $STATUS and $BODY req() { local method="$1" path="$2" body="${3:-}" ikey="${4:-}" local args=(-s -w '\n%{http_code}' -X "$method" "$API$path" -H 'content-type: application/json') [ -n "$ikey" ] && args+=(-H "Idempotency-Key: $ikey") [ -n "$body" ] && args+=(-d "$body") local out; out="$(curl "${args[@]}")" STATUS="${out##*$'\n'}"; BODY="${out%$'\n'*}" } jqv() { echo "$BODY" | jq -r "$1" 2>/dev/null; } # --------------------------------------------------------------------------- note "Read endpoints" req GET /admin/health; [ "$STATUS" = 200 ] && ok "health 200" || bad "health $STATUS" req GET /capabilities; [ "$STATUS" = 200 ] && ok "capabilities 200" || bad "capabilities $STATUS" req GET /missions; [ "$STATUS" = 200 ] && ok "list missions 200" || bad "list missions $STATUS" req GET /ledger; [ "$STATUS" = 200 ] && ok "ledger 200" || bad "ledger $STATUS" req GET '/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=x&hub.challenge=42' [ "$STATUS" = 200 ] || [ "$STATUS" = 403 ] && ok "whatsapp verify reachable ($STATUS)" || bad "whatsapp verify $STATUS" LLM_BLOCKED=$(echo "$BODY" >/dev/null; req GET /capabilities; jqv '[.capabilities[]|select(.adapter|test("llm"))|select(.status=="blocked")]|length') # --------------------------------------------------------------------------- note "Create mission" req POST /missions '{"issueDomain":"consumer","title":"Smoke: defective phone refund refused","description":"Bought a phone for 25000 on 2026-04-01, screen dead in a week, seller refuses refund."}' "$(idk)" if [ "$STATUS" = 201 ] || [ "$STATUS" = 200 ]; then MID="$(jqv '.id')"; ok "create mission -> $MID"; else bad "create mission $STATUS: $BODY"; MID=""; fi if [ -z "$MID" ] || [ "$MID" = null ]; then note "No mission id — aborting loop"; printf '\nPASS=%d FAIL=%d SKIP=%d\n' "$PASS" "$FAIL" "$SKIP"; exit 1 fi req GET "/missions/$MID"; [ "$STATUS" = 200 ] && ok "get mission" || bad "get mission $STATUS" req GET "/missions/$MID/snapshot"; [ "$STATUS" = 200 ] && ok "snapshot" || bad "snapshot $STATUS" req GET "/missions/$MID/timeline"; [ "$STATUS" = 200 ] && ok "timeline" || bad "timeline $STATUS" note "Missing Idempotency-Key is rejected" req POST /missions '{"issueDomain":"consumer","title":"no idem key test"}' [ "$STATUS" = 400 ] || [ "$STATUS" = 422 ] && ok "create without Idempotency-Key rejected ($STATUS)" || skip "create without Idempotency-Key got $STATUS (expected 4xx)" # --------------------------------------------------------------------------- note "Confirm facts" req POST "/missions/$MID/facts/confirm" '{"facts":{"summary":"Defective phone, refund refused","amount":25000,"date_of_incident":"2026-04-01"}}' [ "$STATUS" = 200 ] || [ "$STATUS" = 201 ] && ok "confirm facts ($STATUS)" || bad "confirm facts $STATUS: $BODY" # --------------------------------------------------------------------------- note "Route plan (needs worker + LLM)" req POST "/missions/$MID/route-plan" '{}' "$(idk)" [ "$STATUS" = 202 ] || [ "$STATUS" = 200 ] && ok "trigger route plan ($STATUS)" || bad "trigger route plan $STATUS: $BODY" PLAN=""; for i in $(seq 1 20); do req GET "/missions/$MID/route-plan" if [ "$STATUS" = 200 ]; then PID="$(jqv '.id')"; if [ -n "$PID" ] && [ "$PID" != null ]; then PLAN="$PID"; break; fi; fi sleep 3 done if [ -n "$PLAN" ]; then ok "route plan ready -> $PLAN"; else skip "route plan not ready within 60s (LLM key/worker likely missing) — action+resolution steps skipped" fi # --------------------------------------------------------------------------- if [ -n "$PLAN" ]; then note "Action: preview -> consent -> execute" req POST "/missions/$MID/actions/preview" "{\"routePlanId\":\"$PLAN\",\"stepNo\":0,\"destination\":\"seller@example.com\",\"subject\":\"Refund demand\",\"body\":\"Please refund 25000 for the defective phone.\"}" "$(idk)" if [ "$STATUS" = 201 ] || [ "$STATUS" = 200 ]; then AID="$(jqv '.action.id')"; ok "preview -> $AID"; else bad "preview $STATUS: $BODY"; AID=""; fi if [ -n "$AID" ] && [ "$AID" != null ]; then note "Execute BEFORE consent must be refused" req POST "/actions/$AID/execute" RC="$(jqv '.code')" [ "$RC" = consent_not_found ] && ok "pre-consent execute refused (consent_not_found)" || bad "pre-consent execute not refused: $STATUS $BODY" req POST "/actions/$AID/consent" '{}' [ "$STATUS" = 200 ] && ok "grant consent" || bad "consent $STATUS: $BODY" req POST "/actions/$AID/execute" [ "$STATUS" = 202 ] || [ "$STATUS" = 200 ] && ok "execute accepted ($STATUS)" || bad "execute $STATUS: $BODY" # observe honest proof_state sleep 3; req GET "/actions/$AID"; PS="$(jqv '.proofState // .action.proofState')" ok "post-execute proof_state = ${PS:-} (honest reflection check)" note "Double execute must be refused (idempotency)" req POST "/actions/$AID/execute"; RC="$(jqv '.code')" if [ "$RC" = idempotency_key_used ] || [ "$RC" = action_already_executed ] || [ "$RC" = action_not_approved ]; then ok "double execute refused ($RC)" else bad "double execute NOT refused: $STATUS $BODY (DUPLICATE SEND RISK)"; fi fi fi # --------------------------------------------------------------------------- note "Reply ingestion" req POST "/missions/$MID/replies/ingest" '{"body":"We have received your complaint and will process the refund.","from":"seller@example.com","subject":"Re: Refund demand"}' "$(idk)" [ "$STATUS" = 202 ] || [ "$STATUS" = 200 ] && ok "ingest reply ($STATUS)" || bad "ingest reply $STATUS: $BODY" # --------------------------------------------------------------------------- note "Resolution" req POST "/missions/$MID/resolution/propose" '{"outcomeType":"refund_received","amountRecovered":25000,"userSatisfaction":5}' [ "$STATUS" = 200 ] && ok "resolution propose" || skip "resolution propose $STATUS: $BODY" req POST "/missions/$MID/resolution/confirm" '{"outcomeType":"refund_received","amountRecovered":25000,"userSatisfaction":5,"publishConsent":true,"anonymizedPublicSummary":"Consumer received full refund for a defective phone after a formal demand."}' "$(idk)" [ "$STATUS" = 201 ] || [ "$STATUS" = 200 ] && ok "resolution confirm ($STATUS)" || bad "resolution confirm $STATUS: $BODY" req GET /ledger; ok "ledger reachable post-resolution ($STATUS)" printf '\n\033[1mSUMMARY\033[0m PASS=%d FAIL=%d SKIP=%d (mission %s)\n' "$PASS" "$FAIL" "$SKIP" "$MID" [ "$FAIL" -eq 0 ]