diff --git a/modules/conga/confirm_linux_test.go b/modules/conga/confirm_linux_test.go new file mode 100644 index 00000000..ca07b593 --- /dev/null +++ b/modules/conga/confirm_linux_test.go @@ -0,0 +1,174 @@ +//go:build linux + +package conga + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "sync" + "syscall" + "testing" + "time" + "unsafe" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/compass" +) + +// ptyOutput collects what the program writes to a pseudo-terminal. +type ptyOutput struct { + mu sync.Mutex + buf strings.Builder +} + +func (p *ptyOutput) String() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.buf.String() +} + +// openPTY opens a pseudo-terminal pair: the slave is a terminal for the +// console (stdin and stdout), input written to the master reaches the +// slave's stdin, and everything the slave prints is collected. +func openPTY(t *testing.T) (master, slave *os.File, output *ptyOutput) { + t.Helper() + m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("open /dev/ptmx: %v", err) + } + var unlock int32 + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); e != 0 { + t.Fatalf("unlock pty: %v", e) + } + var n uint32 + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))); e != 0 { + t.Fatalf("pty number: %v", e) + } + s, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("open pty slave: %v", err) + } + output = &ptyOutput{} + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 4096) + for { + n, err := m.Read(buf) + output.mu.Lock() + output.buf.Write(buf[:n]) + output.mu.Unlock() + if err != nil { + return + } + } + }() + t.Cleanup(func() { + _ = s.Close() + _ = m.Close() + <-done + }) + return m, s, output +} + +// productionApp is an app in the production environment without a DSN. +func productionApp(t *testing.T, env string) *backpack.App { + t.Helper() + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: env, Environ: []string{"SUMMER_ENV=" + env}}) + if err != nil { + t.Fatal(err) + } + return backpack.New(cfg) +} + +// runQueueClear runs queue:clear with args on stdin and stdout of the +// caller's choosing and returns the exit code and the error stream. +func runQueueClear(t *testing.T, app *backpack.App, in io.Reader, out, errW io.Writer, args ...string) int { + t.Helper() + var clear bonfire.Command + for _, c := range RuntimeCommands(app, nil) { + if c.Name == "queue:clear" { + clear = c + } + } + if clear.Run == nil { + t.Fatal("queue:clear not registered") + } + root, err := bonfire.NewRootIO("acme", []bonfire.Command{clear}, in, out, errW) + if err != nil { + t.Fatal(err) + } + return bonfire.Execute(context.Background(), root, append([]string{"queue:clear"}, args...)) +} + +// TestQueueClearConfirm: in production queue:clear asks on an interactive +// terminal and a "no" ends it with exit 1 and no error block, before the +// database is opened; --force, -n, a non-production environment and a +// non-terminal stdin skip the question (the run then reaches the database, +// which these apps do not configure). +func TestQueueClearConfirm(t *testing.T) { + prod := productionApp(t, "production") + + master, slave, output := openPTY(t) + if _, err := master.Write([]byte("n\n")); err != nil { + t.Fatal(err) + } + if code := runQueueClear(t, prod, slave, slave, slave, "--no-ansi", "imports"); code != bonfire.ExitFailure { + t.Fatalf("answer no: exit %d\n%s", code, output.String()) + } + waitFor(t, output, "Cancelled.") + got := output.String() + if !strings.Contains(got, `Delete every pending job of queue "imports" in production? [y/N]`) { + t.Errorf("question not asked:\n%s", got) + } + if strings.Contains(got, "ERROR") || strings.Contains(got, "database.dsn") { + t.Errorf("a cancelled run printed an error block or reached the database:\n%s", got) + } + + for name, tc := range map[string]struct { + app *backpack.App + args []string + }{ + "--force": {prod, []string{"--no-ansi", "--force"}}, + "-n": {prod, []string{"--no-ansi", "-n"}}, + "not production": {productionApp(t, "staging"), []string{"--no-ansi"}}, + } { + master, slave, output := openPTY(t) + if _, err := master.Write([]byte("n\n")); err != nil { + t.Fatal(err) + } + if code := runQueueClear(t, tc.app, slave, slave, slave, tc.args...); code != bonfire.ExitFailure { + t.Fatalf("%s: exit %d", name, code) + } + waitFor(t, output, "database.dsn") + if strings.Contains(output.String(), "[y/N]") { + t.Errorf("%s: the question was asked:\n%s", name, output.String()) + } + } + + // Off a terminal: no question, stdin is never read. + in := strings.NewReader("n\n") + var out, errW strings.Builder + if code := runQueueClear(t, prod, in, &out, &errW); code != bonfire.ExitFailure { + t.Fatalf("non-terminal: exit %d", code) + } + if in.Len() != 2 || strings.Contains(out.String(), "[y/N]") || !strings.Contains(errW.String(), "database.dsn") { + t.Errorf("non-terminal run asked or read stdin (left %d):\n%s\n%s", in.Len(), out.String(), errW.String()) + } +} + +// waitFor waits until the pty output contains want. +func waitFor(t *testing.T, output *ptyOutput, want string) { + t.Helper() + for range 200 { + if strings.Contains(output.String(), want) { + return + } + <-time.After(10 * time.Millisecond) + } + t.Fatalf("pty output lacks %q:\n%s", want, output.String()) +} diff --git a/modules/conga/stats_test.go b/modules/conga/stats_test.go new file mode 100644 index 00000000..1f39b8d7 --- /dev/null +++ b/modules/conga/stats_test.go @@ -0,0 +1,87 @@ +package conga + +import ( + "context" + "errors" + "io" + "testing" + + "git.golem15.com/golem15/summercms/modules/diary" + "github.com/riverqueue/river/rivertype" +) + +// TestWorkerStats: runAttempt counts every attempt of the running worker: +// done on nil, failed on an error or a panic, in flight while it runs, and +// handed back when Stop cancelled it; a nil worker has no counters. +func TestWorkerStats(t *testing.T) { + // The panicking attempt logs its stack; keep it out of the test output. + t.Cleanup(diary.Reset) + diary.Bootstrap(io.Discard, diary.Options{}) + m := &Manager{} + counters := &workerCounters{} + m.counters.Store(counters) + w := &Worker{counters: counters} + row := &rivertype.JobRow{ID: 1, Kind: "acme_probe", Attempt: 1, MaxAttempts: 3} + ctx := context.Background() + + for range 3 { + if err := m.runAttempt(ctx, row, func(context.Context) error { return nil }); err != nil { + t.Fatal(err) + } + } + boom := errors.New("boom") + if err := m.runAttempt(ctx, row, func(context.Context) error { return boom }); !errors.Is(err, boom) { + t.Fatalf("failing attempt returned %v", err) + } + if err := m.runAttempt(ctx, row, func(context.Context) error { panic("kaboom") }); err == nil { + t.Fatal("a panic did not become an error") + } + + started, release := make(chan struct{}), make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- m.runAttempt(ctx, row, func(context.Context) error { + close(started) + <-release + return nil + }) + }() + <-started + if got := w.Stats(); got.InFlight != 1 || got.Done != 3 || got.Failed != 2 { + t.Fatalf("while running: %+v, want 1 in flight, 3 done, 2 failed", got) + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + + // Stop cancels the running attempts: they are handed back, not failed. + counters.stopping.Store(true) + cctx, cancel := context.WithCancel(ctx) + cancel() + if err := m.runAttempt(cctx, row, func(c context.Context) error { return c.Err() }); err == nil { + t.Fatal("cancelled attempt returned nil") + } + // An error unrelated to the cancellation while stopping still fails. + if err := m.runAttempt(ctx, row, func(context.Context) error { return boom }); err == nil { + t.Fatal("failing attempt returned nil") + } + + want := WorkerStats{Done: 4, Failed: 3, InFlight: 0, HandedBack: 1} + if got := w.Stats(); got != want { + t.Fatalf("Stats() = %+v, want %+v", got, want) + } + var none *Worker + if got := none.Stats(); got != (WorkerStats{}) { + t.Fatalf("nil worker Stats() = %+v", got) + } + if got := (&Worker{}).Stats(); got != (WorkerStats{}) { + t.Fatalf("worker without counters Stats() = %+v", got) + } + + // Without a running worker, attempts run and nothing is counted. + idle := &Manager{} + if err := idle.runAttempt(ctx, row, func(context.Context) error { return nil }); err != nil { + t.Fatal(err) + } +} diff --git a/modules/lagoon/commands_flags_test.go b/modules/lagoon/commands_flags_test.go new file mode 100644 index 00000000..1e945dec --- /dev/null +++ b/modules/lagoon/commands_flags_test.go @@ -0,0 +1,133 @@ +package lagoon + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/compass" +) + +// envApp is an app in environment env with a fixed test app key and an +// optional DSN. +func envApp(t *testing.T, env, dsn string, extra ...string) *backpack.App { + t.Helper() + environ := append([]string{ + "SUMMER_ENV=" + env, + "SUMMER_APP__KEY=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("k"), 32)), + }, extra...) + if dsn != "" { + environ = append(environ, "SUMMER_DATABASE__DSN="+dsn) + } + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: env, Environ: environ}) + if err != nil { + t.Fatal(err) + } + return backpack.New(cfg) +} + +// probeConfirm is a command that runs confirmProduction, as migrate does. +func probeConfirm(app *backpack.App) bonfire.Command { + return bonfire.Command{ + Name: "probe:confirm", + Description: "Ask like migrate does", + Flags: []bonfire.Flag{{Name: "force", Description: forceDescription, Bare: true}}, + Run: func(_ context.Context, in bonfire.Input, out bonfire.Output) error { + if err := confirmProduction(app, in, out, "Apply 3 migrations to the production database?", "Migration cancelled."); err != nil { + return err + } + out.Println("proceeded") + return nil + }, + } +} + +// TestConfirmProductionNonTTYProceeds: in production a console that is not +// an interactive terminal never asks and never reads stdin, so migrate, +// migrate:rollback and deferred:purge proceed in deploys and cron; +// bonfire.Call does the same. Every destructive command offers --force. +func TestConfirmProductionNonTTYProceeds(t *testing.T) { + app := envApp(t, "production", "") + in := strings.NewReader("n\n") + var out, errW bytes.Buffer + root, err := bonfire.NewRootIO("acme", []bonfire.Command{probeConfirm(app)}, in, &out, &errW) + if err != nil { + t.Fatal(err) + } + if code := bonfire.Execute(context.Background(), root, []string{"probe:confirm"}); code != bonfire.ExitOK { + t.Fatalf("exit %d\n%s", code, errW.String()) + } + if in.Len() != 2 || out.String() != "proceeded\n" { + t.Fatalf("stdin left %d, output %q: the probe asked or did not proceed", in.Len(), out.String()) + } + var buf bytes.Buffer + if err := bonfire.Call(context.Background(), []bonfire.Command{probeConfirm(app)}, "probe:confirm", nil, &buf); err != nil || buf.String() != "proceeded\n" { + t.Fatalf("Call: %v, %q", err, buf.String()) + } + + for _, c := range RuntimeCommands(app, nil) { + switch c.Name { + case "migrate", "migrate:rollback", "deferred:purge": + force := false + for _, f := range c.Flags { + if f.Name == "force" && f.Bare { + force = true + } + } + if !force || !strings.Contains(c.Long, "--force skips the") || !strings.Contains(c.Long, "production") { + t.Errorf("%s does not document the production question and --force", c.Name) + } + } + } +} + +// TestKeyGenerateJSON: key:generate --json prints {"key": "..."} with a +// 32-byte base64 key, under -q too. +func TestKeyGenerateJSON(t *testing.T) { + seen := map[string]bool{} + for _, args := range [][]string{{"--json"}, {"--json", "-q"}} { + var buf bytes.Buffer + if err := bonfire.Call(context.Background(), []bonfire.Command{KeyGenerateCommand()}, "key:generate", args, &buf); err != nil { + t.Fatal(err) + } + var got map[string]string + if err := json.Unmarshal(buf.Bytes(), &got); err != nil || len(got) != 1 { + t.Fatalf("%v: not {\"key\": ...}: %v\n%s", args, err, buf.String()) + } + raw, err := base64.StdEncoding.DecodeString(got["key"]) + if err != nil || len(raw) != 32 { + t.Fatalf("%v: key %q is not 32 base64 bytes", args, got["key"]) + } + if seen[got["key"]] { + t.Fatal("key:generate printed the same key twice") + } + seen[got["key"]] = true + } +} + +// TestKeyGenerateQuiet: under -q key:generate prints the bare key and a +// newline (KEY=$(bin/acme key:generate -q)); without it the key follows +// the success mark. +func TestKeyGenerateQuiet(t *testing.T) { + var buf bytes.Buffer + if err := bonfire.Call(context.Background(), []bonfire.Command{KeyGenerateCommand()}, "key:generate", []string{"-q"}, &buf); err != nil { + t.Fatal(err) + } + out := buf.String() + key := strings.TrimSuffix(out, "\n") + if strings.Count(out, "\n") != 1 || strings.ContainsAny(key, " ✓") || len(key) != 44 { + t.Fatalf("-q output = %q, want the bare 44-character key", out) + } + buf.Reset() + if err := bonfire.Call(context.Background(), []bonfire.Command{KeyGenerateCommand()}, "key:generate", nil, &buf); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(buf.String(), "✓ ") || len(strings.TrimSpace(strings.TrimPrefix(buf.String(), "✓ "))) != 44 { + t.Fatalf("default output = %q", buf.String()) + } +} diff --git a/modules/lagoon/pending_test.go b/modules/lagoon/pending_test.go new file mode 100644 index 00000000..065e698c --- /dev/null +++ b/modules/lagoon/pending_test.go @@ -0,0 +1,296 @@ +package lagoon + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// pendingPlugins are two plugins with one and two migrations. +func pendingPlugins() (alpha, beta migPlugin) { + table := func(name string) *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202610210001_create_" + name, + Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE ` + name + ` (id BIGSERIAL PRIMARY KEY)`).Error }, + Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS ` + name).Error }, + } + } + seed := &gormigrate.Migration{ + ID: "202610210002_seed_pending_beta", + Migrate: func(tx *gorm.DB) error { return tx.Exec(`INSERT INTO pending_beta DEFAULT VALUES`).Error }, + Rollback: func(tx *gorm.DB) error { return tx.Exec(`DELETE FROM pending_beta`).Error }, + } + return migPlugin{id: "demo.alpha", migrations: []*gormigrate.Migration{table("pending_alpha")}}, + migPlugin{id: "demo.beta", migrations: []*gormigrate.Migration{table("pending_beta"), seed}} +} + +// pluginRefs returns the refs of pending that belong to plugin IDs (the +// framework sets come first and are left out). +func pluginRefs(pending []MigrationRef) []string { + var out []string + for _, r := range pending { + if strings.HasPrefix(r.Plugin, "demo.") { + out = append(out, r.Plugin+":"+r.ID) + } + } + return out +} + +// runLagoon runs one runtime command on a fresh app (a non-terminal stdin +// holding "n", which a question would consume) and returns stdout, stderr, +// the exit code and what is left of stdin. +func runLagoon(t *testing.T, env, dsn string, plugins []party.Plugin, args ...string) (string, string, int, int) { + t.Helper() + app := envApp(t, env, dsn, "SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://"+t.TempDir()) + in := strings.NewReader("n\n") + var out, errW bytes.Buffer + root, err := bonfire.NewRootIO("acme", RuntimeCommands(app, plugins), in, &out, &errW) + if err != nil { + t.Fatal(err) + } + code := bonfire.Execute(context.Background(), root, args) + return out.String(), errW.String(), code, in.Len() +} + +// TestPendingMigrations: with no history every migration is pending, +// framework sets first, then plugins in order; applied IDs drop out, a +// partly applied set keeps only the rest, and a missing history table +// makes its whole set pending. +func TestPendingMigrations(t *testing.T) { + db, _ := dedicatedDB(t, "lagoon_pending") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + alpha, beta := pendingPlugins() + plugins := []party.Plugin{alpha, beta} + + all, err := PendingMigrations(gdb, plugins) + if err != nil { + t.Fatal(err) + } + want := []string{"demo.alpha:202610210001_create_pending_alpha", "demo.beta:202610210001_create_pending_beta", "demo.beta:202610210002_seed_pending_beta"} + if got := pluginRefs(all); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("plugin refs = %v, want %v", got, want) + } + if len(all) <= len(want) || strings.HasPrefix(all[0].Plugin, "demo.") { + t.Fatalf("framework sets do not come first: %v", all) + } + for _, ref := range all[:len(all)-len(want)] { + if strings.HasPrefix(ref.Plugin, "demo.") { + t.Fatalf("a plugin ref sits among the framework sets: %v", all) + } + } + + // Framework and alpha applied: only beta is pending, its history table + // still missing. + if err := Migrate(gdb, []party.Plugin{alpha}); err != nil { + t.Fatal(err) + } + if gdb.Migrator().HasTable("summer_migrations_demo_beta") { + t.Fatal("beta's history table exists before beta migrated") + } + some, err := PendingMigrations(gdb, plugins) + if err != nil { + t.Fatal(err) + } + if strings.Join(pluginRefs(some), ",") != strings.Join(want[1:], ",") || len(some) != 2 { + t.Fatalf("after alpha: %v, want only beta's two", some) + } + + // beta applied, then its seed removed from the history: one pending. + if err := Migrate(gdb, plugins); err != nil { + t.Fatal(err) + } + if err := gdb.Exec(`DELETE FROM summer_migrations_demo_beta WHERE id = ?`, "202610210002_seed_pending_beta").Error; err != nil { + t.Fatal(err) + } + partly, err := PendingMigrations(gdb, plugins) + if err != nil { + t.Fatal(err) + } + if len(partly) != 1 || partly[0] != (MigrationRef{Plugin: "demo.beta", ID: "202610210002_seed_pending_beta"}) { + t.Fatalf("partly applied = %v", partly) + } + if _, err := PendingMigrations(nil, plugins); err == nil { + t.Fatal("a nil db did not fail") + } +} + +// TestMigratePretend: migrate --pretend lists every pending migration and +// applies nothing; migrate in production off a terminal then applies them +// without asking (stdin unread), and both report nothing left after. +func TestMigratePretend(t *testing.T) { + db, dsn := dedicatedDB(t, "lagoon_pretend") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + alpha, beta := pendingPlugins() + plugins := []party.Plugin{alpha, beta} + before, err := PendingMigrations(gdb, plugins) + if err != nil { + t.Fatal(err) + } + + out, errOut, code, left := runLagoon(t, "production", dsn, plugins, "migrate", "--pretend") + if code != bonfire.ExitOK || left != 2 { + t.Fatalf("--pretend: exit %d, stdin left %d\n%s%s", code, left, out, errOut) + } + for _, ref := range before { + if !strings.Contains(out, ref.Plugin+": "+ref.ID+"\n") { + t.Errorf("--pretend lacks %s: %s:\n%s", ref.Plugin, ref.ID, out) + } + } + if !strings.Contains(out, fmt.Sprintf("%d migrations would run", len(before))) { + t.Errorf("--pretend lacks the count:\n%s", out) + } + after, err := PendingMigrations(gdb, plugins) + if err != nil || len(after) != len(before) || gdb.Migrator().HasTable("pending_alpha") { + t.Fatalf("--pretend applied something: %v, %v", after, err) + } + + // -q keeps the data lines (the IDs) and drops the count line. + out, _, code, _ = runLagoon(t, "production", dsn, plugins, "migrate", "--pretend", "-q") + if code != bonfire.ExitOK || !strings.Contains(out, "demo.beta: 202610210002_seed_pending_beta\n") || strings.Contains(out, "would run") { + t.Errorf("--pretend -q:\n%s", out) + } + + out, errOut, code, left = runLagoon(t, "production", dsn, plugins, "migrate") + if code != bonfire.ExitOK || left != 2 || strings.Contains(out, "[y/N]") { + t.Fatalf("production migrate off a terminal: exit %d, stdin left %d\n%s%s", code, left, out, errOut) + } + if !strings.Contains(out, fmt.Sprintf("Applied %d migrations", len(before))) || !strings.Contains(out, "demo.beta: 2 applied") { + t.Errorf("migrate result lines:\n%s", out) + } + if !gdb.Migrator().HasTable("pending_beta") { + t.Fatal("migrate did not apply beta") + } + out, _, _, _ = runLagoon(t, "production", dsn, plugins, "migrate", "--pretend") + if !strings.Contains(out, "0 migrations would run") { + t.Errorf("--pretend after migrate:\n%s", out) + } + out, _, _, _ = runLagoon(t, "production", dsn, plugins, "migrate") + if !strings.Contains(out, "Nothing to migrate") { + t.Errorf("second migrate:\n%s", out) + } +} + +// TestRollbackPretend: migrate:rollback --pretend names the migration that +// would roll back and changes nothing; the real rollback in production off +// a terminal proceeds without asking. +func TestRollbackPretend(t *testing.T) { + db, dsn := dedicatedDB(t, "lagoon_rollback_pretend") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + alpha, beta := pendingPlugins() + plugins := []party.Plugin{alpha, beta} + if err := Migrate(gdb, []party.Plugin{alpha}); err != nil { + t.Fatal(err) + } + out, _, code, _ := runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta", "--pretend") + if code != bonfire.ExitOK || !strings.Contains(out, "demo.beta has no applied migration to roll back") { + t.Fatalf("pretend without history: exit %d\n%s", code, out) + } + if err := Migrate(gdb, plugins); err != nil { + t.Fatal(err) + } + out, _, code, _ = runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta", "--pretend") + if code != bonfire.ExitOK || !strings.Contains(out, "Would roll back 202610210002_seed_pending_beta of demo.beta") { + t.Fatalf("pretend: exit %d\n%s", code, out) + } + var n int64 + if err := gdb.Table("summer_migrations_demo_beta").Count(&n).Error; err != nil || n != 2 { + t.Fatalf("pretend changed the history: %d rows, %v", n, err) + } + + out, errOut, code, left := runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta") + if code != bonfire.ExitOK || left != 2 || !strings.Contains(out, "Rolled back 202610210002_seed_pending_beta of demo.beta") { + t.Fatalf("rollback: exit %d, stdin left %d\n%s%s", code, left, out, errOut) + } + if err := gdb.Table("summer_migrations_demo_beta").Count(&n).Error; err != nil || n != 1 { + t.Fatalf("rollback left %d history rows, %v", n, err) + } +} + +// TestDeferredPurgePretend: deferred:purge --pretend counts the bindings +// older than --days and deletes none; the real purge in production off a +// terminal proceeds without asking. +func TestDeferredPurgePretend(t *testing.T) { + db, dsn := dedicatedDB(t, "lagoon_purge_pretend") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + if err := Migrate(gdb, nil); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-10 * 24 * time.Hour) + for i, created := range []time.Time{old, old, time.Now()} { + row := DeferredBinding{MasterType: "acme.blog.post", MasterField: "cover", SlaveType: "acme.unknown", SlaveID: fmt.Sprint(i + 1), + SessionKey: "session", IsBind: true, BackendUserID: 1, CreatedAt: created, UpdatedAt: created} + if err := gdb.Create(&row).Error; err != nil { + t.Fatal(err) + } + } + out, errOut, code, _ := runLagoon(t, "production", dsn, nil, "deferred:purge", "--days", "5", "--pretend") + if code != bonfire.ExitOK || !strings.Contains(out, "2 deferred bindings older than 5 days would be purged") { + t.Fatalf("pretend: exit %d\n%s%s", code, out, errOut) + } + var n int64 + if err := gdb.Model(&DeferredBinding{}).Count(&n).Error; err != nil || n != 3 { + t.Fatalf("pretend deleted bindings: %d left, %v", n, err) + } + + out, errOut, code, left := runLagoon(t, "production", dsn, nil, "deferred:purge", "--days", "5") + if code != bonfire.ExitOK || left != 2 || !strings.Contains(out, "older than 5 days") || strings.Contains(out, "[y/N]") { + t.Fatalf("purge: exit %d, stdin left %d\n%s%s", code, left, out, errOut) + } +} + +// TestMigrateStatusJSON: migrate:status --json lists every declared +// migration of the plugins with its table and applied flag, under -q too. +func TestMigrateStatusJSON(t *testing.T) { + db, dsn := dedicatedDB(t, "lagoon_status_json") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + alpha, beta := pendingPlugins() + plugins := []party.Plugin{alpha, beta} + if err := Migrate(gdb, []party.Plugin{alpha}); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"migrate:status", "--json"}, {"migrate:status", "--json", "-q"}} { + out, errOut, code, _ := runLagoon(t, "development", dsn, plugins, args...) + if code != bonfire.ExitOK { + t.Fatalf("%v: exit %d\n%s", args, code, errOut) + } + var list []statusJSON + if err := json.Unmarshal([]byte(out), &list); err != nil { + t.Fatalf("%v: not JSON: %v\n%s", args, err, out) + } + want := []statusJSON{ + {Plugin: "demo.alpha", Table: "summer_migrations_demo_alpha", ID: "202610210001_create_pending_alpha", Applied: true}, + {Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210001_create_pending_beta"}, + {Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210002_seed_pending_beta"}, + } + if fmt.Sprint(list) != fmt.Sprint(want) { + t.Errorf("%v: status = %+v, want %+v", args, list, want) + } + if !strings.Contains(out, `"applied": false`) { + t.Errorf("%v: pending entries lack \"applied\": false:\n%s", args, out) + } + } +} diff --git a/modules/lagoon/pty_linux_test.go b/modules/lagoon/pty_linux_test.go new file mode 100644 index 00000000..dac3eae7 --- /dev/null +++ b/modules/lagoon/pty_linux_test.go @@ -0,0 +1,152 @@ +//go:build linux + +package lagoon + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "sync" + "syscall" + "testing" + "time" + "unsafe" + + "git.golem15.com/golem15/summercms/modules/bonfire" +) + +// ptyOutput collects what the program writes to a pseudo-terminal. +type ptyOutput struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (p *ptyOutput) String() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.buf.String() +} + +// openPTY opens a pseudo-terminal pair: the slave is a terminal for the +// console (stdin and stdout), input written to the master reaches the +// slave's stdin, and everything the slave prints is collected. +func openPTY(t *testing.T) (master, slave *os.File, output *ptyOutput) { + t.Helper() + m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("open /dev/ptmx: %v", err) + } + var unlock int32 + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); e != 0 { + t.Fatalf("unlock pty: %v", e) + } + var n uint32 + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))); e != 0 { + t.Fatalf("pty number: %v", e) + } + s, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("open pty slave: %v", err) + } + output = &ptyOutput{} + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 4096) + for { + n, err := m.Read(buf) + output.mu.Lock() + output.buf.Write(buf[:n]) + output.mu.Unlock() + if err != nil { + return + } + } + }() + t.Cleanup(func() { + _ = s.Close() + _ = m.Close() + <-done + }) + return m, s, output +} + +// waitFor waits until the pty output contains want. +func waitFor(t *testing.T, output *ptyOutput, want string) { + t.Helper() + for range 200 { + if strings.Contains(output.String(), want) { + return + } + <-time.After(10 * time.Millisecond) + } + t.Fatalf("pty output lacks %q:\n%s", want, output.String()) +} + +// runOnPTY runs probe:confirm with args on a fresh pseudo-terminal after +// typing answer, and returns the exit code and the terminal output. +func runOnPTY(t *testing.T, env, answer string, args ...string) (int, *ptyOutput) { + t.Helper() + master, slave, output := openPTY(t) + if _, err := master.Write([]byte(answer)); err != nil { + t.Fatal(err) + } + root, err := bonfire.NewRootIO("acme", []bonfire.Command{probeConfirm(envApp(t, env, ""))}, slave, slave, slave) + if err != nil { + t.Fatal(err) + } + // --no-ansi keeps the line prompt (huh needs a terminal size). + code := bonfire.Execute(context.Background(), root, append([]string{"--no-ansi", "probe:confirm"}, args...)) + return code, output +} + +// TestConfirmProductionInteractiveNo: on an interactive terminal in +// production the question is asked; "n" or an empty answer prints the +// cancel line and exits 1 without an error block (Reported); "y" proceeds. +func TestConfirmProductionInteractiveNo(t *testing.T) { + for _, answer := range []string{"n\n", "\n", "no\n"} { + code, output := runOnPTY(t, "production", answer) + if code != bonfire.ExitFailure { + t.Fatalf("answer %q: exit %d\n%s", answer, code, output.String()) + } + waitFor(t, output, "Migration cancelled.") + got := output.String() + if !strings.Contains(got, "Apply 3 migrations to the production database? [y/N]") { + t.Errorf("answer %q: question not asked:\n%s", answer, got) + } + if strings.Contains(got, "ERROR") || strings.Contains(got, "proceeded") { + t.Errorf("answer %q: error block or the step ran:\n%s", answer, got) + } + } + code, output := runOnPTY(t, "production", "y\n") + if code != bonfire.ExitOK { + t.Fatalf("answer y: exit %d\n%s", code, output.String()) + } + waitFor(t, output, "proceeded") +} + +// TestForceSkipsConfirm: --force, -n and any environment other than +// production proceed on an interactive terminal without asking (the typed +// "n" is never read). +func TestForceSkipsConfirm(t *testing.T) { + for name, tc := range map[string]struct { + env string + args []string + }{ + "--force": {"production", []string{"--force"}}, + "-n": {"production", []string{"-n"}}, + "staging": {"staging", nil}, + "development": {"development", nil}, + } { + code, output := runOnPTY(t, tc.env, "n\n", tc.args...) + if code != bonfire.ExitOK { + t.Fatalf("%s: exit %d\n%s", name, code, output.String()) + } + waitFor(t, output, "proceeded") + if strings.Contains(output.String(), "[y/N]") { + t.Errorf("%s: the question was asked:\n%s", name, output.String()) + } + } +} diff --git a/modules/surf/accesslog_test.go b/modules/surf/accesslog_test.go index fceaea1a..ea7a557d 100644 --- a/modules/surf/accesslog_test.go +++ b/modules/surf/accesslog_test.go @@ -4,14 +4,19 @@ import ( "bytes" "context" "encoding/json" + "fmt" "log/slog" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" "strings" "testing" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" ) @@ -413,3 +418,167 @@ func TestAccessLog(t *testing.T) { t.Fatalf("direct records = %v", recs) } } + +// sharePlugin serves token-bearing paths: GET /share/{token} and +// POST /invite/{token}. +type sharePlugin struct{} + +func (sharePlugin) ID() string { return "acme.share" } +func (sharePlugin) Requires() []string { return nil } +func (sharePlugin) Register(*backpack.App) error { return nil } +func (sharePlugin) Boot(*backpack.App) error { return nil } +func (sharePlugin) Routes(r pact.Router) error { + r.Get("/share/{token}", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + r.Post("/invite/{token}", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + return nil +} + +// diaryAccessRouter assembles plugin with httpYAML and no published +// logger, so the request log goes to the diary component http, set up on a +// console buffer with environ. +func diaryAccessRouter(t *testing.T, httpYAML string, plugin party.Plugin, environ ...string) (http.Handler, *syncBuffer) { + t.Helper() + app := backpack.New(writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"+httpYAML)) + h, err := Assemble(app, []party.Plugin{plugin}) + if err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: append([]string{"SUMMER_LOG__DEFAULT=console"}, environ...)}) + if err != nil { + t.Fatal(err) + } + var buf syncBuffer + t.Cleanup(diary.Reset) + if err := diary.Setup(cfg, diary.Options{Stderr: &buf, Root: t.TempDir(), Environ: []string{}}); err != nil { + t.Fatal(err) + } + return h, &buf +} + +// TestAccessLogRequestIDCap: X-Request-Id is trimmed, cut to 128 bytes on +// a rune boundary and stripped of invalid UTF-8. +func TestAccessLogRequestIDCap(t *testing.T) { + h, buf := accessLogRouter(t, true) + cases := map[string]string{ + strings.Repeat("a", 300): strings.Repeat("a", errorPageMaxRequestID), + "\t req-7 \t": "req-7", + strings.Repeat("a", 127) + "é" + "tail": strings.Repeat("a", 127), + "bad\xffid": "badid", + } + for header, want := range cases { + buf.Reset() + req := httptest.NewRequest(http.MethodGet, "/blog/posts/1", nil) + req.Header["X-Request-Id"] = []string{header} + h.ServeHTTP(httptest.NewRecorder(), req) + recs := accessRecords(t, buf) + if len(recs) != 1 || recs[0]["request_id"] != want { + t.Errorf("X-Request-Id %q: records %v, want request_id %q", header, recs, want) + } + if id, _ := recs[0]["request_id"].(string); len(id) > errorPageMaxRequestID { + t.Errorf("request_id is %d bytes", len(id)) + } + } + buf.Reset() + req := httptest.NewRequest(http.MethodGet, "/blog/posts/1", nil) + req.Header.Set("X-Request-Id", " ") + h.ServeHTTP(httptest.NewRecorder(), req) + if recs := accessRecords(t, buf); len(recs) != 1 || recs[0]["request_id"] != nil { + t.Errorf("a blank X-Request-Id gives %v, want no request_id", recs) + } +} + +// TestAccessLogDefaultOn: without http.access_log, and with it true, every +// request is logged once. +func TestAccessLogDefaultOn(t *testing.T) { + for name, yaml := range map[string]string{"unset": "", "true": "access_log: true\n"} { + h, buf := diaryAccessRouter(t, yaml, blogPostsPlugin{}) + for range 3 { + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil)) + } + if n := strings.Count(buf.String(), `msg="http request"`); n != 3 { + t.Errorf("%s: %d request records, want 3:\n%s", name, n, buf.String()) + } + } +} + +// TestAccessLogDisabled: http.access_log false, also as the string an +// environment variable gives, turns the request log off. +func TestAccessLogDisabled(t *testing.T) { + h, buf := diaryAccessRouter(t, "access_log: false\n", blogPostsPlugin{}) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil)) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/nowhere", nil)) + if strings.Contains(buf.String(), "http request") { + t.Errorf("access_log false still logged:\n%s", buf.String()) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_HTTP__ACCESS_LOG=false"}}) + if err != nil { + t.Fatal(err) + } + if accessLogEnabled(backpack.New(cfg)) { + t.Error("SUMMER_HTTP__ACCESS_LOG=false does not turn the request log off") + } + if !accessLogEnabled(nil) || !accessLogEnabled(backpack.New(nil)) { + t.Error("the request log is off without a config") + } +} + +// TestAccessLogNeverLogsPath: token-bearing paths and queries never reach +// the request log, for a matched route, a 404 (trailing slash) and a 405 +// (wrong method), on the default diary console. +func TestAccessLogNeverLogsPath(t *testing.T) { + h, buf := diaryAccessRouter(t, "", sharePlugin{}) + cases := []struct { + method, target string + status int + pattern string + }{ + {http.MethodGet, "/share/tok-secret-200?sig=query-secret", http.StatusOK, `pattern="GET /share/{token}"`}, + {http.MethodGet, "/share/tok-secret-404/?sig=query-secret", http.StatusNotFound, `pattern=""`}, + {http.MethodDelete, "/share/tok-secret-405?sig=query-secret", http.StatusMethodNotAllowed, `pattern=""`}, + {http.MethodGet, "/invite/tok-secret-invite", http.StatusMethodNotAllowed, `pattern=""`}, + {http.MethodPost, "/invite/tok-secret-post", http.StatusNoContent, `pattern="POST /invite/{token}"`}, + } + for _, tc := range cases { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.target, nil)) + if rec.Code != tc.status { + t.Fatalf("%s %s = %d, want %d", tc.method, tc.target, rec.Code, tc.status) + } + } + got := buf.String() + lines := strings.Split(strings.TrimSpace(got), "\n") + if len(lines) != len(cases) { + t.Fatalf("want %d request records, got %d:\n%s", len(cases), len(lines), got) + } + for i, tc := range cases { + if !strings.Contains(lines[i], tc.pattern) || !strings.Contains(lines[i], fmt.Sprintf("status=%d", tc.status)) { + t.Errorf("record %d = %q, want %s status=%d", i, lines[i], tc.pattern, tc.status) + } + } + for _, leak := range []string{"tok-secret", "query-secret", "sig=", "/share/tok", "/invite/tok"} { + if strings.Contains(got, leak) { + t.Errorf("the request log carries %q:\n%s", leak, got) + } + } +} + +// TestAccessLogComponentHTTP: without a published logger the request log +// is the diary component http, so log.components.http: warn silences it. +func TestAccessLogComponentHTTP(t *testing.T) { + h, buf := diaryAccessRouter(t, "", blogPostsPlugin{}) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil)) + if !strings.Contains(buf.String(), "component=http") || !strings.Contains(buf.String(), "level=INFO") { + t.Errorf("request record lacks component=http:\n%s", buf.String()) + } + + quiet, qbuf := diaryAccessRouter(t, "", blogPostsPlugin{}, "SUMMER_LOG__COMPONENTS__HTTP=warn") + quiet.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil)) + if strings.Contains(qbuf.String(), "http request") { + t.Errorf("log.components.http warn still logged:\n%s", qbuf.String()) + } +} diff --git a/modules/surf/summary_test.go b/modules/surf/summary_test.go new file mode 100644 index 00000000..df10eb52 --- /dev/null +++ b/modules/surf/summary_test.go @@ -0,0 +1,352 @@ +package surf + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "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/diary" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/party" +) + +// summaryFixture is buildFacts over summaryApp with two plugins, three +// pending migrations and a worker. +func summaryFixture(t *testing.T) summaryFacts { + t.Helper() + return buildFacts(summaryApp(t), factInputs{ + addr: ":8096", + bin: "bin/acme", + plugins: []party.Plugin{summaryPlugin{id: "acme.user"}, summaryPlugin{id: "acme.blog", req: []string{"acme.user"}}}, + queues: []string{"default"}, + worker: true, + adminPrefix: "/backend", + mounted: true, + admins: adminsPresent, + pending: []lagoon.MigrationRef{ + {Plugin: "acme.blog", ID: "0001_posts"}, + {Plugin: "acme.blog", ID: "0002_tags"}, + {Plugin: "summercms.cabana", ID: "0001"}, + }, + version: bonfire.Version{App: "v1.4.0", Framework: "v0.4.0"}, + }) +} + +// runSummaryProbe prints the summary through a command run with args and +// returns its output. +func runSummaryProbe(t *testing.T, f summaryFacts, args ...string) string { + t.Helper() + probe := []bonfire.Command{{Name: "probe:summary", Run: func(_ context.Context, _ bonfire.Input, out bonfire.Output) error { + printSummary(out, f) + return nil + }}} + var buf bytes.Buffer + if err := bonfire.Call(context.Background(), probe, "probe:summary", args, &buf); err != nil { + t.Fatal(err) + } + return buf.String() +} + +// TestServeSummaryLines: the block at verbosity 0, the per-plugin lines at +// -v, nothing under -q, and never a DSN user or password; the database +// label for URL, keyword, socket, IPv6, empty and unparsable DSNs. +func TestServeSummaryLines(t *testing.T) { + f := summaryFixture(t) + plain := runSummaryProbe(t, f) + for _, want := range []string{ + "SummerCMS acme v1.4.0 (framework v0.4.0)\n", + " Environment staging\n", + " URL http://127.0.0.1:8096/\n", + " Admin http://127.0.0.1:8096/backend\n", + " Database db.internal:5433/acme_prod\n", + " Migrations 3 pending — run bin/acme migrate\n", + " Plugins 2 (acme.user, acme.blog)\n", + " Queue default (worker in serve)\n", + " Scheduler in serve\n", + } { + if !strings.Contains(plain, want) { + t.Errorf("verbosity 0 block lacks %q:\n%s", want, plain) + } + } + if strings.Contains(plain, "requires") || strings.Contains(plain, "sign in") { + t.Errorf("verbosity 0 block has per-plugin lines or an admin note with admins present:\n%s", plain) + } + + verbose := runSummaryProbe(t, f, "-v") + for _, want := range []string{ + " acme.user requires nothing; 0 pending migrations\n", + " acme.blog requires acme.user; 2 pending migrations\n", + } { + if !strings.Contains(verbose, want) { + t.Errorf("-v block lacks %q:\n%s", want, verbose) + } + } + if quiet := runSummaryProbe(t, f, "-q"); quiet != "" { + t.Errorf("-q printed the block:\n%s", quiet) + } + for _, out := range []string{plain, verbose} { + for _, secret := range []string{"summer_user", "pa55-secret", "postgres://"} { + if strings.Contains(out, secret) { + t.Fatalf("summary leaks %q:\n%s", secret, out) + } + } + } + + // The worker off, migrations up to date or unknown. + off := buildFacts(summaryApp(t), factInputs{addr: "127.0.0.1:80", bin: "bin/acme", version: bonfire.Version{App: "devel"}}) + if off.URL != "http://127.0.0.1/" || !strings.Contains(off.Queue, "queue.work_in_serve is false") || !strings.Contains(off.Scheduler, "run schedule:run") { + t.Errorf("worker off facts = %+v", off) + } + if m, warn := off.migrations(); m != "up to date" || warn { + t.Errorf("no pending migrations = %q, %v", m, warn) + } + unknown := buildFacts(summaryApp(t), factInputs{addr: ":1", pendingErr: errors.New("no history")}) + if m, warn := unknown.migrations(); !strings.HasPrefix(m, "unknown") || !warn || unknown.Pending != -1 { + t.Errorf("unreadable migrations = %q, %v, %d", m, warn, unknown.Pending) + } + + for dsn, want := range map[string]string{ + "postgres://u:secret@db.example:5432/app": "db.example:5432/app", + "host=db port=6543 dbname=acme user=u password=p": "db:6543/acme", + "host=/var/run/postgresql dbname=acme user=u": "socket /var/run/postgresql:5432/acme", + "postgres://u:p@[::1]:5432/acme": "[::1]:5432/acme", + "": "not configured", + "postgres://u:p@[::1:bad": "configured", + } { + if got := lagoon.DatabaseLabel(dsn); got != want { + t.Errorf("DatabaseLabel(%q) = %q, want %q", dsn, got, want) + } + } +} + +// TestServeRecords: off a terminal serve logs one "startup summary" record +// with every fact and one record whose message is exactly "listening on +// "; with HideOnTTY (used only when the block printed) a styled +// console skips both while the daily file keeps them. +func TestServeRecords(t *testing.T) { + f := summaryFixture(t) + var logs bytes.Buffer + logSummary(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), f) + var summary map[string]any + var messages []string + 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.Fatal(err) + } + messages = append(messages, rec["msg"].(string)) + if rec["msg"] == summaryMessage { + summary = rec + } + } + if strings.Join(messages, "|") != "startup summary|listening on :8096" { + t.Fatalf("records = %q, want the summary and the listening line (no admin note with admins present)", messages) + } + keys := []string{"app", "environment", "url", "admin", "database", "migrations_pending", "plugins", "plugin_ids", "queue", "scheduler", "mail", "cache", "realtime", "search"} + for _, k := range keys { + if _, ok := summary[k]; !ok { + t.Errorf("summary record lacks %s: %v", k, summary) + } + } + if summary["app"] != "SummerCMS acme v1.4.0 (framework v0.4.0)" || summary["url"] != "http://127.0.0.1:8096/" { + t.Errorf("summary record = %v", summary) + } + + // Admin notes follow, warnings at WARN. + logs.Reset() + noAdmins := f + noAdmins.Admin, noAdmins.AdminNotes = adminStatus(f.URL, "/backend", true, adminsNone) + logSummary(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), noAdmins) + if !strings.Contains(logs.String(), `"level":"WARN","msg":"no administrators yet`) { + t.Errorf("admin warning record missing:\n%s", logs.String()) + } + + // HideOnTTY: hidden on a styled console, kept in the daily file. + root := t.TempDir() + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_LOG__CHANNELS__DAILY__LEVEL=info"}}) + if err != nil { + t.Fatal(err) + } + var console syncBuffer + t.Cleanup(diary.Reset) + now := func() time.Time { return time.Date(2026, 10, 21, 12, 0, 0, 0, time.Local) } + if err := diary.Setup(cfg, diary.Options{Stderr: &console, Root: root, Color: bonfire.ColorAlways, Environ: []string{}, Now: now}); err != nil { + t.Fatal(err) + } + logSummary(diary.HideOnTTY(context.Background()), serveLog, f) + if strings.Contains(console.String(), summaryMessage) || strings.Contains(console.String(), "listening on") { + t.Errorf("styled console shows the hidden records:\n%s", console.String()) + } + logSummary(context.Background(), serveLog, f) + if !strings.Contains(console.String(), "listening on :8096") { + t.Errorf("styled console lacks the records logged without HideOnTTY:\n%s", console.String()) + } + if err := diary.Close(); err != nil { + t.Fatal(err) + } + daily, err := os.ReadFile(filepath.Join(root, "storage", "logs", "summer-2026-10-21.log")) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(daily), `"msg":"listening on :8096"`); n != 2 { + t.Errorf("daily file has %d listening records, want 2 (hidden and shown):\n%s", n, daily) + } +} + +// shutdownServer serves handler through a requestCounter on a loopback +// listener and returns the server, the counter and the base URL. +func shutdownServer(t *testing.T, handler http.Handler) (*http.Server, *requestCounter, chan error, string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + counter := newRequestCounter(handler) + srv := &http.Server{Handler: counter} + errCh := make(chan error, 1) + go func() { errCh <- srv.Serve(ln) }() + return srv, counter, errCh, "http://" + ln.Addr().String() +} + +// TestShutdownReportAborted: a request still running at the drain deadline +// is counted as aborted, the drain error is in the report and Err returns +// it, which makes serve exit 1. +func TestShutdownReportAborted(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + srv, counter, errCh, base := shutdownServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(started) + <-release + w.WriteHeader(http.StatusOK) + })) + defer close(release) + go func() { + if resp, err := http.Get(base + "/slow"); err == nil { + _ = resp.Body.Close() + } + }() + <-started + + report := shutdownReport{Draining: counter.inFlight.Load()} + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + report.Aborted = counter.inFlight.Load() + _ = srv.Close() + report.Errors = append(report.Errors, fmt.Errorf("surf: drain requests: %w", err)) + } + if err := <-errCh; err != http.ErrServerClosed { + t.Fatalf("Serve returned %v", err) + } + report.Served = counter.served.Load() + if report.Draining != 1 || report.Aborted != 1 || report.Drained() != 0 { + t.Fatalf("report = %+v, want 1 draining, 1 aborted, 0 drained", report) + } + if err := report.Err(); err == nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Err() = %v, want the drain deadline error", err) + } + var buf bytes.Buffer + bonfire.Details(bonfire.NewOutput(strings.NewReader(""), &buf, &buf), "Shutdown report", report.details()) + for _, want := range []string{"0 drained, 1 aborted", "surf: drain requests: context deadline exceeded"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("report lacks %q:\n%s", want, buf.String()) + } + } +} + +// TestServeGracefulNil: requests that finish within the deadline drain, +// the report counts them as served and drained, and Err is nil (exit 0). +func TestServeGracefulNil(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + srv, counter, errCh, base := shutdownServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/slow" { + close(started) + <-release + } + w.WriteHeader(http.StatusOK) + })) + for range 3 { + resp, err := http.Get(base + "/fast") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + } + slowDone := make(chan int, 1) + go func() { + resp, err := http.Get(base + "/slow") + if err != nil { + slowDone <- 0 + return + } + _ = resp.Body.Close() + slowDone <- resp.StatusCode + }() + <-started + + report := shutdownReport{Draining: counter.inFlight.Load()} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + shut := make(chan error, 1) + go func() { shut <- srv.Shutdown(ctx) }() + close(release) + if err := <-shut; err != nil { + t.Fatalf("Shutdown: %v", err) + } + if err := <-errCh; err != http.ErrServerClosed { + t.Fatalf("Serve returned %v", err) + } + if code := <-slowDone; code != http.StatusOK { + t.Fatalf("the in-flight request got %d, want 200", code) + } + report.Served = counter.served.Load() + if report.Draining != 1 || report.Aborted != 0 || report.Drained() != 1 || report.Served != 4 { + t.Fatalf("report = %+v, want 1 draining, 1 drained, 4 served", report) + } + if err := report.Err(); err != nil { + t.Fatalf("Err() = %v, want nil after a graceful stop", err) + } +} + +// TestRouteListJSON: route:list --json prints every route as a JSON array +// with method, pattern, plugin, middleware (never null) and raw, under -q +// too; the table and the admin line are left out. +func TestRouteListJSON(t *testing.T) { + app := backpack.New(writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n")) + cmd := RouteListCommand(app, []party.Plugin{blogPostsPlugin{}}) + for _, args := range [][]string{{"--json"}, {"--json", "-q"}} { + var buf bytes.Buffer + if err := bonfire.Call(context.Background(), []bonfire.Command{cmd}, "route:list", args, &buf); err != nil { + t.Fatal(err) + } + var routes []map[string]any + if err := json.Unmarshal(buf.Bytes(), &routes); err != nil { + t.Fatalf("%v: not a JSON array: %v\n%s", args, err, buf.String()) + } + if len(routes) != 1 { + t.Fatalf("%v: routes = %v", args, routes) + } + r := routes[0] + if r["method"] != "GET" || r["pattern"] != "/blog/posts/{id}" || r["plugin"] != "acme.blog" || r["raw"] != false { + t.Errorf("%v: route = %v", args, r) + } + if mw, ok := r["middleware"].([]any); !ok || mw == nil { + t.Errorf("%v: middleware is not an array: %v", args, r["middleware"]) + } + if strings.Contains(buf.String(), "admin:") { + t.Errorf("%v: JSON output carries the admin line:\n%s", args, buf.String()) + } + } +}