#!/usr/bin/env bash # # Verify a Krow API deployment, from the outside. # # KROW_EMAIL=you@example.com KROW_PASSWORD=... ./scripts/verify-deployment.sh # KROW_BASE=https://mcp.krowforce.com ./scripts/verify-deployment.sh --write # # WHAT THIS IS FOR. The 404 that started this was invisible to every # unauthenticated probe: authentication wraps the whole mux, so a route that # does not exist and a route you are not signed in for answer identically. The # only way to tell them apart is to hold a session and ask. That is what this # does, and it is why it needs credentials. # # READ-ONLY BY DEFAULT. Everything below is a GET unless --write is passed. # With --write it creates ONE job posting with status "draft" — drafts are # invisible to talent and to the public listing, and the JobPosting resource # has no DELETE operation, so the row is permanent. That is the whole reason # the write test is opt-in rather than default: verifying a deployment should # not silently leave records in a production database. # # Exit status is the number of failed checks, so it can gate a rollout. set -uo pipefail BASE="${KROW_BASE:-https://mcp.krowforce.com}" API="$BASE/api/v1" JAR="$(mktemp -t krowjar.XXXXXX)" DO_WRITE=false [[ "${1:-}" == "--write" ]] && DO_WRITE=true trap 'rm -f "$JAR"' EXIT pass=0; fail=0; skip=0 ok() { printf ' \033[32mok\033[0m %s\n' "$1"; pass=$((pass+1)); } bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' "$2"; fail=$((fail+1)); } note() { printf ' \033[33mskip\033[0m %s\n' "$1"; skip=$((skip+1)); } head_() { printf '\n\033[1m%s\033[0m\n' "$1"; } # status [body] — prints the HTTP status, keeps the body in $BODY BODY="" status() { local method="$1" path="$2" body="${3:-}" code if [[ -n "$body" ]]; then code=$(curl -sS -m 20 -o /tmp/krowbody.$$ -w '%{http_code}' -X "$method" "$API$path" \ -b "$JAR" -c "$JAR" -H 'Content-Type: application/json' -H 'Accept: application/json' -d "$body") else code=$(curl -sS -m 20 -o /tmp/krowbody.$$ -w '%{http_code}' -X "$method" "$API$path" \ -b "$JAR" -c "$JAR" -H 'Accept: application/json') fi BODY=$(cat /tmp/krowbody.$$ 2>/dev/null); rm -f /tmp/krowbody.$$ printf '%s' "$code" } printf '\033[1mKrow deployment verification\033[0m\n' printf 'target %s\n' "$BASE" printf 'mode %s\n' "$([[ $DO_WRITE == true ]] && echo 'read + one draft write' || echo 'read-only')" # ── 1. Reachability, before any credential ───────────────────────────────── head_ '1 · Reachability' health=$(curl -sS -m 20 -o /tmp/h.$$ -w '%{http_code}' "$BASE/health"); hbody=$(cat /tmp/h.$$); rm -f /tmp/h.$$ if [[ "$health" == "200" ]]; then ok "/health → 200 $(printf '%s' "$hbody" | tr -d ' \n')" else bad "/health → $health (want 200)" "$hbody"; fi # The status word matters: "degraded" means the process is fine and the schema # is not — an unmigrated or dirty database. Deploying the binary before the # migration is exactly how that happens. if printf '%s' "$hbody" | grep -q 'degraded'; then bad "schema is not current — run migrations before rolling out the binary" fi # ── 2. Session ───────────────────────────────────────────────────────────── head_ '2 · Authentication' if [[ -z "${KROW_EMAIL:-}" || -z "${KROW_PASSWORD:-}" ]]; then printf ' \033[31mKROW_EMAIL / KROW_PASSWORD are not set.\033[0m\n' printf ' Every check below needs a session: an unauthenticated request to a\n' printf ' route that exists and one to a route that does not are both 401, so\n' printf ' without credentials this script cannot tell you what is deployed.\n\n' exit 1 fi login=$(status POST /auth/login "$(printf '{"email":%s,"password":%s,"remember_me":false}' \ "$(printf '%s' "$KROW_EMAIL" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \ "$(printf '%s' "$KROW_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")") if [[ "$login" == "200" ]]; then ok "POST /auth/login → 200" else bad "POST /auth/login → $login" "$BODY"; printf '\nCannot continue without a session.\n'; exit 1; fi # The cookie decides whether a browser will ever send this session again. cookie_line=$(grep -i 'krow_session' "$JAR" | head -1) if [[ -n "$cookie_line" ]]; then ok "session cookie issued"; else bad "no krow_session cookie in the response"; fi me=$(status GET /me) if [[ "$me" == "200" ]]; then role=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"].get("role","?"))' 2>/dev/null) ok "GET /me → 200, role=$role" # Authorization is decided by `role`, never by `account_type`. A talent role # is refused every operator write, which presents as an app that "does not # work" rather than as a permission problem. if [[ "$role" == "talent" ]]; then bad "this account's role is 'talent'" \ "operator writes will 403 and the positions list will show only active postings" fi else bad "GET /me → $me" "$BODY"; fi # ── 3. Which version is deployed ─────────────────────────────────────────── head_ '3 · Deployed version' # The Owliver suggestions route is the discriminator: it exists only from # commit b6f8655 onward. Authenticated, so 404 means "not in this binary" # rather than "not signed in". sug=$(status GET '/owliver/suggestions?page=positions&query=pipeline') case "$sug" in 200) ok "GET /owliver/suggestions → 200 — b6f8655 or later is deployed" ;; 404) bad "GET /owliver/suggestions → 404 — the deployed binary predates b6f8655" \ "this is the deployment lag; the route exists in go-api/internal/httpserver/owliver.go" ;; *) bad "GET /owliver/suggestions → $sug (want 200)" "$BODY" ;; esac # The resource is job-postings. /api/v1/positions has never existed in this # API, and asserting that here stops anyone "fixing" a 404 by adding it. pos=$(status GET /positions) if [[ "$pos" == "404" ]]; then ok "GET /positions → 404 — correct, the resource is job-postings" else bad "GET /positions → $pos (want 404)" "a duplicate positions route may have been added"; fi # ── 4. The job-posting resource ──────────────────────────────────────────── head_ '4 · Job postings (the position resource)' list=$(status GET '/job-postings?limit=5') if [[ "$list" == "200" ]]; then count=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]))' 2>/dev/null) ok "GET /job-postings → 200, $count row(s)" else bad "GET /job-postings → $list" "$BODY"; fi if [[ "$DO_WRITE" == true ]]; then stamp=$(date -u +%Y%m%dT%H%M%SZ) # status draft: not published, not visible to talent. The least invasive # record that still proves the whole write path reaches PostgreSQL. payload=$(printf '{"title":"Deployment verification %s","company":"Verification","role_category":"Server","location":"n/a","status":"draft","headcount":1,"pay_range_min":0,"pay_range_max":0,"min_experience_years":0,"english_required":"basic","priority":"normal"}' "$stamp") created=$(status POST /job-postings "$payload") if [[ "$created" == "201" ]]; then id=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["id"])' 2>/dev/null) ok "POST /job-postings → 201, id=$id" back=$(status GET "/job-postings/$id") if [[ "$back" == "200" ]]; then ok "GET /job-postings/$id → 200 — persisted in PostgreSQL" else bad "created row not readable back → $back"; fi printf ' note: draft row %s is permanent (JobPosting has no DELETE)\n' "$id" elif [[ "$created" == "403" ]]; then bad "POST /job-postings → 403" "this account's role is not an operator (admin or employer)" else bad "POST /job-postings → $created" "$BODY" fi else note "write test skipped (pass --write to create one draft posting)" fi # ── 5. Owliver, in both modes ────────────────────────────────────────────── head_ '5 · Owliver suggestions' if [[ "$sug" == "200" ]]; then n=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]["suggestions"]))' 2>/dev/null) if [[ "${n:-x}" =~ ^[0-9]+$ ]] && (( n <= 3 )); then ok "typed query returned $n suggestion(s), cap is 3" else bad "typed query returned $n suggestions" "the contract caps this at 3"; fi # No query: this is the path that reads the organization's state out of # PostgreSQL, so it is the one that proves context building works. untyped=$(status GET '/owliver/suggestions?page=positions') if [[ "$untyped" == "200" ]]; then m=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]["suggestions"]))' 2>/dev/null) ok "untyped query → 200, $m suggestion(s) from live data" else bad "untyped query → $untyped" "$BODY"; fi bad_page=$(status GET '/owliver/suggestions?page=not-a-real-surface') if [[ "$bad_page" == "400" ]]; then ok "unknown page → 400 invalid_query" else bad "unknown page → $bad_page (want 400)"; fi else note "suggestion detail checks skipped — the route is not deployed" fi # ── 6. Cookie posture ────────────────────────────────────────────────────── head_ '6 · Session cookie posture' logout_hdrs=$(curl -sS -m 20 -D - -o /dev/null -X POST "$API/auth/logout" -b "$JAR") setc=$(printf '%s' "$logout_hdrs" | grep -i '^set-cookie:' | head -1) printf ' %s\n' "${setc:-(no Set-Cookie)}" if printf '%s' "$setc" | grep -qi 'SameSite=None'; then printf ' \033[33mSameSite=None\033[0m — the cookie travels cross-site. That is required only\n' printf ' for a frontend on a DIFFERENT registrable domain. platform.krowforce.com\n' printf ' and mcp.krowforce.com are the same site, so Lax would suffice for them —\n' printf ' and SameSite is the only CSRF protection this API has.\n' elif printf '%s' "$setc" | grep -qi 'SameSite=Lax'; then ok "SameSite=Lax — CSRF protection retained" fi # ── Summary ──────────────────────────────────────────────────────────────── printf '\n\033[1m%d passed, %d failed, %d skipped\033[0m\n' "$pass" "$fail" "$skip" exit "$fail"