#!/bin/bash # End-to-end decision test for sf-deploy-gate (issue #259). # # Stubs `sf` on PATH so the full prod-check / destructive path runs offline — # no live org. Asserts the hook's JSON decision (allow vs. deny) for each org # bucket, proving the #259 fix end-to-end: a trial org must ALLOW, not block. # # Run: bash plugins/sfdx-deploy/test/gate-decision.test.sh set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" GATE="$ROOT/sf-deploy-gate" PASS=0 FAIL=0 # Build a temp dir with a fake `sf` whose `org display` / `config get` output we # control via env vars, then put it first on PATH. STUB_DIR=$(mktemp -d) trap 'rm -rf "$STUB_DIR"' EXIT cat > "$STUB_DIR/sf" <<'STUB' #!/bin/bash # Minimal sf stub: only the two subcommands the gate calls. case "$*" in "config get target-org --json") printf '{"result":[{"name":"target-org","value":"%s"}]}' "${STUB_DEFAULT_ORG:-stuborg}" ;; "org display --target-org "*" --json"|"org display "*"--json") printf '%s' "${STUB_ORG_JSON:-{\}}" ;; *) printf '{}' ;; esac STUB chmod +x "$STUB_DIR/sf" export PATH="$STUB_DIR:$PATH" # decision decision() { local expected="$1" desc="$2" cmd="$3" org_json="$4" export STUB_ORG_JSON="$org_json" local out got out=$(printf '{"tool_input":{"command":"%s"}}' "$cmd" | "$GATE" prod-check) got=$(printf '%s' "$out" | python3 -c " import json,sys d=json.load(sys.stdin) print('deny' if d.get('hookSpecificOutput',{}).get('permissionDecision')=='deny' else 'allow') ") if [ "$got" = "$expected" ]; then PASS=$((PASS + 1)); printf ' ok %-46s → %s\n' "$desc" "$got" else FAIL=$((FAIL + 1)); printf ' FAIL %-46s → got "%s", expected "%s"\n' "$desc" "$got" "$expected" printf ' raw: %s\n' "$out" fi } echo "sf-deploy-gate prod-check — end-to-end decision (stubbed sf)" # The #259 case: OrgFarm trial must ALLOW (was wrongly denied as production). decision allow "trial OrgFarm → allow" \ "sf project deploy start --source-dir force-app --target-org mytrial" \ '{"result":{"isSandbox":null,"isScratch":null,"instanceUrl":"https://orgfarm-x.develop.my.salesforce.com"}}' decision allow "dry-run against trial → allow" \ "sf project deploy start --dry-run -o mytrial" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://my.develop.my.salesforce.com"}}' decision allow "sandbox → allow" \ "sf project deploy start --target-org sbx" \ '{"result":{"isSandbox":true,"instanceUrl":"https://acme--dev.sandbox.my.salesforce.com"}}' decision allow "scratch → allow" \ "sf project deploy start --target-org scr" \ '{"result":{"isSandbox":false,"isScratch":true,"instanceUrl":"https://x.scratch.my.salesforce.com"}}' # Genuine production still blocks. decision deny "production → deny" \ "sf project deploy start --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # Production WITH explicit confirmation flag → allow (override path preserved). decision allow "production + CONFIRM_PROD=1 → allow" \ "CONFIRM_PROD=1 sf project deploy start --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # --- Destructive-changes deploys (#407) ------------------------------------- # A destructiveChanges manifest reaches the gate as an ordinary deploy, but has # the blast radius of `sf project delete` and must be gated like one on prod. decision deny "destructive manifest → prod → deny" \ "sf project deploy start --manifest destructiveChanges.xml --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # CONFIRM_PROD does NOT override a destructive prod deploy — it routes to the skill. decision deny "destructive + CONFIRM_PROD=1 → prod → deny" \ "CONFIRM_PROD=1 sf project deploy start --post-destructive-changes destructiveChanges.xml -o prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # Non-prod destructive deploys are advised, not blocked. decision allow "destructive manifest → sandbox → allow" \ "sf project deploy start --pre-destructive-changes destructiveChanges.xml --target-org sbx" \ '{"result":{"isSandbox":true,"instanceUrl":"https://acme--dev.sandbox.my.salesforce.com"}}' # Ordinary (non-destructive) prod deploy still uses the standard confirmation path. decision deny "ordinary deploy → prod → deny (unchanged)" \ "sf project deploy start --source-dir force-app --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # --- Command self-gate ------------------------------------------------------ # Some Claude Code builds ignore the plugin.json `if:` matcher and fire every # PreToolUse Bash hook on every command. A NON-deploy command must ALLOW without # classifying — even against a production org (the gate never even reads it). decision allow "non-deploy command → allow (self-gated, prod org ignored)" \ "cd /tmp && grep -r foo ." \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # #1030 review: a merely QUOTED mention of the deploy command in an unrelated # command must ALLOW without classifying — the regex self-gate wrongly matched the # quoted substring and denied the grep against a prod org. Quote/position-aware now. decision allow "quoted deploy mention → allow (not gated)" \ 'grep -r \"sf project deploy start\" .' \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' decision allow "deploy string in echo → allow (not gated)" \ 'echo \"remember: sf project deploy start\"' \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # Sub-command scope: a check-only `validate` is NOT a prod-mutating deploy, so it # must ALLOW (self-gated off) even against a prod org — the gate is scoped to # start/quick, matching plugin.json. (A bare-substring self-gate wrongly denied it.) decision allow "validate against prod → allow (not gated)" \ "sf project deploy validate --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # Whitespace-flexible self-gate: `sf` accepts arbitrary spacing, so an unusual-but- # valid multi-space deploy must still be GATED — a single-space substring would # miss it and fail OPEN on the production gate. decision deny "multi-space deploy start → prod → deny" \ "sf project deploy start --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # #1030 review (thread 4): a prod deploy wrapped by a shell builtin or a control # structure still EXECUTES the deploy, so the command-position matcher must unwrap # `command`/`time`/`then …` etc. and still reach the production gate. decision deny "command-wrapped deploy → prod → deny" \ "command sf project deploy start --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' decision deny "control-structure-wrapped deploy → prod → deny" \ "if true; then sf project deploy start --target-org prod; fi" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # #1030 review (follow-up): a deploy used AS the control CONDITION (segment leads # with `if`/`while`/`until`, not `then`) still executes — must still reach the gate. decision deny "deploy as if-condition → prod → deny" \ "if sf project deploy start --target-org prod; then :; fi" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' decision deny "deploy as while-condition → prod → deny" \ "while sf project deploy start --target-org prod; do :; done" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # #1030 adversarial review (finding 1): shlex defaulted to commenters='#', so a bare # '#' truncated the line and dropped a trailing '&& sf project deploy start' — the # deploy then slipped the gate ungated. Must still DENY against prod now. decision deny "deploy after '#' comment → prod → deny" \ "curl http://example.com/a#b && sf project deploy start --target-org prod" \ '{"result":{"isSandbox":false,"isScratch":false,"instanceUrl":"https://acme.my.salesforce.com"}}' # #1030 adversarial review (finding 2): a heredoc-delivered deploy tokenizes with the # heredoc delimiter word shielding the real first command. Per-line scan closes it. decision deny "heredoc-delivered deploy → prod → deny" \ "bash <