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,368 @@
package flare
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
)
// newCommandApp builds an app on a temporary config directory in the
// development environment, without process environment variables.
func newCommandApp(t *testing.T, files map[string]string) (*backpack.App, string) {
t.Helper()
dir := t.TempDir()
for name, body := range files {
path := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("config: %v", err)
}
return backpack.New(cfg), dir
}
// runCommand runs one command with empty stdin (prompts take their
// defaults) and returns the combined output.
func runCommand(t *testing.T, app *backpack.App, args ...string) (string, error) {
t.Helper()
var buf bytes.Buffer
root, err := bonfire.NewRootIO("acme", Commands(app), strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatalf("NewRootIO: %v", err)
}
root.SetArgs(args)
err = root.Execute()
return buf.String(), err
}
func pushYAML(enabled bool, keys VAPIDKeys, hosts ...string) string {
var b strings.Builder
if enabled {
b.WriteString("enabled: true\n")
} else {
b.WriteString("enabled: false\n")
}
b.WriteString("public_key: " + keys.PublicKey + "\n")
b.WriteString("private_key: " + keys.PrivateKey + "\n")
b.WriteString("subject: mailto:ops@example.com\n")
if len(hosts) > 0 {
b.WriteString("allowed_hosts:\n")
for _, h := range hosts {
b.WriteString(" - " + h + "\n")
}
}
return b.String()
}
// keyAfter returns the line that follows a heading and its divider.
func keyAfter(t *testing.T, out, heading string) string {
t.Helper()
lines := strings.Split(out, "\n")
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), heading) && i+2 < len(lines) {
return strings.TrimSpace(lines[i+2])
}
}
t.Fatalf("no %q in output:\n%s", heading, out)
return ""
}
func truncated(s string) string { return s[:8] + "..." + s[len(s)-4:] }
func TestGenerateVAPIDKeysCommand(t *testing.T) {
t.Run("prints a new pair with manual instructions", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": "enabled: false\n"})
out, err := runCommand(t, app, "websockets:generate-vapid-keys")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
for _, want := range []string{"Web Push VAPID Key Generator", "Public: Not configured", "Private: Not configured", "Public key: 87 characters (correct)", "Private key: 43 characters (correct)", "Base64url encoding valid", "VAPID key generation completed"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
pub := keyAfter(t, out, "Public Key (87 chars):")
priv := keyAfter(t, out, "Private Key (43 chars):")
if len(pub) != PublicKeyLength || len(priv) != PrivateKeyLength {
t.Fatalf("key lengths %d/%d", len(pub), len(priv))
}
if _, err := ParseVAPIDKeys(pub, priv); err != nil {
t.Fatalf("printed keys do not parse: %v", err)
}
if !strings.Contains(out, "SUMMER_PUSH__PUBLIC_KEY="+pub) || !strings.Contains(out, "SUMMER_PUSH__PRIVATE_KEY="+priv) {
t.Fatalf("manual instructions missing:\n%s", out)
}
})
t.Run("--show-current truncates the configured keys", func(t *testing.T) {
keys, err := GenerateVAPIDKeys()
if err != nil {
t.Fatal(err)
}
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
out, err := runCommand(t, app, "websockets:generate-vapid-keys", "--show-current")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
for _, want := range []string{truncated(keys.PublicKey) + " (87 chars) ✓", truncated(keys.PrivateKey) + " (43 chars) ✓"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, keys.PrivateKey) || strings.Contains(out, keys.PublicKey) {
t.Fatalf("--show-current printed a full configured key:\n%s", out)
}
if strings.Contains(out, "Generating") {
t.Fatalf("--show-current generated keys:\n%s", out)
}
})
t.Run("--update persists the pair to the overrides file", func(t *testing.T) {
app, dir := newCommandApp(t, map[string]string{
"push.yaml": "enabled: false\n",
"env/development/overrides.yaml": "app:\n name: acme\n",
})
out, err := runCommand(t, app, "websockets:generate-vapid-keys", "--update")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
pub := keyAfter(t, out, "Public Key (87 chars):")
priv := keyAfter(t, out, "Private Key (43 chars):")
if strings.Contains(out, "SUMMER_PUSH__PRIVATE_KEY=") {
t.Fatalf("--update printed manual instructions:\n%s", out)
}
if !strings.Contains(out, "env/development/overrides.yaml") {
t.Fatalf("output does not name the overrides file:\n%s", out)
}
reloaded, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if reloaded.String("push.public_key") != pub || reloaded.String("push.private_key") != priv {
t.Fatal("the reloaded config does not return the new keys")
}
if reloaded.String("app.name") != "acme" {
t.Fatalf("an earlier override was lost: app.name = %q", reloaded.String("app.name"))
}
info, err := os.Stat(filepath.Join(dir, "env", "development", "overrides.yaml"))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("overrides mode %o, want 0600", info.Mode().Perm())
}
})
}
type fakeSource struct {
subs []SubscriptionInfo
err error
calls atomic.Int32
}
func (f *fakeSource) Subscriptions(_ context.Context, userID uint) ([]SubscriptionInfo, error) {
f.calls.Add(1)
if userID != 5 {
return nil, ErrUserNotFound
}
return f.subs, f.err
}
func publishSource(t *testing.T, app *backpack.App, src SubscriptionSource) {
t.Helper()
if err := app.Publish[SubscriptionSource](src); err != nil {
t.Fatal(err)
}
}
func TestTestPushCommand(t *testing.T) {
keys, err := GenerateVAPIDKeys()
if err != nil {
t.Fatal(err)
}
noSecret := func(t *testing.T, out string) {
t.Helper()
if strings.Contains(out, keys.PrivateKey) {
t.Fatalf("output contains the configured private key:\n%s", out)
}
}
t.Run("no subscription source", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
out, err := runCommand(t, app, "websockets:test-push", "5", "--show-config")
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
for _, want := range []string{"Current Configuration:", "Enabled: ✓ Yes", "Public Key: ✓ Set (87 chars)", "Private Key: ✓ Set (43 chars)", "Subject: mailto:ops@example.com", "Public key length valid: 87 chars", "Private key length valid: 43 chars", "Subject format: mailto", "no subscription source registered"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
noSecret(t, out)
})
t.Run("unknown user", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
publishSource(t, app, &fakeSource{err: ErrUserNotFound})
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil || !strings.Contains(out, "User 5 not found") {
t.Fatalf("err = %v, output:\n%s", err, out)
}
noSecret(t, out)
})
t.Run("no subscriptions", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
publishSource(t, app, &fakeSource{})
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil || !strings.Contains(out, "No push subscriptions found for this user") {
t.Fatalf("err = %v, output:\n%s", err, out)
}
})
var hits atomic.Int32
received := make(chan receivedPush, 8)
var subs []testSubscriber
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Path == "/gone" {
w.WriteHeader(http.StatusGone)
return
}
for _, s := range subs {
if s.sub.Endpoint == "https://"+r.Host+r.URL.Path {
received <- verifyVAPIDRequest(r, s)
w.WriteHeader(http.StatusCreated)
return
}
}
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
longPath := "/push/" + strings.Repeat("a", 80)
subs = []testSubscriber{newTestSubscriber(t, srv.URL+longPath+"1"), newTestSubscriber(t, srv.URL+longPath+"2")}
subscribed := time.Now().Add(-3 * 24 * time.Hour)
infos := func(extra ...SubscriptionInfo) []SubscriptionInfo {
out := []SubscriptionInfo{
{Subscription: subs[0].sub, ID: 7, UserAgent: "Firefox 140", SubscribedAt: &subscribed},
{Subscription: subs[1].sub, ID: 8, UserAgent: "Chrome 150", SubscribedAt: &subscribed, LastUsedAt: &subscribed},
}
return append(out, extra...)
}
t.Run("push disabled lists but does not send", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(false, keys, "127.0.0.1")})
publishSource(t, app, &fakeSource{subs: infos()})
svc, err := From(app)
if err != nil {
t.Fatal(err)
}
svc.SetHTTPClient(srv.Client())
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
for _, want := range []string{"Enabled: ✗ No", "Found 2 subscription(s)", "Subscription #7:", "Endpoint: " + subs[0].sub.Endpoint[:60] + "...", "User Agent: Firefox 140", "Subscribed: 3 days ago", "Last Used: 3 days ago", "Push is disabled"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
if hits.Load() != 0 {
t.Fatal("a push was sent while push.enabled is false")
}
noSecret(t, out)
})
t.Run("sends one encrypted push per subscription", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{
"app.yaml": "name: acme\n",
"push.yaml": pushYAML(true, keys, "127.0.0.1"),
})
publishSource(t, app, &fakeSource{subs: infos()})
svc, err := From(app)
if err != nil {
t.Fatal(err)
}
svc.SetHTTPClient(srv.Client())
out, err := runCommand(t, app, "websockets:test-push", "5")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
if !strings.Contains(out, "Test notification sent successfully") {
t.Fatalf("output:\n%s", out)
}
for range 2 {
got := <-received
if got.err != nil {
t.Fatalf("push service rejected: %v", got.err)
}
var body struct {
Title string `json:"title"`
Body string `json:"body"`
Data struct {
Test bool `json:"test"`
Timestamp int64 `json:"timestamp"`
} `json:"data"`
}
if err := json.Unmarshal(got.payload, &body); err != nil {
t.Fatalf("payload %q: %v", got.payload, err)
}
if body.Title != "acme test" || !strings.HasPrefix(body.Body, "This is a test push notification sent at ") || !body.Data.Test || body.Data.Timestamp < time.Now().Add(-time.Minute).Unix() {
t.Fatalf("payload %s", got.payload)
}
if !strings.HasPrefix(string(got.payload), `{"title":"acme test","body":"This is a test push notification sent at `) {
t.Fatalf("payload key order %s", got.payload)
}
}
if n := hits.Load(); n != 2 {
t.Fatalf("%d requests, want 2", n)
}
noSecret(t, out)
})
t.Run("a failed send exits with an error", func(t *testing.T) {
hits.Store(0)
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys, "127.0.0.1")})
gone := SubscriptionInfo{Subscription: Subscription{Endpoint: srv.URL + "/gone", P256dh: subs[0].sub.P256dh, Auth: subs[0].sub.Auth}, ID: 9}
publishSource(t, app, &fakeSource{subs: infos(gone)})
svc, err := From(app)
if err != nil {
t.Fatal(err)
}
svc.SetHTTPClient(srv.Client())
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
for range 2 {
if got := <-received; got.err != nil {
t.Fatalf("push service rejected: %v", got.err)
}
}
if n := hits.Load(); n != 3 {
t.Fatalf("%d requests, want 3 (every subscription attempted)", n)
}
if !strings.Contains(out, "Subscription #9") || !strings.Contains(out, "gone") {
t.Fatalf("output does not report the gone subscription:\n%s", out)
}
noSecret(t, out)
})
}