From 445df7a8361d2c15a89eb04eda0a4acc1f309a51 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 16:54:43 -0500 Subject: [PATCH] test: smoke coverage for search, private messages, webhook/SSO The fake smoke Discourse now serves /search.json (title substring matches over created topics), accepts private-message creation on POST /posts.json (targets comma-joined on the wire, deliveries recorded and exposed via /__pms.json), and the smoke script drives the real CLI through it: search hits, PM send + recipient assertion, webhook verify with a python-computed HMAC (good sig exits 0, bad sig exits 2 with the typed message), and sso verify decoding a signed Discourse Connect payload (tamper case exits 2). No live forum is contacted. Part of Redmine 507 (Discourse Go client). --- smoke/fakediscourse/main.go | 52 +++++++++++++++++++++++++++++++++++++ smoke/smoke.sh | 32 +++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/smoke/fakediscourse/main.go b/smoke/fakediscourse/main.go index 3f752ef..288c587 100644 --- a/smoke/fakediscourse/main.go +++ b/smoke/fakediscourse/main.go @@ -26,6 +26,7 @@ type server struct { nextCat int nextPost int nextT int + pms []map[string]any // recorded private messages, creation order } func main() { @@ -88,6 +89,23 @@ func main() { s.nextPost++ if topicID == 0 { title, _ := req["title"].(string) + if tu, _ := req["target_usernames"].(string); tu != "" || hasAnyKey(req, "target_group_names", "target_emails") { + // private-message creation + if strings.Contains(str(req["target_usernames"]), "denied") { + reply(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}}) + return + } + tid := s.nextT + s.nextT++ + slug := strings.ToLower(strings.ReplaceAll(title, " ", "-")) + s.pms = append(s.pms, map[string]any{ + "topic_id": tid, "title": title, + "target_usernames": req["target_usernames"], "raw": req["raw"], + }) + s.posts[pid] = map[string]any{"id": pid, "topic_id": tid, "raw": req["raw"], "post_number": 1} + reply(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug}) + return + } catID, _ := req["category"].(float64) tid := s.nextT s.nextT++ @@ -127,6 +145,24 @@ func main() { case path == "/latest.json" && r.Method == "GET": reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}}) + case path == "/search.json" && r.Method == "GET": + term := strings.ToLower(r.URL.Query().Get("term")) + var topics, posts []map[string]any + for _, t := range s.topics { + if term != "" && strings.Contains(strings.ToLower(str(t["title"])), term) { + topics = append(topics, t) + posts = append(posts, map[string]any{ + "id": 900 + t["id"].(int), "topic_id": t["id"], "username": "smoker", + "blurb": "match on " + str(t["title"]), + "topic_title_headline": t["title"], + }) + } + } + reply(w, 200, map[string]any{"posts": posts, "topics": topics, "users": []any{}, "categories": []any{}}) + + case path == "/__pms.json" && r.Method == "GET": + reply(w, 200, map[string]any{"private_messages": s.pms}) + default: reply(w, 404, map[string]any{"errors": []string{"not found on the smoke instance"}}) } @@ -147,3 +183,19 @@ func orDefault(v any, def string) any { } return def } + +// str renders any as a display string (missing keys -> ""). +func str(v any) string { + s, _ := v.(string) + return s +} + +// hasAnyKey reports whether any of the keys holds a non-empty string. +func hasAnyKey(m map[string]any, keys ...string) bool { + for _, k := range keys { + if str(m[k]) != "" { + return true + } + } + return false +} diff --git a/smoke/smoke.sh b/smoke/smoke.sh index 5cc7da2..e4aced6 100755 --- a/smoke/smoke.sh +++ b/smoke/smoke.sh @@ -96,6 +96,38 @@ CLI posts update "$POST_ID" -raw "edited body" -reason smoke | tee -a "$capture" echo "--- raw passthrough" CLI raw GET /latest.json | tee -a "$capture" | grep -q '"topic_list"' || { echo "smoke: raw GET failed" >&2; exit 1; } +echo "--- search" +CLI search "Smoke Topic" | tee -a "$capture" | grep -q '"Smoke Topic"' || { echo "smoke: search failed" >&2; exit 1; } + +echo "--- private message send + delivery record" +CLI messages send -title "Smoke PM" -to ops,reachableceo -raw "pm body" | tee -a "$capture" | grep -q '"topic_slug": "smoke-pm"' || { echo "smoke: PM send failed" >&2; exit 1; } +CLI raw GET /__pms.json | tee -a "$capture" | grep -q '"target_usernames": "ops,reachableceo"' || { echo "smoke: PM recipients not recorded" >&2; exit 1; } + +echo "--- webhook verify: good sig exits 0, bad sig exits 2" +WHSEC="smoke-webhook-secret" +printf '{"post":{"id":101}}' > .smoke/hook-body +SIG=$(python3 -c 'import hmac,hashlib,sys; print("sha256="+hmac.new(sys.argv[1].encode(),open(sys.argv[2],"rb").read(),hashlib.sha256).hexdigest())' "$WHSEC" .smoke/hook-body) +( set -a; DISCOURSE_WEBHOOK_SECRET="$WHSEC"; set +a; exec ./bin/discourse-go webhook verify "$SIG" ) < .smoke/hook-body >>"$capture" 2>&1 \ + || { echo "smoke: webhook verify (good sig) failed" >&2; exit 1; } +set +e +( set -a; DISCOURSE_WEBHOOK_SECRET="$WHSEC"; set +a; exec ./bin/discourse-go webhook verify "sha256=deadbeef" ) < .smoke/hook-body >"$PWD/.smoke/badsig" 2>&1 +code=$? +set -e +if [ "$code" -ne 2 ]; then echo "smoke: bad webhook sig exit=$code want 2" >&2; exit 1; fi +grep -q "bad signature" "$PWD/.smoke/badsig" || { echo "smoke: bad-signature message missing" >&2; exit 1; } + +echo "--- sso verify: decoded payload + tamper rejection" +SSOSEC="smoke-sso-secret" +SSO=$(python3 -c 'import base64; print(base64.b64encode(b"nonce=smoke123&return_sso_url=https%3A%2F%2Fapp.test%2Fcb").decode())') +SSIG=$(python3 -c 'import hmac,hashlib,sys; print(hmac.new(sys.argv[1].encode(),sys.argv[2].encode(),hashlib.sha256).hexdigest())' "$SSOSEC" "$SSO") +( set -a; DISCOURSE_SSO_SECRET="$SSOSEC"; set +a; exec ./bin/discourse-go sso verify "$SSO" "$SSIG" ) | tee -a "$capture" | grep -q '"nonce": "smoke123"' \ + || { echo "smoke: sso verify failed" >&2; exit 1; } +set +e +( set -a; DISCOURSE_SSO_SECRET="$SSOSEC"; set +a; exec ./bin/discourse-go sso verify "$SSO" "deadbeef" ) >/dev/null 2>&1 +code=$? +set -e +if [ "$code" -ne 2 ]; then echo "smoke: bad sso sig exit=$code want 2" >&2; exit 1; fi + echo "--- redaction: no key material in any captured output" if grep -q "$SMOKE_KEY" "$capture"; then echo "smoke: KEY LEAKED in output" >&2