Files
summercms/modules/flare/commands_test.go
Jakub Zych 2baec56420 test(cabana,flare,dev,summer): json, reported errors and dev serve
- Docker: admin:token:create and list --json, -q keeps the token
- flare generate and test-push failures print once and are Reported
- summer dev starts serve with the forwarded globals, reports rebuilds,
  failures and restarts, and stops serve with SIGTERM returning nil
- delegates carry two examples and say the global options pass through
2026-10-21 19:38:19 +02:00

425 lines
15 KiB
Go

package flare
import (
"bytes"
"context"
"encoding/json"
"errors"
"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)
})
}
// TestFlareReported: a failing generate or test-push prints its own error
// line once and returns an error matching bonfire.ErrReported, so the
// binary exits 1 without a second error block.
func TestFlareReported(t *testing.T) {
keys, err := GenerateVAPIDKeys()
if err != nil {
t.Fatal(err)
}
withKeys, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
withSource, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
publishSource(t, withSource, &fakeSource{})
cases := []struct {
name string
app *backpack.App
args []string
line string
}{
{"generate --update without a config", backpack.New(nil), []string{GenerateVAPIDKeysCommandName, "--update"}, "Failed to save the keys: no configuration loaded"},
{"test-push with a bad user id", withKeys, []string{TestPushCommandName, "abc"}, "User abc not found"},
{"test-push without a source", withKeys, []string{TestPushCommandName, "5"}, "no subscription source registered"},
{"test-push for an unknown user", withSource, []string{TestPushCommandName, "9"}, "User 9 not found"},
{"test-push without subscriptions", withSource, []string{TestPushCommandName, "5"}, "No push subscriptions found for this user"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var out, errW bytes.Buffer
root, err := bonfire.NewRootIO("acme", Commands(tc.app), strings.NewReader(""), &out, &errW)
if err != nil {
t.Fatal(err)
}
if code := bonfire.Execute(context.Background(), root, tc.args); code != bonfire.ExitFailure {
t.Fatalf("exit %d\n%s%s", code, out.String(), errW.String())
}
if n := strings.Count(errW.String(), tc.line); n != 1 {
t.Errorf("error line %q printed %d times:\n%s", tc.line, n, errW.String())
}
if strings.Contains(errW.String(), "ERROR") || strings.Contains(out.String(), "ERROR") {
t.Errorf("a reported failure printed an error block:\n%s%s", out.String(), errW.String())
}
var direct bytes.Buffer
var cmd bonfire.Command
for _, c := range Commands(tc.app) {
if c.Name == tc.args[0] {
cmd = c
}
}
err = bonfire.Call(context.Background(), []bonfire.Command{cmd}, cmd.Name, tc.args[1:], &direct)
if !errors.Is(err, bonfire.ErrReported) {
t.Errorf("error %v does not match bonfire.ErrReported", err)
}
})
}
}