199 lines
10 KiB
Bash
Executable File
199 lines
10 KiB
Bash
Executable File
#!/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 <method> <path> [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"
|