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:
368
modules/flare/commands_test.go
Normal file
368
modules/flare/commands_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user