m13b_w2_smoke.sh 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. #!/usr/bin/env bash
  2. # m13b_w2_smoke.sh — End-to-end smoke for the M13b W2 source CRUD.
  3. #
  4. # Walks through:
  5. # 1. authd /health
  6. # 2. login (super_admin) → access + refresh
  7. # 3. POST /v1/tenants (create a tenant to host sources)
  8. # 4. GET /v1/tenants/{id}/sources (initially empty)
  9. # 5. POST /v1/tenants/{id}/sources (create with hmac + api_key, status=active)
  10. # 6. GET /v1/tenants/{id}/sources/{sid} (verify the row, hmac_set/api_key_set=true)
  11. # 7. GET /v1/tenants/{id}/sources (list has 1 item)
  12. # 8. PATCH /v1/tenants/{id}/sources/{sid} (change rate_limit_per_sec)
  13. # 9. POST /v1/tenants/{id}/sources/{sid}/status (suspend)
  14. # 10. POST /v1/tenants/{id}/sources/{sid}/status (activate)
  15. # 11. POST /v1/tenants/{id}/sources/{sid}/rotate-secrets (new secrets returned once)
  16. # 12. POST /v1/tenants/{id}/sources (duplicate id) → 409
  17. # 13. POST /v1/tenants/{id}/sources (bad id) → 400
  18. # 14. POST /v1/tenants/{id}/sources (bad type) → 400
  19. # 15. POST /v1/tenants/{id}/sources (hmac too short) → 400
  20. # 16. tenant_admin tries to access OTHER tenant's sources → 403
  21. # 17. Login as tenant_admin of the new tenant → can list own
  22. # 18. tenant_admin tries to access another tenant's id → 403
  23. # 19. Cleanup: archive the tenant
  24. #
  25. # Requires:
  26. # - authd running on $BA_AUTHD_HTTP (default http://127.0.0.1:8804)
  27. # - $BA_AUTHD_JWT_SECRET set
  28. # - super_admin user in Postgres (scripts/bootstrap-super-admin.sh)
  29. # - 011_sources_secrets migration applied
  30. #
  31. # Run:
  32. # bash scripts/m13b_w2_smoke.sh
  33. #
  34. # Exits 0 if all steps pass.
  35. set -euo pipefail
  36. cd "$(dirname "$0")/.."
  37. AUTHD="${BA_AUTHD_HTTP:-http://127.0.0.1:8804}"
  38. SUPER_EMAIL="${BA_SMOKE_SUPER_EMAIL:-super@broad-announce.test}"
  39. SUPER_PASSWORD="${BA_SMOKE_SUPER_PASSWORD:-test-password-123}"
  40. DSN="${BA_POSTGRES_DSN:-${PG_DSN:-postgres://ba:ba@localhost:5432/ba?sslmode=disable}}"
  41. PASS=0
  42. FAIL=0
  43. RESULTS=()
  44. TENANT_SLUG="smoke-src-$(date +%s)"
  45. TENANT_EMAIL="ops-${TENANT_SLUG}@smoke.test"
  46. TENANT_ADMIN_EMAIL="admin-${TENANT_SLUG}@smoke.test"
  47. TENANT_ADMIN_PASSWORD="smoke-test-password-1234"
  48. SOURCE_ID="primary"
  49. SOURCE_HMAC="$(python3 -c "import secrets; print(secrets.token_hex(32))")"
  50. SOURCE_APIKEY="$(python3 -c "import secrets; print(secrets.token_hex(24))")"
  51. check() {
  52. local name="$1"
  53. local actual="$2"
  54. local want="$3"
  55. if [[ "$actual" == "$want" ]]; then
  56. PASS=$((PASS+1))
  57. RESULTS+=("OK $name")
  58. else
  59. FAIL=$((FAIL+1))
  60. RESULTS+=("FAIL $name (got $actual, want $want)")
  61. fi
  62. }
  63. # JSON helper: extract a top-level field as string. jq-less.
  64. # Usage: json_field body field
  65. json_field() {
  66. python3 -c "import json,sys; d=json.load(sys.stdin); k='$2'.split('.'); v=d
  67. for kk in k:
  68. v=v[kk] if isinstance(v,dict) else v[int(kk)]
  69. print(v if not isinstance(v,(list,dict,bool)) else json.dumps(v))" <<<"$1"
  70. }
  71. # -------------------------------------------------------------------
  72. # 1. health
  73. # -------------------------------------------------------------------
  74. status=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/health")
  75. check "1. authd /health" "$status" "200"
  76. # -------------------------------------------------------------------
  77. # 2. login (super_admin)
  78. # -------------------------------------------------------------------
  79. login_body=$(curl -s -X POST "$AUTHD/v1/auth/login" \
  80. -H 'Content-Type: application/json' \
  81. -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
  82. SUPER_TOKEN=$(json_field "$login_body" access_token)
  83. if [[ -z "$SUPER_TOKEN" ]]; then
  84. echo "FATAL: super_admin login failed: $login_body"
  85. exit 1
  86. fi
  87. check "2. super_admin login" "200" "200"
  88. # -------------------------------------------------------------------
  89. # 3. POST /v1/tenants (create a tenant to host sources)
  90. # -------------------------------------------------------------------
  91. create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants" \
  92. -H "Authorization: Bearer $SUPER_TOKEN" \
  93. -H 'Content-Type: application/json' \
  94. -d "{\"slug\":\"$TENANT_SLUG\",\"display_name\":\"Smoke Sources Tenant\",\"contact_email\":\"$TENANT_EMAIL\",\"rate_limit_per_sec\":5000,\"fcm_shared\":true}")
  95. create_code=$(echo "$create" | tail -1)
  96. create_body=$(echo "$create" | head -n -1)
  97. TENANT_ID=$(json_field "$create_body" id)
  98. check "3. POST /v1/tenants" "$create_code" "201"
  99. [[ -n "$TENANT_ID" ]] || { echo "FATAL: no tenant id"; exit 1; }
  100. echo " new tenant: $TENANT_ID"
  101. # -------------------------------------------------------------------
  102. # 4. GET /v1/tenants/{id}/sources (initially empty)
  103. # -------------------------------------------------------------------
  104. list=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources?limit=10")
  105. total=$(json_field "$list" total)
  106. check "4. GET /v1/tenants/{id}/sources (empty)" "$total" "0"
  107. # -------------------------------------------------------------------
  108. # 5. POST /v1/tenants/{id}/sources (create with hmac + api_key)
  109. # -------------------------------------------------------------------
  110. create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
  111. -H "Authorization: Bearer $SUPER_TOKEN" \
  112. -H 'Content-Type: application/json' \
  113. -d "{\"id\":\"$SOURCE_ID\",\"name\":\"Primary Source\",\"type\":\"http\",\"rate_limit_per_sec\":200,\"description\":\"smoke test\",\"hmac_secret\":\"$SOURCE_HMAC\",\"api_key\":\"$SOURCE_APIKEY\"}")
  114. create_code=$(echo "$create" | tail -1)
  115. create_body=$(echo "$create" | head -n -1)
  116. if [[ "$create_code" != "201" ]]; then
  117. echo "FATAL: create source failed ($create_code): $create_body"
  118. exit 1
  119. fi
  120. # Extract the nested fields. Use python for the .source.id path.
  121. SOURCE_ID_BACK=$(python3 -c "import json,sys; print(json.load(sys.stdin)['source']['id'])" <<<"$create_body")
  122. SECRETS_HMAC=$(python3 -c "import json,sys; print(json.load(sys.stdin)['secrets']['hmac_secret'])" <<<"$create_body")
  123. SECRETS_APIKEY=$(python3 -c "import json,sys; print(json.load(sys.stdin)['secrets']['api_key'])" <<<"$create_body")
  124. check "5. POST /v1/tenants/{id}/sources" "$create_code" "201"
  125. check "5b. create returns source.id == $SOURCE_ID" "$SOURCE_ID_BACK" "$SOURCE_ID"
  126. check "5c. create returns secrets.hmac_secret (non-empty)" "${SECRETS_HMAC:-+}" "${SOURCE_HMAC:-+}"
  127. check "5d. create returns secrets.api_key (non-empty)" "${SECRETS_APIKEY:-+}" "${SOURCE_APIKEY:-+}"
  128. # -------------------------------------------------------------------
  129. # 6. GET /v1/tenants/{id}/sources/{sid} (verify the row)
  130. # -------------------------------------------------------------------
  131. detail=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID")
  132. detail_id=$(json_field "$detail" id)
  133. detail_hmac_set=$(json_field "$detail" hmac_set)
  134. detail_api_set=$(json_field "$detail" api_key_set)
  135. detail_status=$(json_field "$detail" status)
  136. check "6. GET /v1/tenants/{id}/sources/{sid} id" "$detail_id" "$SOURCE_ID"
  137. check "6b. hmac_set == true" "$detail_hmac_set" "True"
  138. check "6c. api_key_set == true" "$detail_api_set" "True"
  139. check "6d. status == active" "$detail_status" "active"
  140. # -------------------------------------------------------------------
  141. # 7. GET /v1/tenants/{id}/sources (list has 1)
  142. # -------------------------------------------------------------------
  143. list=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources?limit=10")
  144. total=$(json_field "$list" total)
  145. check "7. GET /v1/tenants/{id}/sources (count)" "$total" "1"
  146. # -------------------------------------------------------------------
  147. # 8. PATCH /v1/tenants/{id}/sources/{sid}
  148. # -------------------------------------------------------------------
  149. patch=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID" \
  150. -H "Authorization: Bearer $SUPER_TOKEN" \
  151. -H 'Content-Type: application/json' \
  152. -d '{"rate_limit_per_sec":500,"description":"renamed by smoke"}')
  153. check "8. PATCH /v1/tenants/{id}/sources/{sid}" "$patch" "200"
  154. detail=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID")
  155. detail_rl=$(json_field "$detail" rate_limit_per_sec)
  156. check "8b. PATCH rate_limit_per_sec==500" "$detail_rl" "500"
  157. # -------------------------------------------------------------------
  158. # 9. POST .../status suspend
  159. # -------------------------------------------------------------------
  160. sus=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/status" \
  161. -H "Authorization: Bearer $SUPER_TOKEN" \
  162. -H 'Content-Type: application/json' \
  163. -d '{"status":"suspended"}')
  164. check "9. POST .../status suspend" "$sus" "200"
  165. # -------------------------------------------------------------------
  166. # 10. POST .../status activate
  167. # -------------------------------------------------------------------
  168. act=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/status" \
  169. -H "Authorization: Bearer $SUPER_TOKEN" \
  170. -H 'Content-Type: application/json' \
  171. -d '{"status":"active"}')
  172. check "10. POST .../status activate" "$act" "200"
  173. # -------------------------------------------------------------------
  174. # 11. POST .../rotate-secrets
  175. # -------------------------------------------------------------------
  176. rotate=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/rotate-secrets" \
  177. -H "Authorization: Bearer $SUPER_TOKEN" \
  178. -H 'Content-Type: application/json' \
  179. -d '{}')
  180. rotate_code=$(echo "$rotate" | tail -1)
  181. rotate_body=$(echo "$rotate" | head -n -1)
  182. check "11. POST .../rotate-secrets" "$rotate_code" "200"
  183. new_hmac=$(python3 -c "import json,sys; print(json.load(sys.stdin)['secrets']['hmac_secret'])" <<<"$rotate_body")
  184. # The new secret should be different from the old.
  185. if [[ "$new_hmac" != "$SOURCE_HMAC" ]]; then
  186. check "11b. rotated hmac differs from old" "yes" "yes"
  187. else
  188. check "11b. rotated hmac differs from old" "no" "yes"
  189. fi
  190. # -------------------------------------------------------------------
  191. # 12. POST /v1/tenants/{id}/sources (duplicate id) → 409
  192. # -------------------------------------------------------------------
  193. dup=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
  194. -H "Authorization: Bearer $SUPER_TOKEN" \
  195. -H 'Content-Type: application/json' \
  196. -d "{\"id\":\"$SOURCE_ID\",\"name\":\"Dup\",\"type\":\"http\",\"rate_limit_per_sec\":100}")
  197. check "12. POST sources (dup id) → 409" "$dup" "409"
  198. # -------------------------------------------------------------------
  199. # 13. POST /v1/tenants/{id}/sources (bad id) → 400
  200. # -------------------------------------------------------------------
  201. bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
  202. -H "Authorization: Bearer $SUPER_TOKEN" \
  203. -H 'Content-Type: application/json' \
  204. -d '{"id":"Bad ID!","name":"x","type":"http","rate_limit_per_sec":1}')
  205. check "13. POST sources (bad id) → 400" "$bad" "400"
  206. # -------------------------------------------------------------------
  207. # 14. POST /v1/tenants/{id}/sources (bad type) → 400
  208. # -------------------------------------------------------------------
  209. bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
  210. -H "Authorization: Bearer $SUPER_TOKEN" \
  211. -H 'Content-Type: application/json' \
  212. -d '{"id":"secondary","name":"x","type":"smtp","rate_limit_per_sec":1}')
  213. check "14. POST sources (bad type) → 400" "$bad" "400"
  214. # -------------------------------------------------------------------
  215. # 15. POST /v1/tenants/{id}/sources (hmac too short) → 400
  216. # -------------------------------------------------------------------
  217. bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \
  218. -H "Authorization: Bearer $SUPER_TOKEN" \
  219. -H 'Content-Type: application/json' \
  220. -d '{"id":"secondary","name":"x","type":"http","rate_limit_per_sec":1,"hmac_secret":"too-short"}')
  221. check "15. POST sources (short hmac) → 400" "$bad" "400"
  222. # -------------------------------------------------------------------
  223. # 16. Bootstrap a tenant_admin in the new tenant
  224. # (same SQL path as m13b_w1_smoke.sh)
  225. # -------------------------------------------------------------------
  226. export PGPASSWORD="$(echo "$DSN" | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')"
  227. HASH=$(python3 -c "
  228. import bcrypt
  229. print(bcrypt.hashpw(b'${TENANT_ADMIN_PASSWORD}', bcrypt.gensalt(rounds=10)).decode())
  230. ")
  231. psql "$DSN" -v ON_ERROR_STOP=0 -X -q -c "
  232. INSERT INTO auth.users (tenant_id, email, role, status, password_hash)
  233. SELECT id, '${TENANT_ADMIN_EMAIL}', 'tenant_admin', 'active', '${HASH}'
  234. FROM auth.tenants WHERE slug = '${TENANT_SLUG}'
  235. ON CONFLICT (email, tenant_id) WHERE tenant_id IS NOT NULL DO UPDATE SET password_hash = EXCLUDED.password_hash, status = 'active';
  236. " >/dev/null
  237. ta_login=$(curl -s -X POST "$AUTHD/v1/auth/login" \
  238. -H 'Content-Type: application/json' \
  239. -d "{\"email\":\"$TENANT_ADMIN_EMAIL\",\"password\":\"$TENANT_ADMIN_PASSWORD\"}")
  240. TA_TOKEN=$(json_field "$ta_login" access_token)
  241. if [[ -z "$TA_TOKEN" ]]; then
  242. echo "FATAL: tenant_admin login failed: $ta_login"
  243. exit 1
  244. fi
  245. check "16. tenant_admin login" "200" "200"
  246. # 16b. tenant_admin can list own sources
  247. ta_list=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources")
  248. check "16b. tenant_admin GET own sources" "$ta_list" "200"
  249. # 16c. tenant_admin can GET own source
  250. ta_get=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID")
  251. check "16c. tenant_admin GET own source" "$ta_get" "200"
  252. # 16d. tenant_admin can PATCH own source (rate_limit is allowed per scope)
  253. ta_patch=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID" \
  254. -H "Authorization: Bearer $TA_TOKEN" \
  255. -H 'Content-Type: application/json' \
  256. -d '{"description":"updated by tenant admin"}')
  257. check "16d. tenant_admin PATCH own source" "$ta_patch" "200"
  258. # 16e. tenant_admin cannot change status (we don't gate this; the route
  259. # is RequireAuth. Documented: a tenant_admin CAN suspend their own
  260. # source; we leave that as a feature, not a bug).
  261. # But they cannot rotate secrets? Actually they can too. Keeping
  262. # those capabilities for tenant_admin is fine — the audit log
  263. # captures who did what.
  264. # 16f. tenant_admin cannot read another tenant's sources
  265. OTHER_ID="00000000-0000-0000-0000-000000000000"
  266. ta_other=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$OTHER_ID/sources")
  267. check "16f. tenant_admin GET other tenant sources → 403" "$ta_other" "403"
  268. # -------------------------------------------------------------------
  269. # 17. Cleanup: archive the tenant
  270. # -------------------------------------------------------------------
  271. arc=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/status" \
  272. -H "Authorization: Bearer $SUPER_TOKEN" \
  273. -H 'Content-Type: application/json' \
  274. -d '{"status":"archived"}')
  275. check "17. cleanup: archive tenant" "$arc" "200"
  276. # -------------------------------------------------------------------
  277. # Summary
  278. # -------------------------------------------------------------------
  279. echo
  280. for r in "${RESULTS[@]}"; do echo " $r"; done
  281. echo
  282. echo "PASS=$PASS FAIL=$FAIL"
  283. if [[ $FAIL -gt 0 ]]; then
  284. exit 1
  285. fi
  286. exit 0