feat(11-04): add the websockets health, VAPID key and test-push commands

- centrifugo.Client.Info probes the info API method; an error body fails
- websockets:health ports CentrifugoHealthCheck: exits 1 without an API
  key or when the probe fails, prints the Setting/Value table otherwise
- websockets:generate-vapid-keys prints a new P-256 pair, shows configured
  keys only truncated, and --update persists them to overrides.yaml
- websockets:test-push reads subscriptions from an app-published
  SubscriptionSource, refuses to send while push is disabled and sends
  one encrypted push per subscription
- no command prints a configured private key or the Centrifugo API key
- flare and lighthouse READMEs document the CLI commands
This commit is contained in:
Jakub Zych
2026-09-30 13:52:44 +02:00
parent 5fb22c28d0
commit f55cb444ab
7 changed files with 1057 additions and 3 deletions

View File

@@ -0,0 +1,129 @@
package centrifugo
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
)
const testAPIKey = "test-api-key-7f3c9e"
func healthApp(t *testing.T, realtimeYAML string) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "realtime.yaml"), []byte(realtimeYAML), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
func runHealth(t *testing.T, app *backpack.App) (string, error) {
t.Helper()
var buf bytes.Buffer
root, err := bonfire.NewRootIO("acme", Commands(app), strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"websockets:health"})
err = root.Execute()
return buf.String(), err
}
func TestHealthCommand(t *testing.T) {
var hits atomic.Int32
var gotAuth, gotBody atomic.Value
status := atomic.Int32{}
answer := atomic.Value{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Path != "/api/info" || r.Method != http.MethodPost {
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
gotAuth.Store(r.Header.Get("Authorization"))
gotBody.Store(string(body))
w.WriteHeader(int(status.Load()))
_, _ = io.WriteString(w, answer.Load().(string))
}))
defer srv.Close()
configured := "centrifugo:\n api_url: " + srv.URL + "/api\n api_key: " + testAPIKey + "\n"
t.Run("missing API key", func(t *testing.T) {
out, err := runHealth(t, healthApp(t, "centrifugo:\n api_url: "+srv.URL+"/api\n"))
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
if !strings.Contains(out, "Centrifugo not configured (API key missing)") || !strings.Contains(out, "SUMMER_REALTIME__CENTRIFUGO__API_KEY") {
t.Fatalf("output:\n%s", out)
}
if hits.Load() != 0 {
t.Fatal("a request was sent without an API key")
}
})
t.Run("Centrifugo answers info", func(t *testing.T) {
status.Store(http.StatusOK)
answer.Store(`{"result":{"nodes":[{"name":"node-1"}]}}`)
out, err := runHealth(t, healthApp(t, configured))
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
for _, want := range []string{"Checking Centrifugo connection...", "API URL: " + srv.URL + "/api", "Configuration OK", "Setting\tValue", "API URL\t" + srv.URL + "/api", "Enabled\tYes", "API Key Set\tYes"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
if gotAuth.Load() != "apikey "+testAPIKey || gotBody.Load() != "{}" {
t.Fatalf("info request: auth %v body %v", gotAuth.Load(), gotBody.Load())
}
if strings.Contains(out, testAPIKey) {
t.Fatalf("output contains the API key:\n%s", out)
}
})
t.Run("Centrifugo answers 500", func(t *testing.T) {
status.Store(http.StatusInternalServerError)
answer.Store(`oops`)
out, err := runHealth(t, healthApp(t, configured))
if err == nil || !strings.Contains(out, "Connection check failed:") || strings.Contains(out, "Configuration OK") {
t.Fatalf("err = %v\n%s", err, out)
}
if strings.Contains(out, testAPIKey) {
t.Fatalf("output contains the API key:\n%s", out)
}
})
t.Run("Centrifugo answers an error body", func(t *testing.T) {
status.Store(http.StatusOK)
answer.Store(`{"error":{"code":101,"message":"unauthorized"}}`)
out, err := runHealth(t, healthApp(t, configured))
if err == nil || !strings.Contains(out, "Connection check failed:") || !strings.Contains(out, "unauthorized") {
t.Fatalf("err = %v\n%s", err, out)
}
})
t.Run("Centrifugo unreachable", func(t *testing.T) {
dead := httptest.NewServer(http.NotFoundHandler())
url := dead.URL
dead.Close()
out, err := runHealth(t, healthApp(t, "centrifugo:\n api_url: "+url+"/api\n api_key: "+testAPIKey+"\n"))
if err == nil || !strings.Contains(out, "Connection check failed:") || strings.Contains(out, testAPIKey) {
t.Fatalf("err = %v\n%s", err, out)
}
})
}