package surf import ( "bytes" "context" "encoding/json" "errors" "log/slog" "os" "path/filepath" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/party" ) // summaryPlugin is a plugin with an ID and required plugins. type summaryPlugin struct { id string req []string } func (p summaryPlugin) ID() string { return p.id } func (p summaryPlugin) Requires() []string { return p.req } func (p summaryPlugin) Register(*backpack.App) error { return nil } func (p summaryPlugin) Boot(*backpack.App) error { return nil } // summaryApp is an app whose config holds a DSN with a user and password. func summaryApp(t *testing.T) *backpack.App { t.Helper() dir := t.TempDir() files := map[string]string{ "app.yaml": "name: acme\n", "database.yaml": "dsn: postgres://summer_user:pa55-secret@db.internal:5433/acme_prod?sslmode=disable\n", "mail.yaml": "driver: smtp\n", "cache.yaml": "driver: redis\n", "realtime.yaml": "driver: centrifugo\n", } for name, body := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } cfg, err := compass.Open(compass.Options{Dir: dir, Env: "staging", Environ: []string{"SUMMER_ENV=staging"}}) if err != nil { t.Fatal(err) } return backpack.New(cfg) } func TestServeSummarySmoke(t *testing.T) { plugins := []party.Plugin{ summaryPlugin{id: "acme.user"}, summaryPlugin{id: "acme.blog", req: []string{"acme.user"}}, } facts := buildFacts(summaryApp(t), factInputs{ addr: "127.0.0.1:8096", bin: "bin/acme", plugins: plugins, queues: []string{"default", "mail"}, worker: true, adminPrefix: "/backend", mounted: true, admins: adminsNone, pending: []lagoon.MigrationRef{ {Plugin: "summercms.cabana", ID: "0001"}, {Plugin: "acme.blog", ID: "0001_posts"}, {Plugin: "acme.blog", ID: "0002_tags"}, }, version: bonfire.Version{App: "v1.4.0", Framework: "v0.4.0"}, }) var block bytes.Buffer out := bonfire.NewOutput(strings.NewReader(""), &block, &block) printSummary(out, facts) got := block.String() for _, want := range []string{ "SummerCMS acme v1.4.0 (framework v0.4.0)", "Environment staging", "http://127.0.0.1:8096/", "http://127.0.0.1:8096/backend", "db.internal:5433/acme_prod", "3 pending — run bin/acme migrate", "2 (acme.user, acme.blog)", "default, mail (worker in serve)", "Mail smtp", "Cache redis", "Realtime centrifugo", "Search null", "no administrators yet", } { if !strings.Contains(got, want) { t.Errorf("summary block lacks %q:\n%s", want, got) } } if strings.Contains(got, "requires acme.user") { t.Errorf("verbosity 0 prints per-plugin lines:\n%s", got) } verbose := facts.details(1) var perPlugin bool for _, d := range verbose { if strings.TrimSpace(d.Label) == "acme.blog" && strings.Contains(d.Value, "requires acme.user; 2 pending migrations") { perPlugin = true } } if !perPlugin { t.Errorf("verbosity 1 lacks the acme.blog line: %#v", verbose) } var logs bytes.Buffer logSummary(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), facts) var summary map[string]any listening := 0 for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("log line %q: %v", line, err) } switch rec["msg"] { case summaryMessage: summary = rec case "listening on 127.0.0.1:8096": listening++ } } if listening != 1 { t.Fatalf("want one record \"listening on 127.0.0.1:8096\":\n%s", logs.String()) } if summary == nil { t.Fatalf("no %q record:\n%s", summaryMessage, logs.String()) } for key, want := range map[string]any{ "environment": "staging", "database": "db.internal:5433/acme_prod", "migrations_pending": float64(3), "plugins": float64(2), "plugin_ids": "acme.user,acme.blog", "mail": "smtp", "cache": "redis", "realtime": "centrifugo", "search": "null", } { if summary[key] != want { t.Errorf("summary record %s = %v, want %v", key, summary[key], want) } } for _, secret := range []string{"pa55-secret", "summer_user"} { if strings.Contains(got, secret) || strings.Contains(logs.String(), secret) { t.Fatalf("summary leaks %q:\n%s\n%s", secret, got, logs.String()) } } if lagoon.DatabaseLabel("host=db port=5432 dbname=acme user=u password=p") != "db:5432/acme" { t.Errorf("keyword DSN = %q", lagoon.DatabaseLabel("host=db port=5432 dbname=acme user=u password=p")) } if lagoon.DatabaseLabel("postgres://u:p@[::1:bad") != "configured" { t.Errorf("unparsable DSN = %q, want configured", lagoon.DatabaseLabel("postgres://u:p@[::1:bad")) } probe := []bonfire.Command{{ Name: "probe:summary", Run: func(_ context.Context, _ bonfire.Input, out bonfire.Output) error { printSummary(out, facts) return nil }, }} for _, tc := range []struct { args []string block bool }{{nil, true}, {[]string{"-q"}, false}} { var buf bytes.Buffer if err := bonfire.Call(context.Background(), probe, "probe:summary", tc.args, &buf); err != nil { t.Fatal(err) } if strings.Contains(buf.String(), "Environment") != tc.block { t.Errorf("args %v: block printed = %v, want %v:\n%s", tc.args, !tc.block, tc.block, buf.String()) } } } func TestShutdownReportSmoke(t *testing.T) { report := shutdownReport{ Draining: 3, Aborted: 1, Served: 120, Worker: &conga.WorkerStats{Done: 7, Failed: 2, HandedBack: 1}, Uptime: 3*time.Minute + 4*time.Second, Errors: []error{errors.New("surf: drain requests: context deadline exceeded")}, } var buf bytes.Buffer out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) bonfire.Details(out, "Shutdown report", report.details()) got := buf.String() for _, want := range []string{ "Shutdown report", "Requests 120 served, 2 drained, 1 aborted", "Worker 7 done, 2 failed, 1 handed back to the queue", "Uptime 3m4s", "Errors surf: drain requests: context deadline exceeded", } { if !strings.Contains(got, want) { t.Errorf("report lacks %q:\n%s", want, got) } } if report.Err() == nil { t.Fatal("a drain error must make the report fail") } var logs bytes.Buffer logReport(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), report) var rec map[string]any if err := json.Unmarshal(logs.Bytes(), &rec); err != nil { t.Fatal(err) } if rec["msg"] != reportMessage || rec["level"] != "ERROR" || rec["served"] != float64(120) || rec["drained"] != float64(2) || rec["aborted"] != float64(1) || rec["jobs_done"] != float64(7) || rec["jobs_handed_back"] != float64(1) { t.Fatalf("report record = %v", rec) } clean := shutdownReport{Served: 5, Uptime: time.Second} if clean.Err() != nil { t.Fatal("a stop without errors must exit 0") } buf.Reset() bonfire.Details(out, "", clean.details()) if !strings.Contains(buf.String(), "Worker not running in serve") || !strings.Contains(buf.String(), "Errors none") { t.Fatalf("clean report:\n%s", buf.String()) } }