package bonfire import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "testing" ) // TestExitCodes: Execute returns 0 on success, 1 on a failure and 2 on an // unknown command, an unknown flag, a wrong argument count and an unknown // help topic; Fail returns 0 for nil and 1 otherwise. func TestExitCodes(t *testing.T) { plainEnv(t) commands := []Command{ {Name: "acme:ok", Description: "Succeed", Run: func(context.Context, Input, Output) error { return nil }}, {Name: "acme:fail", Description: "Fail", Run: func(context.Context, Input, Output) error { return errors.New("boom") }}, {Name: "acme:usage", Description: "Wrapped usage", Run: func(context.Context, Input, Output) error { return fmt.Errorf("acme:usage: %w", Usage(errors.New("pick one of a, b"))) }}, {Name: "acme:one", Description: "Exactly one", Args: []Arg{{Name: "x", Required: true}}, Run: func(context.Context, Input, Output) error { return nil }}, {Name: "acme:min", Description: "At least one", Args: []Arg{{Name: "x", Required: true}, {Name: "y"}}, Run: func(context.Context, Input, Output) error { return nil }}, } cases := []struct { args []string want int }{ {nil, ExitOK}, {[]string{"acme:ok"}, ExitOK}, {[]string{"-q", "acme:ok", "-n"}, ExitOK}, {[]string{"acme:one", "a"}, ExitOK}, {[]string{"acme:min", "a", "b"}, ExitOK}, {[]string{"acme:fail"}, ExitFailure}, {[]string{"acme:usage"}, ExitUsage}, {[]string{"acme:nope"}, ExitUsage}, {[]string{"-v", "acme:nope"}, ExitUsage}, {[]string{"acme:ok", "--bogus"}, ExitUsage}, {[]string{"--bogus", "acme:ok"}, ExitUsage}, {[]string{"acme:one"}, ExitUsage}, {[]string{"acme:one", "a", "b"}, ExitUsage}, {[]string{"acme:min"}, ExitUsage}, {[]string{"help", "acme:nope"}, ExitUsage}, {[]string{"list", "--format=yaml"}, ExitUsage}, } for _, tc := range cases { _, errOut, code := runGolden(t, commands, tc.args...) if code != tc.want { t.Errorf("%q: exit %d, want %d\n%s", tc.args, code, tc.want, errOut) } if tc.want != ExitOK && !strings.Contains(errOut, "ERROR") { t.Errorf("%q: no error block on stderr: %q", tc.args, errOut) } if tc.want == ExitUsage && !strings.Contains(errOut, "Try:") { t.Errorf("%q: usage error without a Try line:\n%s", tc.args, errOut) } } var buf bytes.Buffer if code := Fail(&buf, "acme", nil, Globals{}); code != ExitOK || buf.Len() != 0 { t.Errorf("Fail(nil) = %d, %q", code, buf.String()) } if code := Fail(&buf, "acme", errors.New("config/app.yaml: bad"), Globals{}); code != ExitFailure || !strings.Contains(buf.String(), "config/app.yaml: bad") { t.Errorf("Fail(err) = %d, %q", code, buf.String()) } } // TestReportedSilent: a Reported error, also wrapped further, prints no // error block and exits 1; it matches ErrReported and the original error. func TestReportedSilent(t *testing.T) { plainEnv(t) base := errors.New("3 problems") rep := Reported(base) if !errors.Is(rep, ErrReported) || !errors.Is(rep, base) || rep.Error() != "3 problems" { t.Fatalf("Reported(base) = %v", rep) } if !errors.Is(Reported(nil), ErrReported) { t.Fatal("Reported(nil) is not ErrReported") } var buf bytes.Buffer for _, err := range []error{rep, fmt.Errorf("docs:build: %w", rep), Reported(nil)} { RenderError(&buf, "acme", err, Globals{Verbosity: 3}) } if buf.Len() != 0 { t.Fatalf("RenderError printed a reported error: %q", buf.String()) } cmd := Command{Name: "docs:check", Description: "Check", Run: func(_ context.Context, _ Input, out Output) error { out.Println("page.md: broken link") return fmt.Errorf("docs:check: %w", Reported(errors.New("1 problem"))) }} out, errOut, code := runGolden(t, []Command{cmd}, "-v", "docs:check") if code != ExitFailure || errOut != "" || out != "page.md: broken link\n" { t.Fatalf("exit %d, stdout %q, stderr %q", code, out, errOut) } } // TestErrorScrubbed: credential shapes in the message, the explanation, // the Try line and the -v chain are scrubbed by sunscreen. func TestErrorScrubbed(t *testing.T) { const bearer = "abcdefghijklmnop0123456789.payload.sig" const skKey = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ012345" const apiKey = "live-key-0123456789" inner := fmt.Errorf("upstream answered 401 for x-api-key: %s", apiKey) err := WithHint(fmt.Errorf("calling the API with Bearer %s: %w", bearer, inner), "The configured key "+skKey+" was refused.", "{bin} acme:login --token Bearer "+bearer) for _, v := range []int{0, 1} { var buf bytes.Buffer RenderError(&buf, "acme", err, Globals{Verbosity: v, Color: ColorNever}) out := buf.String() for _, secret := range []string{bearer, skKey, apiKey} { if strings.Contains(out, secret) { t.Errorf("verbosity %d leaks %q:\n%s", v, secret, out) } } for _, want := range []string{"Bearer [REDACTED]", "sk-[REDACTED]", "bin/acme acme:login"} { if !strings.Contains(out, want) { t.Errorf("verbosity %d lacks %q:\n%s", v, want, out) } } if v == 1 && !strings.Contains(out, "x-api-key: [REDACTED]") { t.Errorf("the -v chain is not scrubbed:\n%s", out) } } } // TestWrongDirectoryHint: an application binary that fails in a directory // without config/ below an application root (summer.yaml) gets the // "cd ; bin/ " hint; summer, the root itself, usage // errors and directories outside an application get none. func TestWrongDirectoryHint(t *testing.T) { plainEnv(t) root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "summer.yaml"), []byte("binary: acme\n"), 0o644); err != nil { t.Fatal(err) } for _, dir := range []string{"config", "plugins/acme/blog"} { if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { t.Fatal(err) } } migrate := []Command{{Name: "migrate", Description: "Run the migrations", Run: func(context.Context, Input, Output) error { return errors.New("lagoon: database.dsn is empty") }}} run := func(t *testing.T, name, dir string, args ...string) string { t.Helper() t.Chdir(dir) var out, errW bytes.Buffer r, err := NewRootIO(name, migrate, strings.NewReader(""), &out, &errW, WithVersion(goldenVersion)) if err != nil { t.Fatal(err) } if code := Execute(context.Background(), r, args); code != ExitFailure && code != ExitUsage { t.Fatalf("exit %d", code) } return errW.String() } got := run(t, "acme", filepath.Join(root, "plugins"), "migrate") if !strings.Contains(got, wrongDirectoryExplanation) || !strings.Contains(got, "Try:\n cd ..; bin/acme migrate\n") { t.Errorf("plugins/: no hint:\n%s", got) } got = run(t, "acme", filepath.Join(root, "plugins", "acme", "blog"), "-v", "migrate", "--env=staging") if !strings.Contains(got, "cd ../../..; bin/acme -v migrate --env=staging") { t.Errorf("plugins/acme/blog: hint lacks the path or the args:\n%s", got) } for name, tc := range map[string]struct { bin, dir string args []string }{ "summer": {"summer", filepath.Join(root, "plugins"), []string{"migrate"}}, "app root": {"acme", root, []string{"migrate"}}, "usage error": {"acme", filepath.Join(root, "plugins"), []string{"migrate", "--bogus"}}, "no application": {"acme", t.TempDir(), []string{"migrate"}}, } { if got := run(t, tc.bin, tc.dir, tc.args...); strings.Contains(got, wrongDirectoryExplanation) { t.Errorf("%s: unexpected hint:\n%s", name, got) } } }