m4_smoke.sh 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. #!/usr/bin/env bash
  2. # Live M4 smoke test. Run from repo root:
  3. # bash scripts/m4_smoke.sh
  4. #
  5. # Walks through the 5 scenarios in M4_VERIFICATION.md:
  6. #
  7. # Step 2 — happy path: 1 alert via MQTT → 2 deliveries
  8. # Step 3 — 5 alerts, mixed severity + 30% dedupe → 10 deliveries
  9. # Step 4 — bad signature (correct user, wrong HMAC) → broker accepts,
  10. # ingestd rejects, no delivery, bad_signature counter ticks
  11. # Step 5 — ACL violation (prom-prod user → globex's topic) → broker
  12. # denies the publish, no delivery, no ingestd counter tick
  13. # Step 6 — non-JSON body → invalid_json counter ticks
  14. #
  15. # The script assumes the loadgen-mqtt binary is built at /tmp/loadgen-mqtt
  16. # (run `cd loadgen && go build -o /tmp/loadgen-mqtt ./cmd/mqtt`). The
  17. # failure-path test binaries (m4_badsig, m4_acl_violation, m4_badjson)
  18. # are auto-built on first run; they live in /tmp and are reused on
  19. # subsequent runs.
  20. #
  21. # Exit code is the number of failed checks.
  22. set -e
  23. cd "$(dirname "$0")/.."
  24. PG="docker exec -i broad-announce-postgres-1 psql -U ba -d ba -A -t"
  25. INGESTD_METRICS=http://localhost:8800/metrics
  26. BROKER=tcp://localhost:1883
  27. SRC=acme-001:prom-prod:s3cret-acme
  28. SRC_USER=prom-prod-acme-001
  29. fails=0
  30. pass() { echo " ✅ $*"; }
  31. fail() { echo " ❌ $*"; fails=$((fails+1)); }
  32. reset_state() {
  33. $PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL, telegram_invite_code = 'acme-bob-002' WHERE id = 'ind-acme-002';" >/dev/null
  34. $PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL WHERE id = 'ind-acme-003';" >/dev/null
  35. $PG -c "UPDATE subscriptions SET min_severity = 'critical' WHERE individual_id = 'ind-acme-002' AND source_id = 'prom-prod';" >/dev/null
  36. $PG -c "TRUNCATE deliveries;" >/dev/null
  37. curl -sS -X POST http://localhost:8830/admin/reset >/dev/null
  38. docker compose restart telegramd >/dev/null
  39. for i in 1 2 3 4 5 6 7 8 9 10; do
  40. if curl -sS http://localhost:8822/health 2>/dev/null | grep -q '"status":"ok"'; then
  41. sleep 1
  42. break
  43. fi
  44. sleep 1
  45. done
  46. }
  47. mqtt_counter() {
  48. # $1 = result label
  49. # Reads /metrics and returns the integer value of
  50. # ba_ingestd_mqtt_messages_total{result="<label>",service="ingestd"} <N>
  51. # Missing label → 0.
  52. curl -sS "$INGESTD_METRICS" 2>/dev/null | \
  53. grep -E "^ba_ingestd_mqtt_messages_total\{result=\"$1\"" | \
  54. awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
  55. }
  56. # ── Setup: build loadgen-mqtt + the 3 failure-path test binaries ──
  57. mkdir -p /tmp/m4_smoke
  58. if [[ ! -x /tmp/loadgen-mqtt ]]; then
  59. echo "▸ Building /tmp/loadgen-mqtt"
  60. (cd loadgen && CGO_ENABLED=0 go build -o /tmp/loadgen-mqtt ./cmd/mqtt)
  61. fi
  62. cat > /tmp/m4_smoke/acl_violation.go <<'GO'
  63. package main
  64. import (
  65. "fmt"
  66. mqtt "github.com/eclipse/paho.mqtt.golang"
  67. "os"
  68. "time"
  69. )
  70. func main() {
  71. opts := mqtt.NewClientOptions().
  72. AddBroker("tcp://localhost:1883").
  73. SetClientID("m4-acl-violation").
  74. SetUsername("prom-prod-acme-001").
  75. SetPassword("s3cret-acme").
  76. SetCleanSession(true)
  77. c := mqtt.NewClient(opts)
  78. if tok := c.Connect(); !tok.WaitTimeout(5*time.Second) || tok.Error() != nil {
  79. fmt.Println("connect err:", tok.Error()); os.Exit(0)
  80. }
  81. tok := c.Publish("ba/globex-002/grafana/incoming", 1, false, []byte("hack"))
  82. if !tok.WaitTimeout(5*time.Second) {
  83. fmt.Println("publish timeout"); os.Exit(0)
  84. }
  85. fmt.Println("published to wrong topic; err:", tok.Error())
  86. os.Exit(0)
  87. }
  88. GO
  89. cat > /tmp/m4_smoke/badsig.go <<'GO'
  90. package main
  91. import (
  92. "encoding/json"
  93. "fmt"
  94. mqtt "github.com/eclipse/paho.mqtt.golang"
  95. "os"
  96. "time"
  97. )
  98. func main() {
  99. opts := mqtt.NewClientOptions().
  100. AddBroker("tcp://localhost:1883").
  101. SetClientID("m4-badsig").
  102. SetUsername("prom-prod-acme-001").
  103. SetPassword("s3cret-acme").
  104. SetCleanSession(true)
  105. c := mqtt.NewClient(opts)
  106. if tok := c.Connect(); !tok.WaitTimeout(5*time.Second) || tok.Error() != nil {
  107. fmt.Println("connect err:", tok.Error()); os.Exit(0)
  108. }
  109. body, _ := json.Marshal(map[string]any{
  110. "alert": json.RawMessage(`{"company_id":"acme-001","source_id":"prom-prod","severity":"info","title":"bad sig"}`),
  111. "auth": "t=1700000000,v1=deadbeef",
  112. })
  113. tok := c.Publish("ba/acme-001/prom-prod/incoming", 1, false, body)
  114. if !tok.WaitTimeout(5*time.Second) {
  115. fmt.Println("publish timeout"); os.Exit(0)
  116. }
  117. fmt.Println("bad-sig published; err:", tok.Error())
  118. os.Exit(0)
  119. }
  120. GO
  121. cat > /tmp/m4_smoke/badjson.go <<'GO'
  122. package main
  123. import (
  124. "fmt"
  125. mqtt "github.com/eclipse/paho.mqtt.golang"
  126. "os"
  127. "time"
  128. )
  129. func main() {
  130. opts := mqtt.NewClientOptions().
  131. AddBroker("tcp://localhost:1883").
  132. SetClientID("m4-badjson").
  133. SetUsername("prom-prod-acme-001").
  134. SetPassword("s3cret-acme").
  135. SetCleanSession(true)
  136. c := mqtt.NewClient(opts)
  137. if tok := c.Connect(); !tok.WaitTimeout(5*time.Second) || tok.Error() != nil {
  138. fmt.Println("connect err:", tok.Error()); os.Exit(0)
  139. }
  140. tok := c.Publish("ba/acme-001/prom-prod/incoming", 1, false, []byte("not json"))
  141. if !tok.WaitTimeout(5*time.Second) {
  142. fmt.Println("publish timeout"); os.Exit(0)
  143. }
  144. fmt.Println("bad-json published; err:", tok.Error())
  145. os.Exit(0)
  146. }
  147. GO
  148. if [[ ! -x /tmp/m4_smoke/m4_acl_violation ]] || [[ ! -x /tmp/m4_smoke/m4_badsig ]] || [[ ! -x /tmp/m4_smoke/m4_badjson ]]; then
  149. echo "▸ Building the 3 failure-path test binaries"
  150. (cd loadgen && CGO_ENABLED=0 go build -o /tmp/m4_smoke/m4_acl_violation /tmp/m4_smoke/acl_violation.go)
  151. (cd loadgen && CGO_ENABLED=0 go build -o /tmp/m4_smoke/m4_badsig /tmp/m4_smoke/badsig.go)
  152. (cd loadgen && CGO_ENABLED=0 go build -o /tmp/m4_smoke/m4_badjson /tmp/m4_smoke/badjson.go)
  153. fi
  154. echo "═══════════════════════════════════════════════════════"
  155. echo " M4 smoke test — broad-announce (MQTT ingest)"
  156. echo "═══════════════════════════════════════════════════════"
  157. # ── Step 2: happy path ───────────────────────────────────────
  158. echo
  159. echo "▸ Step 2: 1 alert via MQTT"
  160. reset_state
  161. before_accept=$(mqtt_counter accepted)
  162. /tmp/loadgen-mqtt --broker "$BROKER" --api-key "$SRC" --count 1 --rate 5 2>&1 | tail -2
  163. sleep 4
  164. n=$($PG -c "SELECT COUNT(*) FROM deliveries;")
  165. [[ "$n" == "2" ]] && pass "2 deliveries (Alice fcm + Alice telegram)" || fail "expected 2 deliveries, got $n"
  166. fb=$($PG -c "SELECT COUNT(*) FROM deliveries WHERE channel='fcm' AND status='sent';")
  167. tb=$($PG -c "SELECT COUNT(*) FROM deliveries WHERE channel='telegram' AND status='sent';")
  168. [[ "$fb" == "1" && "$tb" == "1" ]] && pass "fcm=1, telegram=1" || fail "fcm=$fb, telegram=$tb (expected 1,1)"
  169. after_accept=$(mqtt_counter accepted)
  170. delta=$((after_accept - before_accept))
  171. [[ "$delta" -ge 1 ]] && pass "mqtt_messages{accepted} delta = $delta (≥ 1)" || fail "accepted counter delta = $delta"
  172. # ── Step 3: 5 alerts, mixed severity + dedupe ────────────────
  173. echo
  174. echo "▸ Step 3: 5 alerts via MQTT (mixed severity, 30% dedupe)"
  175. reset_state
  176. before_accept=$(mqtt_counter accepted)
  177. before_dedup=$(mqtt_counter deduped)
  178. /tmp/loadgen-mqtt --broker "$BROKER" --api-key "$SRC" --count 5 --rate 5 --mode normal 2>&1 | tail -2
  179. sleep 4
  180. n=$($PG -c "SELECT COUNT(*) FROM deliveries;")
  181. alerts=$($PG -c "SELECT COUNT(DISTINCT alert_id) FROM deliveries;")
  182. [[ "$alerts" == "5" ]] && pass "5 distinct alerts in deliveries" || fail "expected 5 distinct alerts, got $alerts"
  183. # Each alert is delivered to at least Alice (fcm + telegram = 2), and
  184. # to Bob too for severity >= critical. The default 'normal' mode is
  185. # 70% info / 25% warning / 4% critical / 1% inminent, so the total
  186. # delivery count is at least 10 but can be up to 20.
  187. [[ "$n" -ge 10 ]] && pass "$n deliveries (≥ 10, 5 alerts × ≥ 2 channels for Alice + maybe Bob)" || fail "expected ≥ 10 deliveries, got $n"
  188. after_accept=$(mqtt_counter accepted)
  189. after_dedup=$(mqtt_counter deduped)
  190. [[ $((after_accept - before_accept)) -ge 3 ]] && pass "accepted counter advanced by ≥ 3" || fail "accepted counter delta small"
  191. [[ $((after_dedup - before_dedup)) -ge 1 ]] && pass "deduped counter advanced by ≥ 1 (30% dedupe-pct)" || fail "deduped counter delta = $((after_dedup - before_dedup))"
  192. # ── Step 4: bad signature ────────────────────────────────────
  193. echo
  194. echo "▸ Step 4: bad signature (correct user, wrong HMAC)"
  195. reset_state
  196. before_bs=$(mqtt_counter bad_signature)
  197. /tmp/m4_smoke/m4_badsig 2>&1 | tail -1
  198. sleep 3
  199. n=$($PG -c "SELECT COUNT(*) FROM deliveries;")
  200. [[ "$n" == "0" ]] && pass "0 deliveries (bad signature rejected)" || fail "expected 0 deliveries, got $n"
  201. after_bs=$(mqtt_counter bad_signature)
  202. [[ $((after_bs - before_bs)) -ge 1 ]] && pass "bad_signature counter advanced by ≥ 1" || fail "bad_signature counter delta = $((after_bs - before_bs))"
  203. # ── Step 5: ACL violation ────────────────────────────────────
  204. echo
  205. echo "▸ Step 5: ACL violation (prom-prod user → globex's topic)"
  206. reset_state
  207. before_recv=$(mqtt_counter received)
  208. /tmp/m4_smoke/m4_acl_violation 2>&1 | tail -1
  209. sleep 3
  210. n=$($PG -c "SELECT COUNT(*) FROM deliveries;")
  211. [[ "$n" == "0" ]] && pass "0 deliveries (ACL denied at broker)" || fail "expected 0 deliveries, got $n"
  212. after_recv=$(mqtt_counter received)
  213. [[ $((after_recv - before_recv)) -eq 0 ]] && pass "received counter unchanged (broker dropped before subscriber)" || fail "received counter delta = $((after_recv - before_recv)) (broker should have dropped)"
  214. # ── Step 6: non-JSON body ────────────────────────────────────
  215. echo
  216. echo "▸ Step 6: non-JSON body"
  217. reset_state
  218. before_ij=$(mqtt_counter invalid_json)
  219. /tmp/m4_smoke/m4_badjson 2>&1 | tail -1
  220. sleep 3
  221. n=$($PG -c "SELECT COUNT(*) FROM deliveries;")
  222. [[ "$n" == "0" ]] && pass "0 deliveries (invalid body rejected)" || fail "expected 0 deliveries, got $n"
  223. after_ij=$(mqtt_counter invalid_json)
  224. [[ $((after_ij - before_ij)) -ge 1 ]] && pass "invalid_json counter advanced by ≥ 1" || fail "invalid_json counter delta = $((after_ij - before_ij))"
  225. echo
  226. echo "═══════════════════════════════════════════════════════"
  227. echo " M4 smoke: $fails failure(s)"
  228. echo "═══════════════════════════════════════════════════════"
  229. exit $fails