Files
mrcharles 445df7a836 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).
2026-08-29 16:54:43 -05:00

202 lines
6.5 KiB
Go

// Command fakediscourse is the smoke-test Discourse: a tiny in-memory
// instance enforcing Api-Key auth, serving exactly what the client covers
// (current user, categories, topics, posts). It exists so the smoke test
// can drive the REAL CLI binary end to end without ever touching a live
// forum. Not built into the library, never shipped.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
)
const smokeKey = "smoke-key-do-not-use"
type server struct {
mu sync.Mutex
cats []map[string]any
posts map[int]map[string]any
topics []map[string]any
nextCat int
nextPost int
nextT int
pms []map[string]any // recorded private messages, creation order
}
func main() {
addr := ":8610"
if len(os.Args) == 3 && os.Args[1] == "-addr" {
addr = os.Args[2]
}
s := &server{
cats: []map[string]any{
{"id": 1, "name": "General", "slug": "general", "color": "0088CC", "text_color": "FFFFFF", "topic_count": 0},
{"id": 2, "name": "MOPAC Briefings", "slug": "mopac-briefings", "color": "3AB54A", "text_color": "FFFFFF", "topic_count": 0},
},
posts: map[int]map[string]any{},
nextCat: 3,
nextPost: 100,
nextT: 10,
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Api-Key") != smokeKey || r.Header.Get("Api-Username") != "smoker" {
reply(w, 403, map[string]any{"errors": []string{"Bad or missing API key"}})
return
}
s.mu.Lock()
defer s.mu.Unlock()
path := r.URL.Path
switch {
case path == "/session/current.json" && r.Method == "GET":
reply(w, 200, map[string]any{"current_user": map[string]any{"id": 7, "username": "smoker", "admin": true}})
case path == "/categories.json" && r.Method == "GET":
reply(w, 200, map[string]any{"category_list": map[string]any{"categories": s.cats}})
case path == "/categories.json" && r.Method == "POST":
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
reply(w, 400, map[string]any{"errors": []string{"bad json"}})
return
}
name, _ := req["name"].(string)
cat := map[string]any{
"id": s.nextCat, "name": name,
"slug": strings.ToLower(strings.ReplaceAll(name, " ", "-")),
"color": orDefault(req["color"], "3AB54A"), "text_color": orDefault(req["text_color"], "FFFFFF"),
"topic_count": 0,
}
s.nextCat++
s.cats = append(s.cats, cat)
reply(w, 200, cat)
case path == "/posts.json" && r.Method == "POST":
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
reply(w, 400, nil)
return
}
topicID, _ := req["topic_id"].(float64)
pid := s.nextPost
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++
slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
s.topics = append(s.topics, map[string]any{"id": tid, "title": title, "slug": slug, "category_id": int(catID)})
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
}
s.posts[pid] = map[string]any{"id": pid, "topic_id": int(topicID), "raw": req["raw"], "post_number": 2}
reply(w, 200, map[string]any{"id": pid, "topic_id": int(topicID), "topic_slug": "existing"})
case strings.HasPrefix(path, "/posts/") && r.Method == "PUT":
id, _ := strconv.Atoi(strings.SplitN(strings.TrimPrefix(path, "/posts/"), ".", 2)[0])
var body struct {
Post struct {
Raw string `json:"raw"`
EditReason string `json:"edit_reason"`
} `json:"post"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
reply(w, 400, nil)
return
}
p, ok := s.posts[id]
if !ok {
reply(w, 404, nil)
return
}
p["raw"] = body.Post.Raw
s.posts[id] = p
reply(w, 200, map[string]any{"post": p})
case strings.HasPrefix(path, "/c/") && r.Method == "GET":
reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}})
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"}})
}
})
fmt.Printf("fakediscourse listening on %s (api-key enforced)\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func reply(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func orDefault(v any, def string) any {
if s, ok := v.(string); ok && s != "" {
return s
}
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
}