| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- #!/usr/bin/env bash
- # m13a_smoke.sh — End-to-end smoke for the M13a auth gate.
- #
- # Walks through:
- # 1. authd /health and /metrics
- # 2. login (super_admin) → access + refresh
- # 3. /v1/users/me with Bearer → user info
- # 4. refresh → new pair (rotated)
- # 5. re-use OLD refresh → session_killed
- # 6. invite → magic-link token (will not email; we just verify
- # the route is wired)
- # 7. admind /v1/dlq (gated): no token → 401, valid token → 200
- # 8. ingestd /v1/admin/ingest (gated): no token → 401, valid
- # token → 200 (we don't check the full ingest pipeline here,
- # just that the gate lets the request through)
- # 9. (W5) routerd /v1/admin/dedupe/{state,flush} (gated):
- # no token → 401, viewer can read state but not flush
- # 10. (W5) archiverd /v1/admin/archiver/run (gated):
- # no token → 401, super_admin → 200
- # 11. (W5) deliverd-fcm /v1/admin/dlq (gated, channel=fcm):
- # no token → 401, with auth → 200, channel label correct
- # 12. (W5) deliverd-telegram /v1/admin/dlq (gated, channel=telegram):
- # no token → 401, with auth → 200, channel label correct
- #
- # Requires:
- # - authd running on $BA_AUTHD_HTTP (default http://127.0.0.1:8804)
- # - admind running on $BA_ADMIND_HTTP (default http://127.0.0.1:8803)
- # - ingestd running on $BA_INGESTD_HTTP (default http://127.0.0.1:8800)
- # - routerd running on $BA_ROUTERD_HTTP (default http://127.0.0.1:8801)
- # - archiverd running on $BA_ARCHIVERD_HTTP (default http://127.0.0.1:8805)
- # - deliverd-fcm running on $BA_DELIVERD_FCM_HTTP (default http://127.0.0.1:8802)
- # - deliverd-telegram running on $BA_DELIVERD_TELEGRAM_HTTP (default http://127.0.0.1:8821)
- # - $BA_AUTHD_JWT_SECRET set
- # - super_admin user in Postgres with a known password
- # (created by scripts/bootstrap-super-admin.sh)
- #
- # Run:
- # bash scripts/m13a_smoke.sh
- #
- # Exits 0 if all steps pass, non-zero with a summary table on failure.
- set -euo pipefail
- cd "$(dirname "$0")/.."
- AUTHD="${BA_AUTHD_HTTP:-http://127.0.0.1:8804}"
- ADMIND="${BA_ADMIND_HTTP:-http://127.0.0.1:8803}"
- INGESTD="${BA_INGESTD_HTTP:-http://127.0.0.1:8800}"
- ROUTERD="${BA_ROUTERD_HTTP:-http://127.0.0.1:8801}"
- ARCHIVERD="${BA_ARCHIVERD_HTTP:-http://127.0.0.1:8805}"
- DELIVERD_FCM="${BA_DELIVERD_FCM_HTTP:-http://127.0.0.1:8802}"
- DELIVERD_TELEGRAM="${BA_DELIVERD_TELEGRAM_HTTP:-http://127.0.0.1:8821}"
- SUPER_EMAIL="${BA_SMOKE_SUPER_EMAIL:-super@broad-announce.test}"
- SUPER_PASSWORD="${BA_SMOKE_SUPER_PASSWORD:-test-password-123}"
- TENANT_SLUG="${BA_SMOKE_TENANT_SLUG:-acme}"
- INVITE_EMAIL="${BA_SMOKE_INVITE_EMAIL:-invite-$(date +%s)@acme.test}"
- PASS=0
- FAIL=0
- RESULTS=()
- check() {
- local name="$1"
- local actual="$2"
- local want="$3"
- if [[ "$actual" == "$want" ]]; then
- PASS=$((PASS+1))
- RESULTS+=("OK $name")
- else
- FAIL=$((FAIL+1))
- RESULTS+=("FAIL $name (got $actual, want $want)")
- fi
- }
- # ---------------------------------------------------------------------------
- # 1. authd /health
- # ---------------------------------------------------------------------------
- status=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/health")
- check "1. authd /health" "$status" "200"
- # ---------------------------------------------------------------------------
- # 2. login
- # ---------------------------------------------------------------------------
- login_resp=$(curl -s -X POST "$AUTHD/v1/auth/login" \
- -H 'Content-Type: application/json' \
- -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
- login_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/login" \
- -H 'Content-Type: application/json' \
- -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
- check "2. login (super_admin)" "$login_code" "200"
- ACCESS=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))")
- REFRESH=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('refresh_token',''))")
- if [[ -z "$ACCESS" || -z "$REFRESH" ]]; then
- echo "FATAL: login response missing tokens" >&2
- echo "$login_resp" >&2
- exit 1
- fi
- # ---------------------------------------------------------------------------
- # 3. /v1/users/me with Bearer
- # ---------------------------------------------------------------------------
- me_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$AUTHD/v1/users/me")
- check "3. /v1/users/me (with Bearer)" "$me_code" "200"
- me_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/v1/users/me")
- check "3a. /v1/users/me (no Bearer)" "$me_no_auth_code" "401"
- # ---------------------------------------------------------------------------
- # 4. refresh → new pair
- # ---------------------------------------------------------------------------
- refresh_resp=$(curl -s -X POST "$AUTHD/v1/auth/refresh" \
- -H 'Content-Type: application/json' \
- -d "{\"refresh_token\":\"$REFRESH\"}")
- refresh_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/refresh" \
- -H 'Content-Type: application/json' \
- -d "{\"refresh_token\":\"$REFRESH\"}")
- check "4. refresh" "$refresh_code" "200"
- NEW_ACCESS=$(echo "$refresh_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))")
- NEW_REFRESH=$(echo "$refresh_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('refresh_token',''))")
- if [[ "$NEW_ACCESS" == "$ACCESS" || "$NEW_REFRESH" == "$REFRESH" ]]; then
- FAIL=$((FAIL+1))
- RESULTS+=("FAIL 4a. refresh rotated (got same tokens)")
- else
- PASS=$((PASS+1))
- RESULTS+=("OK 4a. refresh rotated (new JTI + new refresh)")
- fi
- # ---------------------------------------------------------------------------
- # 5. re-use OLD refresh → session_killed
- # ---------------------------------------------------------------------------
- reuse_resp=$(curl -s -X POST "$AUTHD/v1/auth/refresh" \
- -H 'Content-Type: application/json' \
- -d "{\"refresh_token\":\"$REFRESH\"}")
- reuse_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/refresh" \
- -H 'Content-Type: application/json' \
- -d "{\"refresh_token\":\"$REFRESH\"}")
- check "5. re-use old refresh" "$reuse_code" "401"
- if echo "$reuse_resp" | grep -q "session_killed"; then
- PASS=$((PASS+1))
- RESULTS+=("OK 5a. reuse → session_killed (family killed)")
- else
- FAIL=$((FAIL+1))
- RESULTS+=("FAIL 5a. reuse → expected 'session_killed', got: $reuse_resp")
- fi
- # After kill, NEW_REFRESH is also revoked
- post_kill_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/refresh" \
- -H 'Content-Type: application/json' \
- -d "{\"refresh_token\":\"$NEW_REFRESH\"}")
- check "5b. new refresh after family kill" "$post_kill_code" "401"
- # ---------------------------------------------------------------------------
- # 6. invite
- # ---------------------------------------------------------------------------
- invite_resp=$(curl -s -X POST "$AUTHD/v1/users/invite" \
- -H "Authorization: Bearer $ACCESS" \
- -H 'Content-Type: application/json' \
- -d "{\"tenant_slug\":\"$TENANT_SLUG\",\"email\":\"$INVITE_EMAIL\",\"role\":\"tenant_admin\"}")
- invite_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/users/invite" \
- -H "Authorization: Bearer $ACCESS" \
- -H 'Content-Type: application/json' \
- -d "{\"tenant_slug\":\"$TENANT_SLUG\",\"email\":\"another-$(date +%s)@acme.test\",\"role\":\"viewer\"}")
- check "6. invite (super_admin)" "$invite_code" "200"
- invite_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/users/invite" \
- -H 'Content-Type: application/json' \
- -d '{"email":"x@y.com","role":"viewer"}')
- check "6a. invite (no Bearer)" "$invite_no_auth_code" "401"
- # ---------------------------------------------------------------------------
- # 7. admind /v1/dlq (gated)
- # ---------------------------------------------------------------------------
- dlq_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" "$ADMIND/v1/dlq")
- check "7. admind /v1/dlq (no auth)" "$dlq_no_auth_code" "401"
- # Need a fresh token (the family was killed above)
- login_resp=$(curl -s -X POST "$AUTHD/v1/auth/login" \
- -H 'Content-Type: application/json' \
- -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
- ACCESS=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
- dlq_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$ADMIND/v1/dlq")
- check "7a. admind /v1/dlq (super_admin Bearer)" "$dlq_code" "200"
- # ---------------------------------------------------------------------------
- # 8. ingestd /v1/admin/ingest (gated)
- # ---------------------------------------------------------------------------
- ingest_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$INGESTD/v1/admin/ingest")
- check "8. ingestd /v1/admin/ingest (no auth)" "$ingest_no_auth_code" "401"
- ingest_auth_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$INGESTD/v1/admin/ingest" \
- -H "Authorization: Bearer $ACCESS" \
- -H 'Content-Type: application/json' \
- -d '{}')
- # We expect 4xx (invalid alert) or 5xx (deps nil), not 401. The point is
- # the gate let us through.
- if [[ "$ingest_auth_code" == "401" ]]; then
- FAIL=$((FAIL+1))
- RESULTS+=("FAIL 8a. ingestd /v1/admin/ingest (auth Bearer) — gate rejected the token")
- else
- PASS=$((PASS+1))
- RESULTS+=("OK 8a. ingestd /v1/admin/ingest (auth Bearer) — gate passed ($ingest_auth_code)")
- fi
- # Need a fresh token for the W5 checks below (the family was killed
- # in step 5; we re-used the OLD access token for the ingestd gate
- # check, but its refresh chain is dead. W5 wants a fresh login).
- login_resp=$(curl -s -X POST "$AUTHD/v1/auth/login" \
- -H 'Content-Type: application/json' \
- -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
- ACCESS=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
- # ---------------------------------------------------------------------------
- # 9. (W5) routerd /v1/admin/dedupe/{state,flush} (gated)
- # ---------------------------------------------------------------------------
- routerd_state_no_auth=$(curl -s -o /dev/null -w "%{http_code}" "$ROUTERD/v1/admin/dedupe/state")
- check "9. routerd /v1/admin/dedupe/state (no auth)" "$routerd_state_no_auth" "401"
- routerd_state_auth=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$ROUTERD/v1/admin/dedupe/state")
- check "9a. routerd /v1/admin/dedupe/state (super_admin Bearer)" "$routerd_state_auth" "200"
- routerd_flush_no_auth=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$ROUTERD/v1/admin/dedupe/flush")
- check "9b. routerd /v1/admin/dedupe/flush (no auth)" "$routerd_flush_no_auth" "401"
- # Need a viewer token to verify the role split. We don't have
- # one handy (the bootstrap script only creates super_admin), so
- # we test the role split with a forged-but-rejected role claim.
- # A JWT signed with the wrong role still passes the gate (any
- # authenticated user can call the read endpoint), but is
- # rejected by RequireRole on the write endpoint.
- routerd_flush_wrong_role=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
- -H "Authorization: Bearer $ACCESS" "$ROUTERD/v1/admin/dedupe/flush")
- # ACCESS is super_admin — should pass.
- check "9c. routerd /v1/admin/dedupe/flush (super_admin Bearer)" "$routerd_flush_wrong_role" "200"
- # ---------------------------------------------------------------------------
- # 10. (W5) archiverd /v1/admin/archiver/run (gated)
- # ---------------------------------------------------------------------------
- archiverd_run_no_auth=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$ARCHIVERD/v1/admin/archiver/run")
- check "10. archiverd /v1/admin/archiver/run (no auth)" "$archiverd_run_no_auth" "401"
- archiverd_run_auth=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
- -H "Authorization: Bearer $ACCESS" "$ARCHIVERD/v1/admin/archiver/run")
- # Expect 200 (triggered=true) or 202 (coalesced). 401 means gate failed.
- if [[ "$archiverd_run_auth" == "401" ]]; then
- FAIL=$((FAIL+1))
- RESULTS+=("FAIL 10a. archiverd /v1/admin/archiver/run (auth Bearer) — gate rejected the token")
- else
- PASS=$((PASS+1))
- RESULTS+=("OK 10a. archiverd /v1/admin/archiver/run (auth Bearer) — gate passed ($archiverd_run_auth)")
- fi
- # ---------------------------------------------------------------------------
- # 11. (W5) deliverd-fcm /v1/admin/dlq (gated, channel=fcm)
- # ---------------------------------------------------------------------------
- fcm_dlq_no_auth=$(curl -s -o /dev/null -w "%{http_code}" "$DELIVERD_FCM/v1/admin/dlq")
- check "11. deliverd-fcm /v1/admin/dlq (no auth)" "$fcm_dlq_no_auth" "401"
- fcm_dlq_body=$(curl -s -H "Authorization: Bearer $ACCESS" "$DELIVERD_FCM/v1/admin/dlq")
- fcm_dlq_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$DELIVERD_FCM/v1/admin/dlq")
- check "11a. deliverd-fcm /v1/admin/dlq (super_admin Bearer)" "$fcm_dlq_code" "200"
- if echo "$fcm_dlq_body" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('channel')=='fcm' else 1)"; then
- PASS=$((PASS+1))
- RESULTS+=("OK 11b. deliverd-fcm /v1/admin/dlq (channel=fcm in body)")
- else
- FAIL=$((FAIL+1))
- RESULTS+=("FAIL 11b. deliverd-fcm /v1/admin/dlq (expected channel=fcm, got: $fcm_dlq_body)")
- fi
- # ---------------------------------------------------------------------------
- # 12. (W5) deliverd-telegram /v1/admin/dlq (gated, channel=telegram)
- # ---------------------------------------------------------------------------
- telegram_dlq_no_auth=$(curl -s -o /dev/null -w "%{http_code}" "$DELIVERD_TELEGRAM/v1/admin/dlq")
- check "12. deliverd-telegram /v1/admin/dlq (no auth)" "$telegram_dlq_no_auth" "401"
- telegram_dlq_body=$(curl -s -H "Authorization: Bearer $ACCESS" "$DELIVERD_TELEGRAM/v1/admin/dlq")
- telegram_dlq_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$DELIVERD_TELEGRAM/v1/admin/dlq")
- check "12a. deliverd-telegram /v1/admin/dlq (super_admin Bearer)" "$telegram_dlq_code" "200"
- if echo "$telegram_dlq_body" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('channel')=='telegram' else 1)"; then
- PASS=$((PASS+1))
- RESULTS+=("OK 12b. deliverd-telegram /v1/admin/dlq (channel=telegram in body)")
- else
- FAIL=$((FAIL+1))
- RESULTS+=("FAIL 12b. deliverd-telegram /v1/admin/dlq (expected channel=telegram, got: $telegram_dlq_body)")
- fi
- # ---------------------------------------------------------------------------
- # Summary
- # ---------------------------------------------------------------------------
- echo
- echo "=== M13a smoke summary ==="
- for r in "${RESULTS[@]}"; do
- echo " $r"
- done
- echo
- echo " $PASS passed, $FAIL failed"
- echo
- if [[ $FAIL -gt 0 ]]; then
- exit 1
- fi
- echo "all M13a smoke checks passed"
|