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) }) }