package main import ( "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "os/signal" "path/filepath" "runtime" "slices" "strings" "sync" "syscall" "testing" "time" "git.golem15.com/golem15/summercms/modules/bonfire" ) // captureAppRunner replaces appRunner for the test and returns a pointer to // the argv of the last call. func captureAppRunner(t *testing.T) *[]string { t.Helper() got := new([]string) prev := appRunner appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error { *got = append([]string(nil), argv...) return nil } t.Cleanup(func() { appRunner = prev }) return got } func TestDelegatedServePassesFlags(t *testing.T) { for _, tc := range []struct { args []string want []string }{ {[]string{"serve", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}}, {[]string{"serve", "--help"}, []string{"serve", "--help"}}, {[]string{"serve", "-h"}, []string{"serve", "-h"}}, {[]string{"serve", "--addr=:8123"}, []string{"serve", "--addr=:8123"}}, {[]string{"serve", "--", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}}, } { got := captureAppRunner(t) if out, err := runSummer(t, tc.args...); err != nil { t.Fatalf("%q: %v\n%s", tc.args, err, out) } if !slices.Equal(*got, tc.want) { t.Fatalf("%q: argv = %q, want %q", tc.args, *got, tc.want) } } } func TestDelegateCommandsPassArgs(t *testing.T) { for _, args := range [][]string{ {"migrate:rollback", "--plugin", "acme.blog"}, {"queue:work", "--queue", "a", "--queue", "b"}, {"schedule:run", "--once"}, {"queue:clear", "emails"}, {"migrate"}, {"migrate:status", "--help"}, {"route:list"}, {"key:generate"}, {"admin:create", "--email", "you@example.com", "--superuser"}, {"admin:reset-password", "admin@example.com"}, } { got := captureAppRunner(t) if out, err := runSummer(t, args...); err != nil { t.Fatalf("%q: %v\n%s", args, err, out) } if !slices.Equal(*got, args) { t.Fatalf("%q: argv = %q, want %q", args, *got, args) } } } func TestDelegateCommandsHelp(t *testing.T) { hints := map[string][]string{ "migrate:rollback": {"--plugin"}, "queue:work": {"--queue"}, "schedule:run": {"--once"}, "serve": {"--addr"}, "admin:create": {"--email", "--superuser"}, "admin:token:create": {"--name", "--scope", "--expires"}, } cmds := delegateCommands() if len(cmds) != 15 { t.Fatalf("delegateCommands() has %d commands, want 15", len(cmds)) } got := captureAppRunner(t) for _, c := range cmds { out, err := runSummer(t, "help", c.Name) if err != nil { t.Fatalf("help %s: %v", c.Name, err) } for _, want := range append([]string{"passed to the app binary"}, hints[c.Name]...) { if !strings.Contains(out, want) { t.Fatalf("help %s missing %q:\n%s", c.Name, want, out) } } } if *got != nil { t.Fatalf("help ran the app binary with %q", *got) } out, err := runSummer(t, "--help") if err != nil { t.Fatal(err) } for _, c := range cmds { found := false for _, line := range strings.Split(out, "\n") { fields := strings.Fields(line) if len(fields) > 0 && fields[0] == c.Name && strings.Contains(line, "(flags pass to the app binary)") { found = true break } } if !found { t.Fatalf("summer --help does not mark %s:\n%s", c.Name, out) } } } // TestSignalHelperProcess is not a real test: the signal tests re-execute the // test binary with SUMMER_SIGNAL_HELPER=1 to get a child that shuts down // gracefully on SIGINT or SIGTERM, like the app binary's serve. func TestSignalHelperProcess(t *testing.T) { if os.Getenv("SUMMER_SIGNAL_HELPER") != "1" { return } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() fmt.Println("ready") select { case <-ctx.Done(): fmt.Println("graceful shutdown") os.Exit(0) case <-time.After(20 * time.Second): os.Exit(3) } } // syncBuffer is a bytes.Buffer safe for the child's writes and the test's // reads at the same time. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } func helperArgv() []string { return []string{"-test.run=^TestSignalHelperProcess$"} } func helperEnv(cmd *exec.Cmd) { cmd.Env = append(os.Environ(), "SUMMER_SIGNAL_HELPER=1") } func waitForOutput(t *testing.T, out *syncBuffer, want string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for !strings.Contains(out.String(), want) { if time.Now().After(deadline) { t.Fatalf("child never printed %q; output:\n%s", want, out.String()) } time.Sleep(10 * time.Millisecond) } } func TestRunChildForwardsSignals(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signal forwarding is POSIX-only") } for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} { t.Run(sig.String(), func(t *testing.T) { var out syncBuffer cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out, &out) helperEnv(cmd) sigs := make(chan os.Signal, 1) done := make(chan error, 1) go func() { done <- runChild(cmd, sigs) }() waitForOutput(t, &out, "ready") sigs <- sig select { case err := <-done: if err != nil { t.Fatalf("runChild: %v\n%s", err, out.String()) } case <-time.After(10 * time.Second): t.Fatal("runChild did not return after the signal") } if !strings.Contains(out.String(), "graceful shutdown") { t.Fatalf("child did not shut down gracefully:\n%s", out.String()) } }) } } func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("SIGTERM is POSIX-only") } ctx, cancel := context.WithCancel(context.Background()) defer cancel() var out syncBuffer cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out, &out) helperEnv(cmd) done := make(chan error, 1) go func() { done <- runChild(cmd, nil) }() waitForOutput(t, &out, "ready") cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled\n%s", err, out.String()) } case <-time.After(10 * time.Second): t.Fatal("child did not exit after cancel") } if !strings.Contains(out.String(), "graceful shutdown") { t.Fatalf("child was not stopped with SIGTERM:\n%s", out.String()) } } // TestDelegateExamples: every delegate has two examples rendered after // summer in its help, and its long help says that flags and the global // options pass to the app binary; global options before or after the // command word reach the app binary. func TestDelegateExamples(t *testing.T) { got := captureAppRunner(t) for _, c := range delegateCommands() { if len(c.Examples) != 2 { t.Errorf("%s has %d examples, want 2", c.Name, len(c.Examples)) } if !strings.Contains(c.Long, "global options (-v, -q, --ansi, --no-ansi, -n, --env)") || !strings.Contains(c.Long, "passed to the app binary unchanged") { t.Errorf("%s: long help does not say that global options pass through:\n%s", c.Name, c.Long) } out, err := runSummer(t, "help", c.Name) if err != nil { t.Fatalf("help %s: %v", c.Name, err) } for _, e := range c.Examples { if !strings.HasPrefix(e.Command, c.Name) || e.Description == "" { t.Errorf("%s: example %+v does not start with the command or lacks a description", c.Name, e) } if !strings.Contains(out, " summer "+e.Command+"\n") || !strings.Contains(out, "# "+e.Description) { t.Errorf("help %s lacks the example %q:\n%s", c.Name, e.Command, out) } } } if *got != nil { t.Fatalf("help ran the app binary with %q", *got) } if problems := bonfire.CheckDocs(delegateCommands()); len(problems) != 0 { t.Errorf("delegates lack documentation: %v", problems) } for _, tc := range []struct { args []string want []string }{ {[]string{"-v", "migrate", "--pretend"}, []string{"migrate", "-v", "--pretend"}}, {[]string{"migrate", "-qn"}, []string{"migrate", "-qn"}}, {[]string{"--env", "staging", "queue:work"}, []string{"queue:work", "--env", "staging"}}, {[]string{"--no-ansi", "serve", "--addr", ":8123"}, []string{"serve", "--no-ansi", "--addr", ":8123"}}, } { got := captureAppRunner(t) if out, err := runSummer(t, tc.args...); err != nil { t.Fatalf("%q: %v\n%s", tc.args, err, out) } if !slices.Equal(*got, tc.want) { t.Errorf("%q: argv = %q, want %q", tc.args, *got, tc.want) } } } // TestExitHelperProcess is not a real test: TestDelegateExitCodePassthrough // re-executes the test binary with SUMMER_EXIT_HELPER set to get a child // that prints an error on stderr and exits 2, or that is killed by SIGKILL. func TestExitHelperProcess(t *testing.T) { switch os.Getenv("SUMMER_EXIT_HELPER") { case "exit2": fmt.Fprintln(os.Stderr, "ERROR the app binary failed") os.Exit(2) case "sigkill": p, _ := os.FindProcess(os.Getpid()) _ = p.Signal(syscall.SIGKILL) time.Sleep(10 * time.Second) os.Exit(3) } } // exitHelperRunner replaces appRunner with one that runs the exit helper // as the app binary. func exitHelperRunner(t *testing.T, mode string) { t.Helper() prev := appRunner appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error { stdout, stderr := appStreams(out, bonfire.IsTerminal(out)) cmd := newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, stdout, stderr) cmd.Env = append(os.Environ(), "SUMMER_EXIT_HELPER="+mode) return runAppCmd(cmd, argv) } t.Cleanup(func() { appRunner = prev }) } // TestDelegateExitCodePassthrough (CR-02, WR-10): summer exits with the // app binary's exit code and prints no error block of its own; a binary // killed by a signal gets summer's error block, with secret flag values // masked, and exit 1. func TestDelegateExitCodePassthrough(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("SIGKILL is POSIX-only") } run := func(t *testing.T, mode string, args ...string) (string, string, int) { t.Helper() exitHelperRunner(t, mode) var out, errW syncBuffer root, err := bonfire.NewRootIO("summer", toolCommands(), strings.NewReader(""), &out, &errW) if err != nil { t.Fatal(err) } code := bonfire.Execute(context.Background(), root, args) return out.String(), errW.String(), code } t.Run("exit code", func(t *testing.T) { out, errOut, code := run(t, "exit2", "migrate", "--bogus") if code != 2 { t.Errorf("exit %d, want the binary's 2", code) } for _, s := range []string{out, errOut} { if strings.Contains(s, " ERROR ") || strings.Contains(s, "summer: migrate") { t.Errorf("summer printed an error block of its own:\nstdout %q\nstderr %q", out, errOut) break } } }) t.Run("killed by a signal", func(t *testing.T) { out, errOut, code := run(t, "sigkill", "migrate", "--bogus") if code != bonfire.ExitFailure { t.Errorf("exit %d, want 1", code) } if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "summer: migrate --bogus: signal: killed") { t.Errorf("want one error block naming the signal, got %d:\nstdout %q\nstderr %q", n, out, errOut) } }) t.Run("killed by a signal masks secrets", func(t *testing.T) { out, errOut, code := run(t, "sigkill", "migrate", "--password", "hunter2") if code != bonfire.ExitFailure { t.Errorf("exit %d, want 1", code) } if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "--password ***") { t.Errorf("want one error block with the password masked, got %d:\nstdout %q\nstderr %q", n, out, errOut) } if strings.Contains(out+errOut, "hunter2") { t.Errorf("the password reached the output:\nstdout %q\nstderr %q", out, errOut) } }) } // TestRunAppCmdMasksSecrets (WR-10): the error of an app binary killed by a // signal or failing to start names the command with the values of // secret-named flags masked, as the wrong-directory Try line does. func TestRunAppCmdMasksSecrets(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("SIGKILL is POSIX-only") } argv := []string{"admin:create", "--email", "a@b.c", "--password", "hunter2", "--api-token=t0k3n", "--client-secret", "-Xk9q"} check := func(t *testing.T, err error, want ...string) { t.Helper() if err == nil { t.Fatal("runAppCmd returned nil") } if errors.Is(err, bonfire.ErrReported) { t.Errorf("error is marked as reported: %v", err) } msg := err.Error() for _, w := range append(want, "--password ***", "--api-token=***", "--client-secret ***") { if !strings.Contains(msg, w) { t.Errorf("error lacks %q: %s", w, msg) } } for _, secret := range []string{"hunter2", "t0k3n", "-Xk9q"} { if strings.Contains(msg, secret) { t.Errorf("error repeats %q: %s", secret, msg) } } } t.Run("killed by a signal", func(t *testing.T) { cmd := newAppCmd(context.Background(), os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, io.Discard, io.Discard) cmd.Env = append(cmd.Env, "SUMMER_EXIT_HELPER=sigkill") check(t, runAppCmd(cmd, argv), "signal: killed") }) t.Run("start failure", func(t *testing.T) { cmd := newAppCmd(context.Background(), filepath.Join(t.TempDir(), "missing"), ".", argv, io.Discard, io.Discard) check(t, runAppCmd(cmd, argv)) }) } // TestDelegateStreams (CR-03): off a terminal the app binary's stdout goes // through summer's Output and its stderr to summer's stderr, never into // summer's stdout. func TestDelegateStreams(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the fake app binary is a shell script") } app := t.TempDir() if err := os.WriteFile(filepath.Join(app, "summer.yaml"), []byte("module: example.com/acme\nbinary: acme\n"), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(app, "bin"), 0o755); err != nil { t.Fatal(err) } script := "#!/bin/sh\necho data-line\necho log-line >&2\n" if err := os.WriteFile(filepath.Join(app, "bin", "acme"), []byte(script), 0o755); err != nil { t.Fatal(err) } t.Chdir(app) out, err := runSummer(t, "route:list", "--json") if err != nil { t.Fatalf("route:list: %v\n%s", err, out) } if !strings.Contains(out, "data-line") { t.Errorf("the binary's stdout did not reach summer's Output: %q", out) } if strings.Contains(out, "log-line") { t.Errorf("the binary's stderr landed in summer's stdout: %q", out) } } // TestAppStreams (CR-03): on a terminal the app binary gets summer's own // stdout and stderr; off a terminal its stdout goes through the Output and // its stderr to summer's stderr. func TestAppStreams(t *testing.T) { var buf bytes.Buffer out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) if bonfire.IsTerminal(out) { t.Fatal("a buffer console reports a terminal") } stdout, stderr := appStreams(out, true) if stdout != os.Stdout || stderr != os.Stderr { t.Errorf("terminal: streams %v, %v; want os.Stdout, os.Stderr", stdout, stderr) } stdout, stderr = appStreams(out, false) if stdout != io.Writer(out) || stderr != os.Stderr { t.Errorf("not a terminal: streams %v, %v; want the Output, os.Stderr", stdout, stderr) } }