332 lines
13 KiB
Go
332 lines
13 KiB
Go
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 <rel>; bin/<name> <args>" 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWithExitCode (CR-02): Execute returns the code an error carries
|
|
// through WithExitCode, prints no block when it is also Reported, maps a
|
|
// code outside 1..255 to ExitFailure, and errors.Is and errors.As still see
|
|
// the wrapped error.
|
|
func TestWithExitCode(t *testing.T) {
|
|
plainEnv(t)
|
|
base := errors.New("child failed")
|
|
command := func(err error) []Command {
|
|
return []Command{{Name: "acme:run", Description: "Run", Run: func(context.Context, Input, Output) error { return err }}}
|
|
}
|
|
_, errOut, code := runGolden(t, command(Reported(WithExitCode(base, 2))), "acme:run")
|
|
if code != 2 || errOut != "" {
|
|
t.Errorf("Reported(WithExitCode(err, 2)): exit %d, stderr %q; want 2 and no block", code, errOut)
|
|
}
|
|
_, errOut, code = runGolden(t, command(WithExitCode(base, 3)), "acme:run")
|
|
if code != 3 || !strings.Contains(errOut, "ERROR") || !strings.Contains(errOut, "child failed") {
|
|
t.Errorf("WithExitCode(err, 3): exit %d, stderr %q; want 3 and the block", code, errOut)
|
|
}
|
|
for _, bad := range []int{0, -1, 256} {
|
|
if _, _, code := runGolden(t, command(Reported(WithExitCode(base, bad))), "acme:run"); code != ExitFailure {
|
|
t.Errorf("WithExitCode(err, %d): exit %d, want %d", bad, code, ExitFailure)
|
|
}
|
|
}
|
|
if WithExitCode(nil, 2) != nil {
|
|
t.Error("WithExitCode(nil, 2) is not nil")
|
|
}
|
|
wrapped := fmt.Errorf("outer: %w", WithExitCode(&HintError{Err: base, Try: "x"}, 4))
|
|
var hint *HintError
|
|
if !errors.Is(wrapped, base) || !errors.As(wrapped, &hint) || wrapped.Error() != "outer: child failed" {
|
|
t.Errorf("WithExitCode hides the wrapped error: %v", wrapped)
|
|
}
|
|
if code := exitCode(wrapped); code != 4 {
|
|
t.Errorf("exitCode through a wrapper = %d, want 4", code)
|
|
}
|
|
}
|
|
|
|
// TestWrongDirectoryMasksSecrets (WR-08, WR-11): the wrong-directory Try
|
|
// line replaces the value of every flag named like a password, secret,
|
|
// token or key with ***, in both the "--flag value" and "--flag=value"
|
|
// forms and whatever the value's first character, and keeps every other
|
|
// argument; only a following "--" is not taken as the value.
|
|
func TestWrongDirectoryMasksSecrets(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)
|
|
}
|
|
sub := filepath.Join(root, "bin")
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fail := errors.New("lagoon: database.dsn is empty")
|
|
cases := []struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
{[]string{"admin:create", "--email", "a@b.c", "--password", "hunter2"}, "cd ..; {bin} admin:create --email a@b.c --password ***"},
|
|
{[]string{"admin:create", "--email", "a@b.c", "--password=hunter2"}, "cd ..; {bin} admin:create --email a@b.c --password=***"},
|
|
{[]string{"sync", "--client-secret", "s3cr3t", "--verbose"}, "cd ..; {bin} sync --client-secret *** --verbose"},
|
|
{[]string{"sync", "--api-token=t0k3n"}, "cd ..; {bin} sync --api-token=***"},
|
|
{[]string{"sync", "--key", "k3y", "acme blog"}, "cd ..; {bin} sync --key *** 'acme blog'"},
|
|
{[]string{"sync", "--PASSWD", "hunter2"}, "cd ..; {bin} sync --PASSWD ***"},
|
|
// pflag takes the next argument as the value of a non-boolean
|
|
// flag even when it starts with "-": "--force" is the token here.
|
|
{[]string{"sync", "--token", "--force"}, "cd ..; {bin} sync --token ***"},
|
|
{[]string{"admin:create", "--email", "a@b.c", "--password", "-Xk9!q"}, "cd ..; {bin} admin:create --email a@b.c --password ***"},
|
|
{[]string{"sync", "--client-secret", "-s3cr3t", "--verbose"}, "cd ..; {bin} sync --client-secret *** --verbose"},
|
|
{[]string{"sync", "--token", "--", "x"}, "cd ..; {bin} sync --token -- x"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := wrongDirectory("acme", fail, tc.args, sub)
|
|
var hint *HintError
|
|
if !errors.As(got, &hint) {
|
|
t.Fatalf("%q: no hint: %v", tc.args, got)
|
|
}
|
|
if hint.Try != tc.want {
|
|
t.Errorf("%q: Try = %q, want %q", tc.args, hint.Try, tc.want)
|
|
}
|
|
for _, secret := range []string{"hunter2", "s3cr3t", "t0k3n", "k3y", "-Xk9", "-s3cr3t"} {
|
|
if strings.Contains(hint.Try, secret) {
|
|
t.Errorf("%q: Try line repeats %q: %q", tc.args, secret, hint.Try)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// injected are terminal control sequences carried by untrusted text: an
|
|
// OSC window title and a CSI screen clear.
|
|
var injected = []string{"\x1b]0;pwned\x07", "\x1b[2J", "\x07"}
|
|
|
|
// carriesInjected reports which injected sequence s still holds.
|
|
func carriesInjected(s string) string {
|
|
for _, bad := range injected {
|
|
if strings.Contains(s, bad) {
|
|
return bad
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// TestRenderErrorStripsControl (WR-05): with color on, control sequences
|
|
// in the message, explanation, Try lines and the -v chain never reach the
|
|
// terminal; the visible text stays.
|
|
func TestRenderErrorStripsControl(t *testing.T) {
|
|
plainEnv(t)
|
|
cause := errors.New("cause \x1b]0;pwned\x07 from the database\x1b[2J")
|
|
err := &HintError{
|
|
Err: fmt.Errorf("sync failed \x1b]0;pwned\x07 here: %w", cause),
|
|
Explanation: "the remote said \x1b[2J no",
|
|
Try: "{bin} sync --retry \x1b]0;pwned\x07",
|
|
}
|
|
var buf bytes.Buffer
|
|
RenderError(&buf, "acme", err, Globals{Color: ColorAlways, Verbosity: 1})
|
|
out := buf.String()
|
|
if bad := carriesInjected(out); bad != "" {
|
|
t.Errorf("error block carries %q:\n%q", bad, out)
|
|
}
|
|
if !strings.Contains(out, "\x1b[") {
|
|
t.Errorf("color is off, the test does not exercise styled output:\n%q", out)
|
|
}
|
|
for _, want := range []string{"sync failed", "the remote said", "sync --retry", "from the database"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("error block lacks %q:\n%q", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSanitize (WR-05): tab stays, CR and LF become spaces, every other
|
|
// control character (ESC, BEL, C0, DEL) is removed; clean text is returned
|
|
// unchanged. The rules are those of diary's console channel.
|
|
func TestSanitize(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"": "",
|
|
"plain text ✓": "plain text ✓",
|
|
"a\tb": "a\tb",
|
|
"line one\nline two\rthree": "line one line two three",
|
|
"evil \x1b[31mred\x1b[0m\a bell": "evil [31mred[0m bell",
|
|
"a\x1b]0;title\x07b": "a]0;titleb",
|
|
"nul\x00 and del\x7f and \x01": "nul and del and ",
|
|
"boom\x1b[2Jclear": "boom[2Jclear",
|
|
} {
|
|
if got := Sanitize(in); got != want {
|
|
t.Errorf("Sanitize(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|