The Vercel -> Cloudflare Pages move is done and the Vercel project is deleted, so the one-shot scripts have no remaining purpose. Nothing in CI ever called them — deploy-site.yml runs npm ci / test / build / pages deploy inline — so this removes 10 files and orphans nothing. Deleted: 00-baseline (refused to run without x-vercel-id, so permanently unrunnable), 01-preflight (proved Vercel state and API-token scopes), 07-dns-cutover (the phased cutover; in the end the records were attached through the dashboard, and the rules/apex phases went unused once we chose APEX_MODE=serve), 09-detach-vercel (its target project is gone), plus 03-ensure-project, 04-set-secrets, 05-deploy, 06-verify-deploy, 02-verify-local and 99-rollback, all either spent or duplicating CI. Their lib/cf-api.sh went with them: the survivors use wrangler, so the whole remaining path needs only `wrangler login` and no zone scopes. What is kept is the part with ongoing value: lib/parity.sh, the assertion set that caught five real defects during the migration — the live COOP/COEP bug on the post's canonical URL, the soft-404 Pages would have introduced, the cross-site form-POST guard Vercel had been providing for free, the missing immutable header, and HSTS max-age=0. "Does the page return 200" catches none of those. 08-verify-prod.sh becomes verify.sh, since the numbered sequence it belonged to no longer exists. It drops the stamp machinery, the dry-run plumbing and the Vercel-fallback messaging (there is no fallback now: recovery is promoting a previous Pages deployment), and gains --skip-dns / --skip-domains so it can be pointed at a single deployment via PROD_BASE before promoting it. The README is rewritten around the four invariants that fail SILENTLY — never widen _headers to /*, keep both URL forms of the Gerber post, never delete 404.astro, keep the cross-site form-POST guard — each with the reason, since the reason is the only thing that stops someone simplifying them back out. Verified after: 21 probes, 20 pass, 1 warn (HSTS max-age is 6 months vs Vercel's 2 years — on, just shorter), 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
356 lines
16 KiB
Shell
356 lines
16 KiB
Shell
#!/usr/bin/env bash
|
|
# The one HTTP parity sweep. Point it at any base URL and it asks the same
|
|
# question, so a regression has nowhere to hide:
|
|
#
|
|
# https://www.pcbjam.com production
|
|
# https://<hash>.pcbjam-site.pages.dev a specific deployment, before promoting
|
|
# http://127.0.0.1:8788 `wrangler pages dev` locally
|
|
#
|
|
# It was written for the Vercel -> Cloudflare Pages migration and several
|
|
# assertions exist because that migration found real defects; the comments say
|
|
# which, since the reason is the only thing stopping someone "simplifying" them
|
|
# back out.
|
|
#
|
|
# Usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]
|
|
# Exit: 0 if every hard assertion passed (warnings do not fail), else 1.
|
|
|
|
CURL="curl -sS --max-time 20"
|
|
|
|
# --- the two primitives ----------------------------------------------------
|
|
# Deliberately split. _trace follows redirects to learn WHERE we land; _headers
|
|
# then re-fetches that exact URL WITHOUT -L so there is exactly one response to
|
|
# parse. Asserting on a multi-block `-IL` dump is precisely how the COOP/COEP
|
|
# regression stayed hidden on Vercel: the headers were on the redirect hop, not
|
|
# on the document.
|
|
_trace() { # _trace URL -> "eff_url<TAB>status<TAB>hops"
|
|
$CURL -L -o /dev/null -w '%{url_effective}\t%{http_code}\t%{num_redirects}' "$1" 2>/dev/null
|
|
}
|
|
|
|
_headers() { # _headers URL [extra curl args...] -> "name: value" lines, name lowercased
|
|
$CURL -D - -o /dev/null "$@" 2>/dev/null | tr -d '\r' | awk '
|
|
/^HTTP\/[0-9.]+ [0-9][0-9][0-9]/ { buf=""; next } # reset on each block; keep the last
|
|
NF==0 { next }
|
|
{ i=index($0,":"); if(i){ k=tolower(substr($0,1,i-1)); v=substr($0,i+1);
|
|
sub(/^[ \t]+/,"",v); buf = buf k ": " v "\n" } }
|
|
END { printf "%s", buf }'
|
|
}
|
|
|
|
_hdr() { # _hdr "<headers>" name -> value ("" if absent)
|
|
printf '%s\n' "$1" | awk -v k="$2: " 'index($0,k)==1 { print substr($0, length(k)+1); exit }'
|
|
}
|
|
|
|
_title() { $CURL -L "$1" 2>/dev/null | tr -d '\n' | sed -n 's/.*<title>\([^<]*\)<\/title>.*/\1/p'; }
|
|
|
|
# --- result accumulation (bash 3.2: temp file, not an array) ---------------
|
|
_P_RESULTS=""
|
|
_p_init() { _P_RESULTS="$(mktemp -t cfmparity)"; }
|
|
_p_add() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$_P_RESULTS"; }
|
|
pass() { _p_add PASS "$1" "${2:-}" "${3:-}"; }
|
|
fail() { _p_add FAIL "$1" "${2:-}" "${3:-}"; }
|
|
soft() { _p_add WARN "$1" "${2:-}" "${3:-}"; }
|
|
|
|
# assert_status <probe> <url> <expected>
|
|
_expect_page() { # _expect_page <probe> <base> <path>
|
|
_pr="$1"; _b="$2"; _path="$3"
|
|
_t="$(_trace "$_b$_path")"
|
|
_eff="$(printf '%s' "$_t" | cut -f1)"; _st="$(printf '%s' "$_t" | cut -f2)"
|
|
_hops="$(printf '%s' "$_t" | cut -f3)"
|
|
_effpath="$(printf '%s' "$_eff" | sed -e 's|^[a-z]*://[^/]*||' -e 's|?.*$||')"
|
|
_h="$(_headers "$_eff")"
|
|
_ct="$(_hdr "$_h" content-type)"
|
|
# Hard: final 200, landed on the requested path (+/- a trailing slash), HTML.
|
|
# Trailing-slash HOPS are recorded, never failed on: Vercel serves both forms
|
|
# at 200; Pages 308s the bare form to the slash form. Both are fine. What is
|
|
# NOT fine is landing somewhere else — that is how a soft-404 shows up.
|
|
if [ "$_st" != "200" ]; then fail "$_pr" "$_hops" "expected 200, got $_st"; return; fi
|
|
case "$_effpath" in
|
|
"$_path"|"$_path/") : ;;
|
|
*) fail "$_pr" "$_hops" "landed on '$_effpath', expected '$_path'"; return ;;
|
|
esac
|
|
case "$_ct" in
|
|
text/html*) : ;;
|
|
*) fail "$_pr" "$_hops" "content-type '$_ct'"; return ;;
|
|
esac
|
|
pass "$_pr" "$_hops" "200 $_effpath"
|
|
}
|
|
|
|
_expect_coi() { # _expect_coi <probe> <base> <path> (cross-origin isolated)
|
|
_pr="$1"; _b="$2"; _path="$3"
|
|
_t="$(_trace "$_b$_path")"; _eff="$(printf '%s' "$_t" | cut -f1)"
|
|
_hops="$(printf '%s' "$_t" | cut -f3)"
|
|
_h="$(_headers "$_eff")"
|
|
_coop="$(_hdr "$_h" cross-origin-opener-policy)"
|
|
_coep="$(_hdr "$_h" cross-origin-embedder-policy)"
|
|
if [ "$_coop" = "same-origin" ] && [ "$_coep" = "require-corp" ]; then
|
|
pass "$_pr" "$_hops" "COOP+COEP on $_eff"
|
|
else
|
|
fail "$_pr" "$_hops" "coop='${_coop:-absent}' coep='${_coep:-absent}' on $_eff"
|
|
fi
|
|
}
|
|
|
|
_expect_not_coi() { # the landing page MUST NOT be isolated
|
|
_pr="$1"; _b="$2"; _path="$3"
|
|
_t="$(_trace "$_b$_path")"; _eff="$(printf '%s' "$_t" | cut -f1)"
|
|
_h="$(_headers "$_eff")"
|
|
_coep="$(_hdr "$_h" cross-origin-embedder-policy)"
|
|
if [ -z "$_coep" ]; then
|
|
pass "$_pr" "-" "not isolated (correct — YouTube hero embed)"
|
|
else
|
|
fail "$_pr" "-" "COEP '$_coep' present; a require-corp landing page cannot load the YouTube embed"
|
|
fi
|
|
}
|
|
|
|
assert_parity() {
|
|
_base="${1:?usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]}"
|
|
shift
|
|
_scope=preview; _live_post=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--scope) _scope="$2"; shift 2 ;;
|
|
--live-post) _live_post=1; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
_base="$(printf '%s' "$_base" | sed 's|/*$||')"
|
|
_p_init
|
|
section "parity sweep: $_base (scope=$_scope)"
|
|
|
|
# 1) pages
|
|
for p in / /pricing /blog /privacy /terms /cookies /licenses; do
|
|
_n="$(printf '%s' "$p" | sed 's|^/||')"; [ -z "$_n" ] && _n=home
|
|
_expect_page "$_n" "$_base" "$p"
|
|
done
|
|
|
|
# 2) cross-origin isolation, asserted on the FINAL response.
|
|
# BOTH URL forms of the blog post are probed on purpose. Vercel serves the bare
|
|
# form at 200 with the headers and the TRAILING-SLASH form at 200 WITHOUT them —
|
|
# and the trailing-slash form is the page's own canonical, i.e. what search
|
|
# sends people to. So post_coi passes on Vercel while post_coi_slash fails; that
|
|
# asymmetry IS the bug, and probing only one form would hide it.
|
|
_expect_coi post_coi "$_base" /blog/porting-kicad-graphics-to-webgl-in-2026
|
|
_expect_coi post_coi_slash "$_base" /blog/porting-kicad-graphics-to-webgl-in-2026/
|
|
_expect_coi gerber_boot "$_base" /gerber-demo/boot.js
|
|
_expect_not_coi landing_iso "$_base" /
|
|
|
|
# 3) immutable asset caching
|
|
_asset="$($CURL -L "$_base/" 2>/dev/null | tr '"' '\n' | grep -m1 '^/_astro/[^ ]*\.css$' || true)"
|
|
if [ -n "$_asset" ]; then
|
|
_cc="$(_hdr "$(_headers "$_base$_asset")" cache-control)"
|
|
case "$_cc" in
|
|
*immutable*) pass astro_cache - "$_cc" ;;
|
|
*) soft astro_cache - "cache-control='${_cc:-absent}'" ;;
|
|
esac
|
|
else
|
|
soft astro_cache - "no hashed css found on /"
|
|
fi
|
|
|
|
# 4) the waitlist endpoint
|
|
# Preflight from the allowlisted demo origin. hops MUST be 0: a CORS preflight
|
|
# cannot follow a redirect, so this is the check that guards demo.pcbjam.com's
|
|
# entire waitlist integration.
|
|
_pre="$($CURL -o /dev/null -D - -X OPTIONS "$_base/api/waitlist" \
|
|
-H 'Origin: https://demo.pcbjam.com' \
|
|
-H 'Access-Control-Request-Method: POST' \
|
|
-H 'Access-Control-Request-Headers: content-type' 2>/dev/null | tr -d '\r')"
|
|
_pre_st="$(printf '%s' "$_pre" | awk '/^HTTP/{c=$2} END{print c}')"
|
|
_pre_h="$(_headers "$_base/api/waitlist" -X OPTIONS -H 'Origin: https://demo.pcbjam.com')"
|
|
_acao="$(_hdr "$_pre_h" access-control-allow-origin)"
|
|
_hops0="$($CURL -o /dev/null -w '%{num_redirects}' -X OPTIONS "$_base/api/waitlist" \
|
|
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null)"
|
|
if [ "$_pre_st" = "204" ] && [ "$_acao" = "https://demo.pcbjam.com" ] && [ "$_hops0" = "0" ]; then
|
|
pass api_preflight "$_hops0" "204 acao=$_acao"
|
|
else
|
|
fail api_preflight "$_hops0" "status=$_pre_st acao='${_acao:-absent}' hops=$_hops0 (all of 204/echoed-origin/0-hops required)"
|
|
fi
|
|
|
|
_deny_h="$(_headers "$_base/api/waitlist" -X OPTIONS -H 'Origin: https://not-allowed.example')"
|
|
if [ -z "$(_hdr "$_deny_h" access-control-allow-origin)" ]; then
|
|
pass api_cors_deny - "no CORS for a non-allowlisted origin"
|
|
else
|
|
fail api_cors_deny - "CORS granted to https://not-allowed.example"
|
|
fi
|
|
|
|
_gst="$($CURL -o /dev/null -w '%{http_code}' "$_base/api/waitlist" 2>/dev/null)"
|
|
[ "$_gst" = "405" ] && pass api_get - "405" || fail api_get - "expected 405, got $_gst"
|
|
|
|
_bad="$($CURL -o /dev/null -w '%{http_code}' -X POST "$_base/api/waitlist" \
|
|
-H 'content-type: application/json' --data '{"email":"nope"}' 2>/dev/null)"
|
|
[ "$_bad" = "400" ] && pass api_invalid_email - "400" || fail api_invalid_email - "expected 400, got $_bad"
|
|
|
|
# The honeypot branch returns BEFORE validation, BEFORE the rate limiter and
|
|
# BEFORE any Resend call, so this exercises the whole request path (routing,
|
|
# Functions bundling, body parsing, CORS) with zero side effects — safe to run
|
|
# against production.
|
|
_hp="$($CURL -X POST "$_base/api/waitlist" -H 'content-type: application/json' \
|
|
-H 'Origin: https://demo.pcbjam.com' \
|
|
--data '{"email":"parity@pcbjam.com","company_url":"bot","source":"cfm-parity"}' \
|
|
-w '\n%{http_code}' 2>/dev/null)"
|
|
_hp_st="$(printf '%s' "$_hp" | tail -1)"
|
|
case "$_hp" in
|
|
*'"ok":true'*) [ "$_hp_st" = "200" ] && pass api_honeypot - "200 ok:true" \
|
|
|| fail api_honeypot - "ok:true but status $_hp_st" ;;
|
|
*) fail api_honeypot - "status=$_hp_st body did not contain ok:true" ;;
|
|
esac
|
|
|
|
# No-JS native form submit -> 303 back to the page. The same-origin Origin
|
|
# header is required, not cosmetic: a real browser form submit sends it, and
|
|
# both Vercel's edge and (post-migration) the Function itself refuse a
|
|
# form-encoded POST that carries a foreign Origin. Omitting it here gets a 403
|
|
# and looks like a broken endpoint.
|
|
_form="$($CURL -o /dev/null -D - -X POST "$_base/api/waitlist" \
|
|
-H "Origin: $_base" \
|
|
--data-urlencode 'email=parity@pcbjam.com' \
|
|
--data-urlencode 'company_url=bot' 2>/dev/null | tr -d '\r')"
|
|
_form_st="$(printf '%s' "$_form" | awk '/^HTTP/{c=$2} END{print c}')"
|
|
_loc="$(printf '%s' "$_form" | awk 'tolower($1)=="location:"{print $2}' | tail -1)"
|
|
if [ "$_form_st" = "303" ] && [ "$_loc" = "/?waitlist=ok#waitlist" ]; then
|
|
pass api_form_303 - "303 -> $_loc"
|
|
else
|
|
fail api_form_303 - "status=$_form_st location='${_loc:-absent}'"
|
|
fi
|
|
|
|
# Cross-site form POST must be refused. Vercel's edge did this for free; the
|
|
# Function reproduces it, so this probe must pass on BOTH platforms.
|
|
_csrf="$($CURL -o /dev/null -w '%{http_code}' -X POST "$_base/api/waitlist" \
|
|
-H 'Origin: https://evil.example' \
|
|
--data-urlencode 'email=parity@pcbjam.com' 2>/dev/null || true)"
|
|
[ "$_csrf" = "403" ] && pass api_form_csrf - "403 cross-site form POST refused" \
|
|
|| fail api_form_csrf - "expected 403, got $_csrf (cross-site form POST is a CSRF vector)"
|
|
|
|
if [ "$_live_post" = 1 ]; then
|
|
_live="$($CURL -X POST "$_base/api/waitlist" -H 'content-type: application/json' \
|
|
--data '{"email":"cfm-live@example.com","source":"cfm-parity"}' \
|
|
-w '\n%{http_code}' 2>/dev/null)"
|
|
_live_st="$(printf '%s' "$_live" | tail -1)"
|
|
# 200 = accepted (no key configured); 502 = the Resend SDK loaded and the API
|
|
# rejected our (bogus) key. Both prove the module resolved under workerd.
|
|
case "$_live_st" in
|
|
200|502) pass api_live_post - "status=$_live_st (SDK loaded)" ;;
|
|
*) fail api_live_post - "expected 200 or 502, got $_live_st — likely a module-resolution error (nodejs_compat?)" ;;
|
|
esac
|
|
fi
|
|
|
|
# 5) a real 404, not the homepage at 200
|
|
_nf_url="$_base/__cfm-parity-404__/"
|
|
_nf_st="$($CURL -L -o /dev/null -w '%{http_code}' "$_nf_url" 2>/dev/null)"
|
|
_nf_title="$(_title "$_nf_url")"
|
|
_home_title="$(_title "$_base/")"
|
|
if [ "$_nf_st" != "404" ]; then
|
|
fail notfound - "expected 404, got $_nf_st (a 200 here is a soft-404 serving the homepage)"
|
|
elif [ -n "$_home_title" ] && [ "$_nf_title" = "$_home_title" ]; then
|
|
fail notfound - "404 status but the homepage document was served"
|
|
else
|
|
pass notfound - "404 '$_nf_title'"
|
|
fi
|
|
|
|
# 6) prod-only
|
|
if [ "$_scope" = prod ]; then
|
|
# HSTS is a deliberate, independent choice — one zone toggle, unrelated to
|
|
# whether the migration worked. Absent is a WARN, not a FAIL: failing here
|
|
# would tell an operator to roll back a healthy cutover. Set EXPECT_HSTS=1
|
|
# once you have enabled it, and it becomes a hard assertion.
|
|
_hsts="$(_hdr "$(_headers "$_base/")" strict-transport-security)"
|
|
case "$_hsts" in
|
|
*max-age=0*)
|
|
# Distinct from — and worse than — absent. max-age=0 actively instructs
|
|
# browsers to DISCARD any HSTS policy they hold for this host, so it
|
|
# revokes the 2-year policy Vercel was setting for every returning
|
|
# visitor. Always a hard failure, regardless of EXPECT_HSTS: nobody
|
|
# deliberately wants a header whose only effect is to switch protection off.
|
|
fail hsts - "max-age=0 — HSTS is OFF and this actively clears the policy browsers already hold. Set Max Age to 12 months or 2 years in SSL/TLS -> Edge Certificates." ;;
|
|
*max-age=63072000*) pass hsts - "$_hsts" ;;
|
|
"") if [ "${EXPECT_HSTS:-0}" = 1 ]; then
|
|
fail hsts - "absent, but EXPECT_HSTS=1"
|
|
else
|
|
soft hsts - "absent (Vercel sent max-age=63072000) — enable it in SSL/TLS -> Edge Certificates, then set EXPECT_HSTS=1"
|
|
fi ;;
|
|
*) soft hsts - "$_hsts (differs from the Vercel baseline max-age=63072000, but non-zero so protection is on)" ;;
|
|
esac
|
|
fi
|
|
|
|
# --- report -------------------------------------------------------------
|
|
printf '\n%-6s %-18s %-5s %s\n' STATUS PROBE HOPS DETAIL
|
|
awk -F'\t' '{ printf "%-6s %-18s %-5s %s\n", $1, $2, ($3==""?"-":$3), $4 }' "$_P_RESULTS"
|
|
_np=$(grep -c '^PASS' "$_P_RESULTS" || true)
|
|
_nf=$(grep -c '^FAIL' "$_P_RESULTS" || true)
|
|
_nw=$(grep -c '^WARN' "$_P_RESULTS" || true)
|
|
echo
|
|
echo "$(( _np + _nf + _nw )) probes: ${_np} pass, ${_nf} fail, ${_nw} warn"
|
|
rm -f "$_P_RESULTS"
|
|
if [ "$_nf" -gt 0 ]; then echo "parity: FAIL (${_nf})"; return 1; fi
|
|
echo "parity: PASS"; return 0
|
|
}
|
|
|
|
# Dispatch on the chosen apex topology (see APEX_MODE in lib/common.sh).
|
|
assert_apex() {
|
|
case "${APEX_MODE:-serve}" in
|
|
serve) assert_apex_serves "${1:-$APEX_BASE}" ;;
|
|
redirect) assert_apex_redirect "${1:-$APEX_BASE}" ;;
|
|
*) die "APEX_MODE must be 'serve' or 'redirect' (got '${APEX_MODE}')" ;;
|
|
esac
|
|
}
|
|
|
|
# APEX_MODE=serve: the apex is its own Pages custom domain. It must answer 200
|
|
# directly (no hop), and it must still declare www as canonical — that tag is the
|
|
# only thing consolidating the two hostnames for search, since we are deliberately
|
|
# not redirecting.
|
|
assert_apex_serves() {
|
|
_apex="${1:-$APEX_BASE}"
|
|
section "apex serves directly: $_apex (APEX_MODE=serve)"
|
|
_rc=0
|
|
|
|
_t="$(_trace "$_apex/")"
|
|
_st="$(printf '%s' "$_t" | cut -f2)"; _hops="$(printf '%s' "$_t" | cut -f3)"
|
|
if [ "$_st" = "200" ]; then
|
|
echo "PASS $_apex/ -> 200 (hops $_hops)"
|
|
else
|
|
echo "FAIL $_apex/ -> $_st (expected 200; is the apex attached as a custom domain?)"; _rc=1
|
|
fi
|
|
|
|
_h="$(_headers "$_apex/")"
|
|
if [ -n "$(_hdr "$_h" x-vercel-id)" ]; then
|
|
echo "FAIL x-vercel-id present on the apex — still Vercel (stale DNS, not a pass)"; _rc=1
|
|
else
|
|
echo "PASS no x-vercel-id on the apex"
|
|
fi
|
|
|
|
_can="$($CURL -L "$_apex/" 2>/dev/null | tr -d '\n' \
|
|
| sed -n 's/.*rel="canonical" href="\([^"]*\)".*/\1/p' | head -1)"
|
|
case "$_can" in
|
|
https://www.pcbjam.com/*|https://www.pcbjam.com)
|
|
echo "PASS apex declares canonical $_can" ;;
|
|
*)
|
|
echo "FAIL apex canonical is '${_can:-absent}' — must point at www, otherwise the"
|
|
echo " two hostnames compete instead of consolidating"; _rc=1 ;;
|
|
esac
|
|
|
|
# The demo cross-posts to www, but if anyone ever points it at the apex, the
|
|
# endpoint has to be reachable there too. Cheap to confirm.
|
|
_pre="$($CURL -o /dev/null -w '%{http_code}' -X OPTIONS "$_apex/api/waitlist" \
|
|
-H 'Origin: https://demo.pcbjam.com' \
|
|
-H 'Access-Control-Request-Method: POST' 2>/dev/null || true)"
|
|
[ "$_pre" = "204" ] && echo "PASS apex /api/waitlist preflight 204" \
|
|
|| { echo "FAIL apex /api/waitlist preflight $_pre"; _rc=1; }
|
|
|
|
return $_rc
|
|
}
|
|
|
|
# APEX_MODE=redirect: apex -> www, path + query preserved. Unused with the
|
|
# current `serve` topology; kept in case that decision is revisited.
|
|
assert_apex_redirect() {
|
|
_apex="${1:-$APEX_BASE}"
|
|
section "apex redirect: $_apex"
|
|
_rc=0
|
|
for pair in "/:https://www.pcbjam.com/" "/pricing?a=1&b=2:https://www.pcbjam.com/pricing?a=1&b=2"; do
|
|
_path="${pair%%:*}"; _want="${pair#*:}"
|
|
_h="$($CURL -o /dev/null -D - "$_apex$_path" 2>/dev/null | tr -d '\r')"
|
|
_st="$(printf '%s' "$_h" | awk '/^HTTP/{c=$2} END{print c}')"
|
|
_loc="$(printf '%s' "$_h" | awk 'tolower($1)=="location:"{print $2}' | tail -1)"
|
|
if [ "$_st" = "308" ] && [ "$_loc" = "$_want" ]; then
|
|
echo "PASS $_apex$_path -> 308 $_loc"
|
|
else
|
|
echo "FAIL $_apex$_path -> status=$_st location='${_loc:-absent}' (wanted 308 $_want)"; _rc=1
|
|
fi
|
|
done
|
|
return $_rc
|
|
}
|