#!/usr/bin/env bash # test_cf.sh — smoke tests for clients/bash/cf # # Strategy: drop a fake `curl` into PATH that records its invocation # (argv + the contents of any --config + any --data-binary @) # to a request-shape file, then exercise cf against it. We assert on the # captured request, not on the real network. # # No frameworks, just bash + assertions. Run: # bash clients/bash/test/test_cf.sh # # Exit 0 on all-pass, 1 on first failure. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CF="$HERE/../cf" [[ -x "$CF" ]] || { echo "fatal: cf not found or not exec at $CF" >&2; exit 1; } WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/bin" "$WORK/cfgdir" REQ_LOG="$WORK/req.log" # Fake curl. Captures everything the SUT passes: # - argv (one per line, separator __ARGV__) # - contents of --config if present (separator __CONFIG__) # - contents of --data-binary @ if present (separator __DATAFILE__) # Then prints a stub successful HTTP response on stdout and exits 0. cat > "$WORK/bin/curl" <<'FAKE_CURL' #!/usr/bin/env bash log="${CF_TEST_REQ_LOG:?must export CF_TEST_REQ_LOG}" { echo "__ARGV_COUNT__=$#" for a in "$@"; do printf '__ARGV__=%s\n' "$a" done # Walk the args looking for --config and --data-binary @ prev="" for a in "$@"; do if [[ "$prev" == "--config" ]]; then echo "__CONFIG_FILE__=$a" if [[ -f "$a" ]]; then echo "__CONFIG_CONTENTS__" cat "$a" echo "__END_CONFIG__" fi fi if [[ "$prev" == "--data-binary" && "$a" == @* ]]; then df="${a#@}" echo "__DATAFILE__=$df" if [[ -f "$df" ]]; then echo "__DATAFILE_CONTENTS__" cat "$df" echo "__END_DATAFILE__" fi fi if [[ "$prev" == "--data-binary" && "$a" != @* ]]; then echo "__DATA_INLINE__" printf '%s' "$a" echo echo "__END_DATA_INLINE__" fi prev="$a" done } > "$log" # Stub a response body and the status marker that cf parses. # Use a body that's valid JSON so _extract_error doesn't trip. printf '%s\n__cf_status__=200' '{"ok":true}' exit 0 FAKE_CURL chmod +x "$WORK/bin/curl" # Make a writable cf.env baseline (chmod 600). Tests that need a different # perms set will rewrite this. CF_ENV="$WORK/cfgdir/clawdforge/cf.env" mkdir -p "$(dirname "$CF_ENV")" cat > "$CF_ENV" <&2 FAIL=$((FAIL+1)) } pass() { echo "PASS: $*" PASS=$((PASS+1)) } assert_contains() { local needle="$1" hay="$2" name="$3" if [[ "$hay" == *"$needle"* ]]; then pass "$name" else fail "$name — expected to find: $needle" echo "--- haystack ---" >&2 printf '%s\n' "$hay" >&2 echo "--- end ---" >&2 fi } assert_not_contains() { local needle="$1" hay="$2" name="$3" if [[ "$hay" != *"$needle"* ]]; then pass "$name" else fail "$name — found unexpectedly: $needle" echo "--- haystack ---" >&2 printf '%s\n' "$hay" >&2 echo "--- end ---" >&2 fi } # ---- Test 1: token NOT in argv (S1) -------------------------------------- echo "# Test 1: bearer token not in argv" : > "$REQ_LOG" run_cf healthz >/dev/null log="$(cat "$REQ_LOG")" # Extract argv lines only argv_only="$(grep '^__ARGV__=' "$REQ_LOG" || true)" assert_not_contains "tok-secret-xyz" "$argv_only" "T1.argv-no-token" # But the bearer should appear inside the captured --config file contents. assert_contains "Bearer tok-secret-xyz" "$log" "T1.config-has-bearer" # ---- Test 2: JSON-injection in --files (S2) ------------------------------ echo "# Test 2: JSON injection via --files" # Comma is the documented separator for --files, so per-token injection # attempts must use other characters (quote, backslash, etc.). Each token # MUST be JSON-escaped, and the resulting body MUST remain a valid object # with no smuggled top-level keys. : > "$REQ_LOG" # Single-token injection — quote-escape attempt without a comma. run_cf run hi --files 'tok_a"]:"extra":"injected' >/dev/null body="" in_block=0 while IFS= read -r line; do if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi if (( in_block )); then body+="$line"$'\n'; fi done < "$REQ_LOG" body="${body%$'\n'}" if command -v jq >/dev/null 2>&1; then if jq -e '.files | type == "array" and length == 1 and .[0] == "tok_a\"]:\"extra\":\"injected"' >/dev/null <<<"$body" 2>/dev/null; then pass "T2.files-jq-roundtrip" else fail "T2.files-jq-roundtrip — body=$body" fi has_extra="$(jq -r 'has("extra")' <<<"$body" 2>/dev/null || echo error)" if [[ "$has_extra" == "false" ]]; then pass "T2.no-injected-top-key" else fail "T2.no-injected-top-key — body=$body" fi else assert_contains 'tok_a\"]:\"extra\":\"injected' "$body" "T2.files-escaped-string" fi # Sub-test: even when the input contains commas (intentionally splitting), # each split piece must still be properly JSON-escaped and the body must # remain a valid JSON object with no smuggled top-level keys. This is the # original audit repro 'tok_a","extra":"injected' which DOES contain a # comma and therefore splits into two tokens. : > "$REQ_LOG" run_cf run hi --files 'tok_a","extra":"injected' >/dev/null body="" in_block=0 while IFS= read -r line; do if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi if (( in_block )); then body+="$line"$'\n'; fi done < "$REQ_LOG" body="${body%$'\n'}" if command -v jq >/dev/null 2>&1; then has_extra="$(jq -r 'has("extra")' <<<"$body" 2>/dev/null || echo error)" if [[ "$has_extra" == "false" ]]; then pass "T2.audit-repro-no-injection" else fail "T2.audit-repro-no-injection — body=$body" fi count="$(jq -r '.files | length' <<<"$body" 2>/dev/null || echo err)" if [[ "$count" == "2" ]]; then pass "T2.audit-repro-split-2" else fail "T2.audit-repro-split-2 — count=$count body=$body" fi fi # Sub-test: comma-separated tokens still split into N array elements. : > "$REQ_LOG" run_cf run hi --files 'one,two,three' >/dev/null body="" in_block=0 while IFS= read -r line; do if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi if (( in_block )); then body+="$line"$'\n'; fi done < "$REQ_LOG" body="${body%$'\n'}" if command -v jq >/dev/null 2>&1; then count="$(jq -r '.files | length' <<<"$body" 2>/dev/null || echo err)" if [[ "$count" == "3" ]]; then pass "T2.files-comma-split-contract" else fail "T2.files-comma-split-contract — count=$count body=$body" fi fi # ---- Test 3: JSON-injection in admin token-mint name (S3) ---------------- echo "# Test 3: JSON injection via token-mint name" : > "$REQ_LOG" run_cf admin token-mint 'evil","admin":true,"x":"x' --ip-cidrs '10.0.0.0/8,192.168.0.0/16' >/dev/null body="" in_block=0 while IFS= read -r line; do if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi if (( in_block )); then body+="$line"$'\n'; fi done < "$REQ_LOG" body="${body%$'\n'}" if command -v jq >/dev/null 2>&1; then # The smuggled "admin":true MUST NOT appear at top level. has_admin="$(jq -r 'has("admin")' <<<"$body" 2>/dev/null || echo error)" if [[ "$has_admin" == "false" ]]; then pass "T3.no-admin-key-injected" else fail "T3.no-admin-key-injected — body=$body" fi name_val="$(jq -r '.name' <<<"$body" 2>/dev/null || echo error)" if [[ "$name_val" == 'evil","admin":true,"x":"x' ]]; then pass "T3.name-roundtrips" else fail "T3.name-roundtrips — got: $name_val" fi cidrs_count="$(jq -r '.ip_cidrs | length' <<<"$body" 2>/dev/null || echo error)" if [[ "$cidrs_count" == "2" ]]; then pass "T3.cidrs-2-items" else fail "T3.cidrs-2-items — body=$body" fi else assert_contains 'evil\",\"admin\":true' "$body" "T3.name-escaped" fi # ---- Test 4: URL-encoding in token-revoke (S4) --------------------------- echo "# Test 4: URL-encoding in token-revoke" : > "$REQ_LOG" run_cf admin token-revoke 'foo/../bar' >/dev/null log="$(cat "$REQ_LOG")" # Find the URL argv (looks like http://...) url_line="$(grep '^__ARGV__=http' "$REQ_LOG" || true)" assert_not_contains "foo/../bar" "$url_line" "T4.no-raw-traversal" # Either %2F or %2f acceptable. if [[ "$url_line" == *"foo%2F..%2Fbar"* || "$url_line" == *"foo%2f..%2fbar"* ]]; then pass "T4.has-url-encoded-slashes" else fail "T4.has-url-encoded-slashes — url_line=$url_line" fi # ---- Test 5: cf.env perms guard (S5) -------------------------------------- echo "# Test 5: cf.env refused when world-writable" chmod 644 "$CF_ENV" set +e out="$(run_cf healthz 2>&1)"; rc=$? set -e chmod 600 "$CF_ENV" if (( rc != 0 )) && [[ "$out" == *"refusing to source"* ]]; then pass "T5.refuses-bad-perms" else fail "T5.refuses-bad-perms — rc=$rc out=$out" fi # And with correct perms, still works. : > "$REQ_LOG" if run_cf healthz >/dev/null; then pass "T5.accepts-good-perms"; else fail "T5.accepts-good-perms"; fi # ---- Test 6: cf run - with TTY stdin errors (B1) ------------------------- echo "# Test 6: cf run - on TTY stdin" # We can't easily allocate a real TTY in CI; simulate by giving cf a stdin # that *is* a tty by using script(1) if available; otherwise, fall back to # invoking cf with /dev/tty (which on most CI is unavailable and would error # differently). Easiest robust approach: directly test the [[ -t 0 ]] guard # by feeding stdin from a TTY-like fd. We use `script` from bsdutils. if command -v script >/dev/null 2>&1; then set +e # 'script -qec CMD /dev/null' runs CMD with a pty as stdin/stdout. out="$(script -qec "XDG_CONFIG_HOME='$WORK/cfgdir' CF_TEST_REQ_LOG='$REQ_LOG' PATH='$WORK/bin:$PATH' '$CF' run -" /dev/null 2>&1)" rc=$? set -e if (( rc != 0 )) && [[ "$out" == *"stdin is a TTY"* ]]; then pass "T6.tty-refused" else fail "T6.tty-refused — rc=$rc out=$out" fi else echo "SKIP: T6.tty-refused — 'script' utility not available" fi # Also confirm that piping in works (non-TTY path). : > "$REQ_LOG" if echo "hello-piped" | run_cf run - >/dev/null; then pass "T6.pipe-works"; else fail "T6.pipe-works"; fi # ---- Test 7: glob expansion on --files (B3) ------------------------------ echo "# Test 7: glob expansion on --files" GLOBDIR="$WORK/globdir" mkdir -p "$GLOBDIR" : > "$GLOBDIR/foo1.txt" : > "$GLOBDIR/foo2.txt" : > "$REQ_LOG" ( cd "$GLOBDIR" && run_cf run hi --files 'foo*.txt,bar' >/dev/null ) body="" in_block=0 while IFS= read -r line; do if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi if (( in_block )); then body+="$line"$'\n'; fi done < "$REQ_LOG" body="${body%$'\n'}" if command -v jq >/dev/null 2>&1; then count="$(jq -r '.files | length' <<<"$body" 2>/dev/null || echo err)" first="$(jq -r '.files[0]' <<<"$body" 2>/dev/null || echo err)" second="$(jq -r '.files[1]' <<<"$body" 2>/dev/null || echo err)" if [[ "$count" == "2" && "$first" == "foo*.txt" && "$second" == "bar" ]]; then pass "T7.no-glob-expansion" else fail "T7.no-glob-expansion — count=$count first=$first second=$second body=$body" fi else assert_contains '"foo*.txt"' "$body" "T7.no-glob-expansion-asterisk-preserved" fi # ---- Test 8: cmd_help works with new sentinel (U3) ----------------------- echo "# Test 8: cmd_help" out="$(run_cf_no_env help 2>&1 || true)" assert_contains "cf — bash CLI for clawdforge" "$out" "T8.help-header" assert_contains "Exit codes" "$out" "T8.help-body" # Sentinel line itself should NOT appear in the rendered help. assert_not_contains "end help" "$out" "T8.no-sentinel-leak" # ---- Test 9: upload path with ';' is rejected (S6) ----------------------- echo "# Test 9: upload semicolon-in-path rejected" SEMI="$WORK/has;semi.txt" : > "$SEMI" set +e out="$(run_cf upload "$SEMI" 2>&1)"; rc=$? set -e if (( rc != 0 )) && [[ "$out" == *"';'"* ]]; then pass "T9.semi-rejected" else fail "T9.semi-rejected — rc=$rc out=$out" fi # ---- Test 10: healthz with no token doesn't send empty Bearer (B8) ------- echo "# Test 10: healthz with no token" : > "$REQ_LOG" unset_env_run() { HOME="$WORK/no-such-home" \ XDG_CONFIG_HOME="$WORK/no-such-cfg" \ CF_TEST_REQ_LOG="$REQ_LOG" \ PATH="$WORK/bin:$PATH" \ env -u CLAWDFORGE_TOKEN -u CLAWDFORGE_ADMIN_TOKEN "$CF" "$@" } unset_env_run healthz >/dev/null log="$(cat "$REQ_LOG")" assert_not_contains "Authorization" "$log" "T10.no-auth-header-without-token" # ---- Test 11: admin subcommand validated before token (B9) --------------- echo "# Test 11: admin typo before token" set +e out="$(env -u CLAWDFORGE_ADMIN_TOKEN \ HOME="$WORK/no-such-home" \ XDG_CONFIG_HOME="$WORK/no-such-cfg" \ PATH="$WORK/bin:$PATH" \ "$CF" admin token-mintzz 2>&1)" rc=$? set -e if (( rc == 2 )) && [[ "$out" == *"unknown admin subcommand"* ]]; then pass "T11.subcmd-validated-first" else fail "T11.subcmd-validated-first — rc=$rc out=$out" fi echo "" echo "=============================" echo "PASS: $PASS FAIL: $FAIL" echo "=============================" if (( FAIL > 0 )); then exit 1; fi exit 0