v0: stdlib-only Discourse client — categories/topics/posts + raw passthrough
Covers the MOPAC briefing/report surface (Redmine 495 Part A): category
list/create (create needs the admin-scoped key; current key 403s, typed
as ErrForbidden), topic create/list/latest/get, post create/update/get,
current-user identity probe, and a Do() JSON passthrough so unmodeled
endpoints need no client release. Key is env/constructor-only, never a
flag, never logged; errors classify via errors.Is. Fake-server unit
tests + containerized end-to-end smoke (redaction sweep included); live
reads verified against community.turnsys.com.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
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}})
|
||||
|
||||
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
|
||||
}
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
# End-to-end smoke for discourse-go: builds the CLI in the Docker
|
||||
# builder, boots the FAKE Discourse in a container on 127.0.0.1:8610,
|
||||
# drives the real binary from the host through env vars (0600 temp env
|
||||
# file), asserts the happy paths + failure classes + redaction, and tears
|
||||
# everything down. No live forum is ever contacted. Only exact container
|
||||
# IDs spawned here are killed.
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
IMAGE="golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514"
|
||||
PORT=8610
|
||||
SMOKE_KEY="smoke-key-do-not-use"
|
||||
CID=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$CID" ]; then
|
||||
docker rm -f "$CID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf .smoke
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
mkdir -p .smoke
|
||||
umask 077
|
||||
|
||||
echo "--- build CLI (docker builder)"
|
||||
docker run --rm -v "$PWD:/h" -w /h \
|
||||
-u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \
|
||||
"$IMAGE" go build -o bin/discourse-go ./cmd/discourse-go
|
||||
|
||||
echo "--- boot fake Discourse (container, port $PORT)"
|
||||
CID=$(docker run -d --rm \
|
||||
-v "$PWD:/h" -w /h \
|
||||
-u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \
|
||||
-p 127.0.0.1:$PORT:8610 \
|
||||
"$IMAGE" go run ./smoke/fakediscourse -addr :8610)
|
||||
|
||||
# wait for the fake to answer (any HTTP response proves it is up)
|
||||
wait_up() {
|
||||
python3 - "$PORT" <<'PYEOF'
|
||||
import sys, urllib.request, urllib.error
|
||||
port = sys.argv[1]
|
||||
req = urllib.request.Request('http://127.0.0.1:%s/session/current.json' % port,
|
||||
headers={'Api-Key': 'probe', 'Api-Username': 'probe'})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except urllib.error.HTTPError:
|
||||
sys.exit(0) # got an HTTP answer: server is up
|
||||
except Exception:
|
||||
sys.exit(1) # not yet
|
||||
sys.exit(0)
|
||||
PYEOF
|
||||
}
|
||||
i=0
|
||||
until [ -n "$CID" ] && [ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null)" = "true" ] && wait_up; do
|
||||
i=$((i+1))
|
||||
if [ "$i" -ge 60 ]; then
|
||||
echo "smoke: fake server did not come up; logs:" >&2
|
||||
docker logs "$CID" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 0600 env file = the credential path the docs prescribe
|
||||
ENVF=".smoke/env"
|
||||
printf 'DISCOURSE_URL=http://127.0.0.1:%s\nDISCOURSE_API_KEY=%s\nDISCOURSE_API_USERNAME=smoker\n' \
|
||||
"$PORT" "$SMOKE_KEY" > "$ENVF"
|
||||
|
||||
CLI() {
|
||||
( set -a; . "$ENVF"; set +a; exec ./bin/discourse-go "$@" )
|
||||
}
|
||||
|
||||
capture="$PWD/.smoke/out"
|
||||
touch "$capture"
|
||||
|
||||
echo "--- whoami"
|
||||
CLI whoami | tee -a "$capture" | grep -q '"username": "smoker"' || { echo "smoke: whoami failed" >&2; exit 1; }
|
||||
|
||||
echo "--- categories list"
|
||||
CLI categories list | tee -a "$capture" | grep -q '"mopac-briefings"' || { echo "smoke: categories list failed" >&2; exit 1; }
|
||||
|
||||
echo "--- categories create + typed 404 check"
|
||||
CLI categories create "Smoke Cat" -color 25AAE2 | tee -a "$capture" | grep -q '"slug": "smoke-cat"' || { echo "smoke: category create failed" >&2; exit 1; }
|
||||
CLI raw POST /definitely/not/there -data '{}' 2>>"$capture" >>"$capture" || true
|
||||
grep -q "not found" "$capture" || { echo "smoke: 404 class missing" >&2; exit 1; }
|
||||
|
||||
echo "--- topics create / list / post reply / update"
|
||||
CLI topics create -title "Smoke Topic" -category 2 -raw "first body" | tee -a "$capture" | grep -q '"topic_id"' || { echo "smoke: topic create failed" >&2; exit 1; }
|
||||
CLI topics list -category 2 | tee -a "$capture" | grep -q '"Smoke Topic"' || { echo "smoke: topics list failed" >&2; exit 1; }
|
||||
CLI topics list -slug mopac-briefings | tee -a "$capture" | grep -q '"Smoke Topic"' || { echo "smoke: topics list by slug failed" >&2; exit 1; }
|
||||
POST_ID=$(CLI posts create -topic 10 -raw "reply body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
CLI posts update "$POST_ID" -raw "edited body" -reason smoke | tee -a "$capture" | grep -q '"raw": "edited body"' || { echo "smoke: post update failed" >&2; exit 1; }
|
||||
|
||||
echo "--- raw passthrough"
|
||||
CLI raw GET /latest.json | tee -a "$capture" | grep -q '"topic_list"' || { echo "smoke: raw GET failed" >&2; exit 1; }
|
||||
|
||||
echo "--- redaction: no key material in any captured output"
|
||||
if grep -q "$SMOKE_KEY" "$capture"; then
|
||||
echo "smoke: KEY LEAKED in output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- bad key: exit code 2 + typed error"
|
||||
set +e
|
||||
( set -a; . "$ENVF"; set +a; DISCOURSE_API_KEY=wrong-key exec ./bin/discourse-go whoami ) >"$PWD/.smoke/bad" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
if [ "$code" -ne 2 ]; then echo "smoke: bad key exit=$code want 2" >&2; exit 1; fi
|
||||
grep -q "HTTP 403" "$PWD/.smoke/bad" || { echo "smoke: typed error missing" >&2; exit 1; }
|
||||
|
||||
echo "smoke: OK"
|
||||
Reference in New Issue
Block a user