Files
summercms/modules/tide/centrifugo_test.go
Jakub Zych 9ecbf74a22 feat(11-06): add the tide fake Centrifugo recorder, broadcast goldens and parity:broadcasts
- CentrifugoRecorder records publish/broadcast requests (method, path,
  whether the API key matched, JSON body) and binds loopback only
- BroadcastGolden load/write, NormalizePublications (timestamps, actor,
  captured ids only) and DiffPublications (structural, key order ignored)
- RecordBroadcasts runs a flow or one step against a loopback backend
- summer parity:broadcasts wraps it; README documents format and rules
2026-09-30 13:21:23 +02:00

237 lines
8.5 KiB
Go

package tide
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func postJSON(t *testing.T, url, auth, body string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = resp.Body.Close() })
return resp
}
func TestCentrifugoRecorderRecordsPublishAndBroadcast(t *testing.T) {
rec := NewCentrifugoRecorder(CentrifugoRecorderOptions{APIKey: "k1"})
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
resp := postJSON(t, srv.URL+"/api/publish", "apikey k1", `{"channel":"room:1","data":{}}`)
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || string(body) != `{"result":{}}` {
t.Fatalf("publish answer %d %s", resp.StatusCode, body)
}
postJSON(t, srv.URL+"/api/broadcast", "apikey wrong", `{"channels":["a:1","b:2"]}`)
resp = postJSON(t, srv.URL+"/api/presence", "apikey k1", `{"channel":"room:1"}`)
body, _ = io.ReadAll(resp.Body)
if string(body) != `{"result":{"presence":{}}}` {
t.Fatalf("presence answer %s", body)
}
if resp := postJSON(t, srv.URL+"/api/other", "", `{}`); resp.StatusCode != 404 {
t.Fatalf("unknown path %d", resp.StatusCode)
}
pubs := rec.Publications()
if len(pubs) != 2 {
t.Fatalf("publications %d, want 2 (presence is not recorded)", len(pubs))
}
if pubs[0].Path != "/api/publish" || !pubs[0].Authorization || pubs[0].Method != "POST" {
t.Fatalf("first %+v", pubs[0])
}
if pubs[1].Path != "/api/broadcast" || pubs[1].Authorization {
t.Fatalf("second %+v", pubs[1])
}
rec.Reset()
if len(rec.Publications()) != 0 {
t.Fatal("reset kept publications")
}
}
func TestCentrifugoRecorderRefusesNonLoopback(t *testing.T) {
rec := NewCentrifugoRecorder(CentrifugoRecorderOptions{})
if err := rec.ListenAndServe(t.Context(), "0.0.0.0:0"); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Fatalf("want loopback refusal, got %v", err)
}
}
func TestNormalizePublications(t *testing.T) {
store := mustMemoryStore()
store.Set("id:collection", "12")
store.Set("id:album", "40")
store.Set("jwt:alice", "not-an-id")
pubs := []Publication{{
Method: "POST",
Path: "/api/publish",
Body: json.RawMessage(`{"channel":"collection:12","data":{"event":"deleted.acme.album",` +
`"payload":{"id":40,"collection_id":12,"action":"deleted","actor":{"user_id":3,"name":null},` +
`"timestamp":"2026-09-30T10:00:00+00:00","count":12},"timestamp":"2026-09-30T10:00:01+00:00"}}`),
}}
got, err := NormalizePublications(pubs, store)
if err != nil {
t.Fatal(err)
}
want := `{"channel":"collection:{{id:collection}}","data":{"event":"deleted.acme.album",` +
`"payload":{"id":{{id:album}},"collection_id":{{id:collection}},"action":"deleted","actor":"{{actor}}",` +
`"timestamp":"{{timestamp}}","count":12},"timestamp":"{{timestamp}}"}}`
if string(got[0].Body) != want {
t.Fatalf("normalised\n got %s\nwant %s", got[0].Body, want)
}
// A Z timestamp and an actor with extra keys are left alone.
odd := []Publication{{Method: "POST", Path: "/p", Body: json.RawMessage(
`{"data":{"payload":{"actor":{"user_id":1,"name":"x","role":"a"}},"timestamp":"2026-09-30T10:00:00Z"}}`)}}
got, err = NormalizePublications(odd, store)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got[0].Body), `"2026-09-30T10:00:00Z"`) || !strings.Contains(string(got[0].Body), `"role":"a"`) {
t.Fatalf("over-normalised %s", got[0].Body)
}
store.Set("id:other", "12")
if _, err := NormalizePublications(pubs, store); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("want ambiguity error, got %v", err)
}
}
func TestDiffPublications(t *testing.T) {
want := []Publication{{Method: "POST", Path: "/api/publish", Authorization: true,
Body: json.RawMessage(`{"channel":"c:{{id:c}}","data":{"payload":{"id":{{id:a}},"n":1}}}`)}}
same := []Publication{{Method: "POST", Path: "/api/publish", Authorization: true,
Body: json.RawMessage(`{"data":{"payload":{"n":1,"id":{{id:a}}}},"channel":"c:{{id:c}}"}`)}}
if d := DiffPublications(want, same); len(d) != 0 {
t.Fatalf("key order must not matter: %+v", d)
}
stringID := []Publication{{Method: "POST", Path: "/api/broadcast", Authorization: false,
Body: json.RawMessage(`{"channel":"c:{{id:c}}","data":{"payload":{"id":"{{id:a}}","n":1}}}`)}}
d := DiffPublications(want, stringID)
paths := map[string]bool{}
for _, x := range d {
paths[x.Path] = true
}
for _, p := range []string{"$[0].path", "$[0].authorization", "$[0].body.data.payload.id"} {
if !paths[p] {
t.Fatalf("missing diff at %s in %+v", p, d)
}
}
if d := DiffPublications(want, nil); len(d) != 1 || d[0].Path != "$" {
t.Fatalf("count diff %+v", d)
}
}
func TestBroadcastGoldenRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "g.yaml")
g := BroadcastGolden{Version: CurrentVersion, Name: "deleted", Flow: "flows/x#delete", Pending: "later",
Publications: []Publication{{Method: "POST", Path: "/api/publish", Authorization: true,
Body: json.RawMessage(`{"channel":"c:{{id:c}}","data":{"payload":{"id":{{id:a}}}}}`)}}}
if err := WriteBroadcastGolden(path, g); err != nil {
t.Fatal(err)
}
back, err := LoadBroadcastGolden(path)
if err != nil {
t.Fatal(err)
}
if back.Name != g.Name || back.Flow != g.Flow || back.Pending != g.Pending || len(back.Publications) != 1 {
t.Fatalf("round trip %+v", back)
}
if d := DiffPublications(g.Publications, back.Publications); len(d) != 0 {
t.Fatalf("round trip diff %+v", d)
}
leak := g
leak.Publications = []Publication{{Method: "POST", Path: "/p",
Body: json.RawMessage(`{"token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl"}`)}}
if err := WriteBroadcastGolden(filepath.Join(dir, "leak.yaml"), leak); err == nil {
t.Fatal("a JWT-shaped body must be refused")
}
if err := os.WriteFile(filepath.Join(dir, "bad.yaml"), []byte("version: 1\nname: x\nextra: 1\npublications: []\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadBroadcastGolden(filepath.Join(dir, "bad.yaml")); err == nil {
t.Fatal("unknown field must be rejected")
}
}
func freeLoopbackAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
return addr
}
func TestRecordBroadcastsStep(t *testing.T) {
listen := freeLoopbackAddr(t)
publish := func(body string) {
req, _ := http.NewRequest(http.MethodPost, "http://"+listen+"/api/publish", strings.NewReader(body))
req.Header.Set("Authorization", "apikey test-key")
if resp, err := http.DefaultClient.Do(req); err == nil {
_ = resp.Body.Close()
}
}
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodPost:
publish(`{"channel":"room:5","data":{"event":"created","payload":{"id":77},"timestamp":"2026-09-30T10:00:00+00:00"}}`)
_, _ = io.WriteString(w, `{"data":{"id":77}}`)
case http.MethodDelete:
publish(`{"channel":"room:5","data":{"event":"deleted","payload":{"id":77},"timestamp":"2026-09-30T10:00:02+00:00"}}`)
_, _ = io.WriteString(w, `{"message":"deleted"}`)
}
}))
t.Cleanup(backend.Close)
store := mustMemoryStore()
store.Set("id:room", "5")
spec := Flow{Version: CurrentVersion, Name: "items", Steps: []Step{
{ID: "create", Request: Request{Method: "POST", Path: "/items"},
Capture: []CaptureRule{{From: "response.json", Path: "$.data.id", As: "id:item"}}},
{ID: "delete", Request: Request{Method: "DELETE", Path: "/items/{{id:item}}"}},
}}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
g, err := RecordBroadcasts(ctx, spec, BroadcastConfig{
Target: backend.URL, Listen: listen, APIKey: "test-key", Store: store,
Step: "delete", IDs: []string{"id:room", "id:item"}, Settle: 50 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if g.Name != "delete" || g.Flow != "items#delete" || len(g.Publications) != 1 {
t.Fatalf("golden %+v", g)
}
want := `{"channel":"room:{{id:room}}","data":{"event":"deleted","payload":{"id":{{id:item}}},"timestamp":"{{timestamp}}"}}`
if string(g.Publications[0].Body) != want || !g.Publications[0].Authorization {
t.Fatalf("publication %+v body %s", g.Publications[0], g.Publications[0].Body)
}
if _, err := RecordBroadcasts(ctx, spec, BroadcastConfig{Target: "http://192.0.2.1:80", APIKey: "k"}); err == nil {
t.Fatal("a non-loopback target must be refused")
}
}