- 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
369 lines
12 KiB
Go
369 lines
12 KiB
Go
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)
|
|
})
|
|
}
|