m13a_smoke.sh 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. #!/usr/bin/env bash
  2. # m13a_smoke.sh — End-to-end smoke for the M13a auth gate.
  3. #
  4. # Walks through:
  5. # 1. authd /health and /metrics
  6. # 2. login (super_admin) → access + refresh
  7. # 3. /v1/users/me with Bearer → user info
  8. # 4. refresh → new pair (rotated)
  9. # 5. re-use OLD refresh → session_killed
  10. # 6. invite → magic-link token (will not email; we just verify
  11. # the route is wired)
  12. # 7. admind /v1/dlq (gated): no token → 401, valid token → 200
  13. # 8. ingestd /v1/admin/ingest (gated): no token → 401, valid
  14. # token → 200 (we don't check the full ingest pipeline here,
  15. # just that the gate lets the request through)
  16. # 9. (W5) routerd /v1/admin/dedupe/{state,flush} (gated):
  17. # no token → 401, viewer can read state but not flush
  18. # 10. (W5) archiverd /v1/admin/archiver/run (gated):
  19. # no token → 401, super_admin → 200
  20. # 11. (W5) deliverd-fcm /v1/admin/dlq (gated, channel=fcm):
  21. # no token → 401, with auth → 200, channel label correct
  22. # 12. (W5) deliverd-telegram /v1/admin/dlq (gated, channel=telegram):
  23. # no token → 401, with auth → 200, channel label correct
  24. #
  25. # Requires:
  26. # - authd running on $BA_AUTHD_HTTP (default http://127.0.0.1:8804)
  27. # - admind running on $BA_ADMIND_HTTP (default http://127.0.0.1:8803)
  28. # - ingestd running on $BA_INGESTD_HTTP (default http://127.0.0.1:8800)
  29. # - routerd running on $BA_ROUTERD_HTTP (default http://127.0.0.1:8801)
  30. # - archiverd running on $BA_ARCHIVERD_HTTP (default http://127.0.0.1:8805)
  31. # - deliverd-fcm running on $BA_DELIVERD_FCM_HTTP (default http://127.0.0.1:8802)
  32. # - deliverd-telegram running on $BA_DELIVERD_TELEGRAM_HTTP (default http://127.0.0.1:8821)
  33. # - $BA_AUTHD_JWT_SECRET set
  34. # - super_admin user in Postgres with a known password
  35. # (created by scripts/bootstrap-super-admin.sh)
  36. #
  37. # Run:
  38. # bash scripts/m13a_smoke.sh
  39. #
  40. # Exits 0 if all steps pass, non-zero with a summary table on failure.
  41. set -euo pipefail
  42. cd "$(dirname "$0")/.."
  43. AUTHD="${BA_AUTHD_HTTP:-http://127.0.0.1:8804}"
  44. ADMIND="${BA_ADMIND_HTTP:-http://127.0.0.1:8803}"
  45. INGESTD="${BA_INGESTD_HTTP:-http://127.0.0.1:8800}"
  46. ROUTERD="${BA_ROUTERD_HTTP:-http://127.0.0.1:8801}"
  47. ARCHIVERD="${BA_ARCHIVERD_HTTP:-http://127.0.0.1:8805}"
  48. DELIVERD_FCM="${BA_DELIVERD_FCM_HTTP:-http://127.0.0.1:8802}"
  49. DELIVERD_TELEGRAM="${BA_DELIVERD_TELEGRAM_HTTP:-http://127.0.0.1:8821}"
  50. SUPER_EMAIL="${BA_SMOKE_SUPER_EMAIL:-super@broad-announce.test}"
  51. SUPER_PASSWORD="${BA_SMOKE_SUPER_PASSWORD:-test-password-123}"
  52. TENANT_SLUG="${BA_SMOKE_TENANT_SLUG:-acme}"
  53. INVITE_EMAIL="${BA_SMOKE_INVITE_EMAIL:-invite-$(date +%s)@acme.test}"
  54. PASS=0
  55. FAIL=0
  56. RESULTS=()
  57. check() {
  58. local name="$1"
  59. local actual="$2"
  60. local want="$3"
  61. if [[ "$actual" == "$want" ]]; then
  62. PASS=$((PASS+1))
  63. RESULTS+=("OK $name")
  64. else
  65. FAIL=$((FAIL+1))
  66. RESULTS+=("FAIL $name (got $actual, want $want)")
  67. fi
  68. }
  69. # ---------------------------------------------------------------------------
  70. # 1. authd /health
  71. # ---------------------------------------------------------------------------
  72. status=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/health")
  73. check "1. authd /health" "$status" "200"
  74. # ---------------------------------------------------------------------------
  75. # 2. login
  76. # ---------------------------------------------------------------------------
  77. login_resp=$(curl -s -X POST "$AUTHD/v1/auth/login" \
  78. -H 'Content-Type: application/json' \
  79. -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
  80. login_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/login" \
  81. -H 'Content-Type: application/json' \
  82. -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
  83. check "2. login (super_admin)" "$login_code" "200"
  84. ACCESS=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))")
  85. REFRESH=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('refresh_token',''))")
  86. if [[ -z "$ACCESS" || -z "$REFRESH" ]]; then
  87. echo "FATAL: login response missing tokens" >&2
  88. echo "$login_resp" >&2
  89. exit 1
  90. fi
  91. # ---------------------------------------------------------------------------
  92. # 3. /v1/users/me with Bearer
  93. # ---------------------------------------------------------------------------
  94. me_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$AUTHD/v1/users/me")
  95. check "3. /v1/users/me (with Bearer)" "$me_code" "200"
  96. me_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/v1/users/me")
  97. check "3a. /v1/users/me (no Bearer)" "$me_no_auth_code" "401"
  98. # ---------------------------------------------------------------------------
  99. # 4. refresh → new pair
  100. # ---------------------------------------------------------------------------
  101. refresh_resp=$(curl -s -X POST "$AUTHD/v1/auth/refresh" \
  102. -H 'Content-Type: application/json' \
  103. -d "{\"refresh_token\":\"$REFRESH\"}")
  104. refresh_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/refresh" \
  105. -H 'Content-Type: application/json' \
  106. -d "{\"refresh_token\":\"$REFRESH\"}")
  107. check "4. refresh" "$refresh_code" "200"
  108. NEW_ACCESS=$(echo "$refresh_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))")
  109. NEW_REFRESH=$(echo "$refresh_resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('refresh_token',''))")
  110. if [[ "$NEW_ACCESS" == "$ACCESS" || "$NEW_REFRESH" == "$REFRESH" ]]; then
  111. FAIL=$((FAIL+1))
  112. RESULTS+=("FAIL 4a. refresh rotated (got same tokens)")
  113. else
  114. PASS=$((PASS+1))
  115. RESULTS+=("OK 4a. refresh rotated (new JTI + new refresh)")
  116. fi
  117. # ---------------------------------------------------------------------------
  118. # 5. re-use OLD refresh → session_killed
  119. # ---------------------------------------------------------------------------
  120. reuse_resp=$(curl -s -X POST "$AUTHD/v1/auth/refresh" \
  121. -H 'Content-Type: application/json' \
  122. -d "{\"refresh_token\":\"$REFRESH\"}")
  123. reuse_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/refresh" \
  124. -H 'Content-Type: application/json' \
  125. -d "{\"refresh_token\":\"$REFRESH\"}")
  126. check "5. re-use old refresh" "$reuse_code" "401"
  127. if echo "$reuse_resp" | grep -q "session_killed"; then
  128. PASS=$((PASS+1))
  129. RESULTS+=("OK 5a. reuse → session_killed (family killed)")
  130. else
  131. FAIL=$((FAIL+1))
  132. RESULTS+=("FAIL 5a. reuse → expected 'session_killed', got: $reuse_resp")
  133. fi
  134. # After kill, NEW_REFRESH is also revoked
  135. post_kill_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/auth/refresh" \
  136. -H 'Content-Type: application/json' \
  137. -d "{\"refresh_token\":\"$NEW_REFRESH\"}")
  138. check "5b. new refresh after family kill" "$post_kill_code" "401"
  139. # ---------------------------------------------------------------------------
  140. # 6. invite
  141. # ---------------------------------------------------------------------------
  142. invite_resp=$(curl -s -X POST "$AUTHD/v1/users/invite" \
  143. -H "Authorization: Bearer $ACCESS" \
  144. -H 'Content-Type: application/json' \
  145. -d "{\"tenant_slug\":\"$TENANT_SLUG\",\"email\":\"$INVITE_EMAIL\",\"role\":\"tenant_admin\"}")
  146. invite_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/users/invite" \
  147. -H "Authorization: Bearer $ACCESS" \
  148. -H 'Content-Type: application/json' \
  149. -d "{\"tenant_slug\":\"$TENANT_SLUG\",\"email\":\"another-$(date +%s)@acme.test\",\"role\":\"viewer\"}")
  150. check "6. invite (super_admin)" "$invite_code" "200"
  151. invite_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/users/invite" \
  152. -H 'Content-Type: application/json' \
  153. -d '{"email":"x@y.com","role":"viewer"}')
  154. check "6a. invite (no Bearer)" "$invite_no_auth_code" "401"
  155. # ---------------------------------------------------------------------------
  156. # 7. admind /v1/dlq (gated)
  157. # ---------------------------------------------------------------------------
  158. dlq_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" "$ADMIND/v1/dlq")
  159. check "7. admind /v1/dlq (no auth)" "$dlq_no_auth_code" "401"
  160. # Need a fresh token (the family was killed above)
  161. login_resp=$(curl -s -X POST "$AUTHD/v1/auth/login" \
  162. -H 'Content-Type: application/json' \
  163. -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
  164. ACCESS=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
  165. dlq_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$ADMIND/v1/dlq")
  166. check "7a. admind /v1/dlq (super_admin Bearer)" "$dlq_code" "200"
  167. # ---------------------------------------------------------------------------
  168. # 8. ingestd /v1/admin/ingest (gated)
  169. # ---------------------------------------------------------------------------
  170. ingest_no_auth_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$INGESTD/v1/admin/ingest")
  171. check "8. ingestd /v1/admin/ingest (no auth)" "$ingest_no_auth_code" "401"
  172. ingest_auth_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$INGESTD/v1/admin/ingest" \
  173. -H "Authorization: Bearer $ACCESS" \
  174. -H 'Content-Type: application/json' \
  175. -d '{}')
  176. # We expect 4xx (invalid alert) or 5xx (deps nil), not 401. The point is
  177. # the gate let us through.
  178. if [[ "$ingest_auth_code" == "401" ]]; then
  179. FAIL=$((FAIL+1))
  180. RESULTS+=("FAIL 8a. ingestd /v1/admin/ingest (auth Bearer) — gate rejected the token")
  181. else
  182. PASS=$((PASS+1))
  183. RESULTS+=("OK 8a. ingestd /v1/admin/ingest (auth Bearer) — gate passed ($ingest_auth_code)")
  184. fi
  185. # Need a fresh token for the W5 checks below (the family was killed
  186. # in step 5; we re-used the OLD access token for the ingestd gate
  187. # check, but its refresh chain is dead. W5 wants a fresh login).
  188. login_resp=$(curl -s -X POST "$AUTHD/v1/auth/login" \
  189. -H 'Content-Type: application/json' \
  190. -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
  191. ACCESS=$(echo "$login_resp" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
  192. # ---------------------------------------------------------------------------
  193. # 9. (W5) routerd /v1/admin/dedupe/{state,flush} (gated)
  194. # ---------------------------------------------------------------------------
  195. routerd_state_no_auth=$(curl -s -o /dev/null -w "%{http_code}" "$ROUTERD/v1/admin/dedupe/state")
  196. check "9. routerd /v1/admin/dedupe/state (no auth)" "$routerd_state_no_auth" "401"
  197. routerd_state_auth=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$ROUTERD/v1/admin/dedupe/state")
  198. check "9a. routerd /v1/admin/dedupe/state (super_admin Bearer)" "$routerd_state_auth" "200"
  199. routerd_flush_no_auth=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$ROUTERD/v1/admin/dedupe/flush")
  200. check "9b. routerd /v1/admin/dedupe/flush (no auth)" "$routerd_flush_no_auth" "401"
  201. # Need a viewer token to verify the role split. We don't have
  202. # one handy (the bootstrap script only creates super_admin), so
  203. # we test the role split with a forged-but-rejected role claim.
  204. # A JWT signed with the wrong role still passes the gate (any
  205. # authenticated user can call the read endpoint), but is
  206. # rejected by RequireRole on the write endpoint.
  207. routerd_flush_wrong_role=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  208. -H "Authorization: Bearer $ACCESS" "$ROUTERD/v1/admin/dedupe/flush")
  209. # ACCESS is super_admin — should pass.
  210. check "9c. routerd /v1/admin/dedupe/flush (super_admin Bearer)" "$routerd_flush_wrong_role" "200"
  211. # ---------------------------------------------------------------------------
  212. # 10. (W5) archiverd /v1/admin/archiver/run (gated)
  213. # ---------------------------------------------------------------------------
  214. archiverd_run_no_auth=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$ARCHIVERD/v1/admin/archiver/run")
  215. check "10. archiverd /v1/admin/archiver/run (no auth)" "$archiverd_run_no_auth" "401"
  216. archiverd_run_auth=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  217. -H "Authorization: Bearer $ACCESS" "$ARCHIVERD/v1/admin/archiver/run")
  218. # Expect 200 (triggered=true) or 202 (coalesced). 401 means gate failed.
  219. if [[ "$archiverd_run_auth" == "401" ]]; then
  220. FAIL=$((FAIL+1))
  221. RESULTS+=("FAIL 10a. archiverd /v1/admin/archiver/run (auth Bearer) — gate rejected the token")
  222. else
  223. PASS=$((PASS+1))
  224. RESULTS+=("OK 10a. archiverd /v1/admin/archiver/run (auth Bearer) — gate passed ($archiverd_run_auth)")
  225. fi
  226. # ---------------------------------------------------------------------------
  227. # 11. (W5) deliverd-fcm /v1/admin/dlq (gated, channel=fcm)
  228. # ---------------------------------------------------------------------------
  229. fcm_dlq_no_auth=$(curl -s -o /dev/null -w "%{http_code}" "$DELIVERD_FCM/v1/admin/dlq")
  230. check "11. deliverd-fcm /v1/admin/dlq (no auth)" "$fcm_dlq_no_auth" "401"
  231. fcm_dlq_body=$(curl -s -H "Authorization: Bearer $ACCESS" "$DELIVERD_FCM/v1/admin/dlq")
  232. fcm_dlq_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$DELIVERD_FCM/v1/admin/dlq")
  233. check "11a. deliverd-fcm /v1/admin/dlq (super_admin Bearer)" "$fcm_dlq_code" "200"
  234. 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
  235. PASS=$((PASS+1))
  236. RESULTS+=("OK 11b. deliverd-fcm /v1/admin/dlq (channel=fcm in body)")
  237. else
  238. FAIL=$((FAIL+1))
  239. RESULTS+=("FAIL 11b. deliverd-fcm /v1/admin/dlq (expected channel=fcm, got: $fcm_dlq_body)")
  240. fi
  241. # ---------------------------------------------------------------------------
  242. # 12. (W5) deliverd-telegram /v1/admin/dlq (gated, channel=telegram)
  243. # ---------------------------------------------------------------------------
  244. telegram_dlq_no_auth=$(curl -s -o /dev/null -w "%{http_code}" "$DELIVERD_TELEGRAM/v1/admin/dlq")
  245. check "12. deliverd-telegram /v1/admin/dlq (no auth)" "$telegram_dlq_no_auth" "401"
  246. telegram_dlq_body=$(curl -s -H "Authorization: Bearer $ACCESS" "$DELIVERD_TELEGRAM/v1/admin/dlq")
  247. telegram_dlq_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS" "$DELIVERD_TELEGRAM/v1/admin/dlq")
  248. check "12a. deliverd-telegram /v1/admin/dlq (super_admin Bearer)" "$telegram_dlq_code" "200"
  249. 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
  250. PASS=$((PASS+1))
  251. RESULTS+=("OK 12b. deliverd-telegram /v1/admin/dlq (channel=telegram in body)")
  252. else
  253. FAIL=$((FAIL+1))
  254. RESULTS+=("FAIL 12b. deliverd-telegram /v1/admin/dlq (expected channel=telegram, got: $telegram_dlq_body)")
  255. fi
  256. # ---------------------------------------------------------------------------
  257. # Summary
  258. # ---------------------------------------------------------------------------
  259. echo
  260. echo "=== M13a smoke summary ==="
  261. for r in "${RESULTS[@]}"; do
  262. echo " $r"
  263. done
  264. echo
  265. echo " $PASS passed, $FAIL failed"
  266. echo
  267. if [[ $FAIL -gt 0 ]]; then
  268. exit 1
  269. fi
  270. echo "all M13a smoke checks passed"