#!/usr/bin/env bash # m13b_smoke.sh — End-to-end smoke for the M13b admin UI suite. # # Walks through all three M13b modules on a single tenant: # 1. authd /health # 2. super_admin login # 3. create tenant # 4. bootstrap tenant_admin (via SQL, same pattern as W1/W2/W3) # 5. tenant_admin login # 6. create source (super_admin) — capture hmac_secret # 7. send 1 alert via ingestd with the source's HMAC [CONDITIONAL: needs ingestd] # 8. list sources for the tenant → expect 1 # 9. suspend the source # 10. send 1 alert with suspended source → expect 401 [CONDITIONAL: needs ingestd] # 11. tenant_admin reads own tenant # 12. tenant_admin tries to access another tenant → 403 (cross-tenant isolation) # 13. tenant_admin tries to access OTHER tenant's source → 403 # 14. tenant_admin tries to create source on own tenant → 403 (per W2) # 15. create telegram bot (super_admin) on this tenant # 16. generate invite (super_admin) → expect 200 with magic_link_token # 17. cleanup: archive tenant # # The per-workstream smokes (scripts/m13b_w1_smoke.sh, # scripts/m13b_w2_smoke.sh, scripts/m13b_w3_smoke.sh) cover each # module's CRUD surface exhaustively. This smoke is the # integration test: same operator flow that a real admin would # take, on one tenant, hitting all three modules. # # Requires: # - authd running on $BA_AUTHD_HTTP (default http://127.0.0.1:8804) # - ingestd running on $BA_INGESTD_HTTP (default http://127.0.0.1:8800) # — if not reachable, steps 7 and 10 are skipped with a # warning. The smoke still passes because CRUD (steps 1-6, # 8-9, 11-17) doesn't depend on the alert pipeline. # - $BA_AUTHD_JWT_SECRET set # - super_admin user in Postgres (scripts/bootstrap-super-admin.sh) # - migrations 009, 010, 011, 012 applied # # Run: # bash scripts/m13b_smoke.sh # # Exits 0 if all runnable steps pass. set -euo pipefail cd "$(dirname "$0")/.." AUTHD="${BA_AUTHD_HTTP:-http://127.0.0.1:8804}" INGESTD="${BA_INGESTD_HTTP:-http://127.0.0.1:8800}" SUPER_EMAIL="${BA_SMOKE_SUPER_EMAIL:-super@broad-announce.test}" SUPER_PASSWORD="${BA_SMOKE_SUPER_PASSWORD:-test-password-123}" DSN="${BA_POSTGRES_DSN:-${PG_DSN:-postgres://ba:ba@localhost:5432/ba?sslmode=disable}}" PASS=0 FAIL=0 SKIP=0 RESULTS=() # Tag the run so two concurrent smokes don't collide on the slug RUN_TAG="$(date +%s)-$$" TENANT_SLUG="m13b-smoke-${RUN_TAG}" TENANT_DISPLAY="M13b Smoke ${RUN_TAG}" TENANT_EMAIL="ops-${RUN_TAG}@smoke.test" TENANT_ADMIN_EMAIL="admin-${TENANT_SLUG}@smoke.test" TENANT_ADMIN_PASSWORD="smoke-test-password-1234" SOURCE_ID="primary" SOURCE_HMAC="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" SOURCE_APIKEY="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" BOT_ID="primary" BOT_TOKEN="12345678:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 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 } skip() { local name="$1" local reason="$2" SKIP=$((SKIP+1)) RESULTS+=("SKIP $name ($reason)") } json_field() { echo "$1" | python3 -c "import json,sys; d=json.load(sys.stdin); k='$2'.split('.'); v=d for kk in k: v=v[kk] if isinstance(v,dict) else v[int(kk)] print(v if not isinstance(v,(list,dict,bool)) else json.dumps(v))" } # Detect ingestd once; reuse below. INGESTD_REACHABLE=false if curl -s -o /dev/null -m 2 -w '%{http_code}' "$INGESTD/health" 2>/dev/null | grep -q '^2'; then INGESTD_REACHABLE=true fi # ------------------------------------------------------------------- # 1. health # ------------------------------------------------------------------- status=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/health") check "1. authd /health" "$status" "200" # ------------------------------------------------------------------- # 2. super_admin login # ------------------------------------------------------------------- login_body=$(curl -s -X POST "$AUTHD/v1/auth/login" \ -H 'Content-Type: application/json' \ -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}") SUPER_TOKEN=$(json_field "$login_body" access_token) if [[ -z "$SUPER_TOKEN" ]]; then echo "FATAL: super_admin login failed: $login_body" exit 1 fi check "2. super_admin login" "200" "200" # ------------------------------------------------------------------- # 3. create tenant # ------------------------------------------------------------------- create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants" \ -H "Authorization: Bearer $SUPER_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"slug\":\"$TENANT_SLUG\",\"display_name\":\"$TENANT_DISPLAY\",\"contact_email\":\"ops-${TENANT_SLUG}@smoke.test\",\"rate_limit_per_sec\":5000,\"fcm_shared\":true}") create_code=$(echo "$create" | tail -1) create_body=$(echo "$create" | head -n -1) TENANT_ID=$(json_field "$create_body" id) check "3. POST /v1/tenants" "$create_code" "201" [[ -n "$TENANT_ID" ]] || { echo "FATAL: no tenant id"; exit 1; } echo " tenant: $TENANT_ID ($TENANT_SLUG)" # ------------------------------------------------------------------- # 4. bootstrap tenant_admin (SQL path — same as per-W smokes) # ------------------------------------------------------------------- export PGPASSWORD="$(echo "$DSN" | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" HASH=$(python3 -c " import bcrypt print(bcrypt.hashpw(b'${TENANT_ADMIN_PASSWORD}', bcrypt.gensalt(rounds=10)).decode()) ") psql "$DSN" -v ON_ERROR_STOP=0 -X -q -c " INSERT INTO auth.users (tenant_id, email, role, status, password_hash) SELECT id, '${TENANT_ADMIN_EMAIL}', 'tenant_admin', 'active', '${HASH}' FROM auth.tenants WHERE slug = '${TENANT_SLUG}' ON CONFLICT (email, tenant_id) WHERE tenant_id IS NOT NULL DO UPDATE SET password_hash = EXCLUDED.password_hash, status = 'active'; " >/dev/null check "4. tenant_admin upserted (SQL)" "200" "200" # ------------------------------------------------------------------- # 5. tenant_admin login # ------------------------------------------------------------------- ta_login=$(curl -s -X POST "$AUTHD/v1/auth/login" \ -H 'Content-Type: application/json' \ -d "{\"email\":\"$TENANT_ADMIN_EMAIL\",\"password\":\"$TENANT_ADMIN_PASSWORD\"}") TA_TOKEN=$(json_field "$ta_login" access_token) if [[ -z "$TA_TOKEN" ]]; then echo "FATAL: tenant_admin login failed: $ta_login" exit 1 fi check "5. tenant_admin login" "200" "200" # ------------------------------------------------------------------- # 6. create source (super_admin) # ------------------------------------------------------------------- src_create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \ -H "Authorization: Bearer $SUPER_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"id\":\"$SOURCE_ID\",\"name\":\"M13b Smoke Source\",\"type\":\"http\",\"hmac_secret\":\"$SOURCE_HMAC\",\"api_key\":\"$SOURCE_APIKEY\",\"rate_limit_per_sec\":100}") src_code=$(echo "$src_create" | tail -1) src_body=$(echo "$src_create" | head -n -1) check "6. POST /v1/tenants/{id}/sources" "$src_code" "201" # ------------------------------------------------------------------- # 7. send 1 alert via ingestd [CONDITIONAL] # ------------------------------------------------------------------- if $INGESTD_REACHABLE; then # POST /v1/ingest accepts a signed body keyed by source HMAC. # The exact payload shape is owned by ingestd; the W4 smoke # just verifies the auth-source path is wired end-to-end. ingest_body="{\"tenant_id\":\"$TENANT_ID\",\"source_id\":\"$SOURCE_ID\",\"message\":\"hello from m13b smoke\"}" ingest_sig=$(printf '%s' "$ingest_body" | openssl dgst -sha256 -hmac "$SOURCE_HMAC" -hex | awk '{print $2}') ingest_resp=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$INGESTD/v1/ingest" \ -H 'Content-Type: application/json' \ -H "X-BA-Tenant: $TENANT_ID" \ -H "X-BA-Source: $SOURCE_ID" \ -H "X-BA-Signature: $ingest_sig" \ -d "$ingest_body") # ingestd returns 202 for accepted (status:"ok") and 4xx for # auth/signature failures. Anything 2xx counts as wired. if [[ "$ingest_resp" =~ ^2 ]]; then check "7. POST /v1/ingest (HMAC-signed, accepted)" "200" "200" else check "7. POST /v1/ingest (HMAC-signed, accepted)" "$ingest_resp" "202-or-200" fi else skip "7. POST /v1/ingest" "ingestd not reachable at $INGESTD" fi # ------------------------------------------------------------------- # 8. list sources (expect 1) # ------------------------------------------------------------------- list=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/sources?limit=10") total=$(json_field "$list" total) check "8. GET /v1/tenants/{id}/sources" "$total" "1" # Note: alerts_24h is on the W4 plan as an assertion but the # field isn't wired yet (no alerts counter on the source row). # Adding it requires an alerts_24h view or column; tracked for v1.1. # ------------------------------------------------------------------- # 9. suspend the source # ------------------------------------------------------------------- sus=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/status" \ -H "Authorization: Bearer $SUPER_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"status":"suspended"}') check "9. POST .../sources/{sid}/status suspend" "$sus" "200" # ------------------------------------------------------------------- # 10. send alert with suspended source → expect 401 [CONDITIONAL] # ------------------------------------------------------------------- if $INGESTD_REACHABLE; then ingest_body2="{\"tenant_id\":\"$TENANT_ID\",\"source_id\":\"$SOURCE_ID\",\"message\":\"after suspend\"}" ingest_sig2=$(printf '%s' "$ingest_body2" | openssl dgst -sha256 -hmac "$SOURCE_HMAC" -hex | awk '{print $2}') ingest_resp2=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$INGESTD/v1/ingest" \ -H 'Content-Type: application/json' \ -H "X-BA-Tenant: $TENANT_ID" \ -H "X-BA-Source: $SOURCE_ID" \ -H "X-BA-Signature: $ingest_sig2" \ -d "$ingest_body2") check "10. POST /v1/ingest (suspended source rejected)" "$ingest_resp2" "401" else skip "10. POST /v1/ingest (suspended)" "ingestd not reachable" fi # Reactivate so step 15 (cross-tenant source 403) operates on an # active source (otherwise the 403 path is muddied by suspended # status). Activation isn't a step on its own. curl -s -o /dev/null -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources/$SOURCE_ID/status" \ -H "Authorization: Bearer $SUPER_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"status":"active"}' # ------------------------------------------------------------------- # 11. tenant_admin reads own tenant # ------------------------------------------------------------------- ta_get=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID") check "11. tenant_admin GET own tenant" "$ta_get" "200" # ------------------------------------------------------------------- # 12. tenant_admin tries to access another tenant → 403 # ------------------------------------------------------------------- fake_id="00000000-0000-0000-0000-000000000000" ta_other=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$fake_id") check "12. tenant_admin GET other tenant (cross-tenant)" "$ta_other" "403" # ------------------------------------------------------------------- # 13. tenant_admin tries to read OTHER tenant's source → 403 # (use the same fake tenant id; cross-tenant scope check # fails BEFORE the source lookup) # ------------------------------------------------------------------- ta_src=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$fake_id/sources/$SOURCE_ID") check "13. tenant_admin GET other tenant's source" "$ta_src" "403" # ------------------------------------------------------------------- # 14. tenant_admin tries to create a source on OTHER tenant → 403 # (cross-tenant scope check fires BEFORE validation; this is # the security guarantee W2 promises and W4 re-asserts) # ------------------------------------------------------------------- ta_src_create=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$AUTHD/v1/tenants/$fake_id/sources" \ -H "Authorization: Bearer $TA_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"id\":\"secondary\",\"name\":\"Cross-tenant attempt\",\"type\":\"http\",\"hmac_secret\":\"$SOURCE_HMAC\"}") check "14. tenant_admin POST sources on other tenant (cross-tenant)" "$ta_src_create" "403" # 14b. tenant_admin CAN create a source on own tenant (per W2: any auth, # per-tenant scope). This is the green-path side of the same gate. ta_own_src=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$AUTHD/v1/tenants/$TENANT_ID/sources" \ -H "Authorization: Bearer $TA_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"id\":\"tenant-admin-source\",\"name\":\"Owned by tenant_admin\",\"type\":\"http\",\"hmac_secret\":\"$SOURCE_HMAC\",\"rate_limit_per_sec\":50}") check "14b. tenant_admin POST sources on own tenant (allowed)" "$ta_own_src" "201" # ------------------------------------------------------------------- # 15. create telegram bot (super_admin) # ------------------------------------------------------------------- bot_create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots" \ -H "Authorization: Bearer $SUPER_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"id\":\"$BOT_ID\",\"name\":\"Smoke Bot\",\"bot_token\":\"$BOT_TOKEN\",\"welcome_message\":\"hi\",\"description\":\"m13b smoke\"}") bot_code=$(echo "$bot_create" | tail -1) check "15. POST /v1/tenants/{id}/telegram/bots" "$bot_code" "201" # 15b. bot_token is write-only: response MUST NOT echo the plaintext. bot_body=$(echo "$bot_create" | head -n -1) if echo "$bot_body" | grep -q "\"bot_token\""; then check "15b. bot_token NOT in response" "absent" "present" else check "15b. bot_token NOT in response" "absent" "absent" fi # 15c. tenant_admin tries to create a telegram bot → 403 ta_bot=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots" \ -H "Authorization: Bearer $TA_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"id\":\"x\",\"name\":\"x\",\"bot_token\":\"$BOT_TOKEN\",\"status\":\"active\"}") check "15c. tenant_admin POST telegram/bots (forbidden)" "$ta_bot" "403" # ------------------------------------------------------------------- # 16. generate invite (super_admin) → 200 with magic_link_token # (the W4 plan called for "expect 201"; authd returns 200 here. # A GET /v1/users/invites list endpoint is NOT yet wired — it's # a v1.1 follow-up. We assert the create response has the # magic_link_token, which is the useful invariant.) # ------------------------------------------------------------------- invite_body=$(curl -s -X POST "$AUTHD/v1/users/invite" \ -H "Authorization: Bearer $SUPER_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"tenant_slug\":\"$TENANT_SLUG\",\"email\":\"newbie-${RUN_TAG}@smoke.test\",\"role\":\"viewer\"}") INVITE_TOKEN=$(json_field "$invite_body" magic_link_token) INVITE_USER=$(json_field "$invite_body" user_id) if [[ -n "$INVITE_TOKEN" && "$INVITE_TOKEN" != "None" ]]; then check "16. POST /v1/users/invite → magic_link_token issued" "200" "200" else check "16. POST /v1/users/invite → magic_link_token issued" "absent" "present" fi echo " invite: user=$INVITE_USER token=${INVITE_TOKEN:0:16}..." # ------------------------------------------------------------------- # 17. cleanup: archive tenant # ------------------------------------------------------------------- arch=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$AUTHD/v1/tenants/$TENANT_ID/status" \ -H "Authorization: Bearer $SUPER_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"status":"archived"}') check "17. cleanup: archive tenant" "$arch" "200" # ------------------------------------------------------------------- # summary # ------------------------------------------------------------------- echo echo "═══════════════════════════════════════════════════════════════" echo "m13b_smoke results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" echo "═══════════════════════════════════════════════════════════════" for r in "${RESULTS[@]}"; do echo " $r" done echo "═══════════════════════════════════════════════════════════════" if [[ "$FAIL" -gt 0 ]]; then exit 1 fi exit 0