| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- #!/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
|