package control import ( "bytes" "encoding/json" "errors" "fmt" "io" "io/fs" "slices" "strings" "testing" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) // TestEveryCommandReachableFromBareSSH asserts that each registered command's // path, rendered exactly as a user would type it after `ssh `, resolves // back to that command through the tokenizer and Lookup. This is the guard // that keeps the forge CLI optional. func TestEveryCommandReachableFromBareSSH(t *testing.T) { cmds := Commands() if len(cmds) == 0 { t.Fatal("no commands registered") } for _, cmd := range cmds { line := strings.Join(cmd.Path, " ") argv, err := protocol.Tokenize(line) if err != nil { t.Errorf("command %q not tokenizable: %v", line, err) continue } got, rest, ok := Lookup(argv) if !ok { t.Errorf("command %q not found by Lookup", line) continue } if strings.Join(got.Path, " ") != line || len(rest) != 0 { t.Errorf("Lookup(%q) resolved to %q with rest %v", line, strings.Join(got.Path, " "), rest) } if cmd.Run == nil { t.Errorf("command %q has no Run", line) } if cmd.Summary == "" { t.Errorf("command %q has no summary", line) } } } func TestLookupLongestMatch(t *testing.T) { // "keys list" must not resolve to a hypothetical shorter prefix and // unknown commands must not match. if _, _, ok := Lookup([]string{"keys"}); ok { t.Error("bare \"keys\" resolved; group prefixes must not be runnable") } if _, _, ok := Lookup([]string{"nope"}); ok { t.Error("unknown command resolved") } cmd, rest, ok := Lookup([]string{"keys", "list", "--json"}) if !ok || strings.Join(cmd.Path, " ") != "keys list" || len(rest) != 1 { t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok) } } // TestBuildJobsIsAReadCommand pins the properties the surfaces depend on: // the web build page and a read-scoped API token both need it over GET. func TestBuildJobsIsAReadCommand(t *testing.T) { cmd, _, ok := Lookup([]string{"build", "jobs"}) if !ok { t.Fatal("build jobs not registered") } if !cmd.ReadOnly { t.Error("build jobs must be ReadOnly; listing jobs changes nothing") } } // Nothing in the registry is reachable over SSH alone (#234). The flag // that held commands back is gone, so this pins the replacement rule: // every command runs on every surface, and what a caller may do is // decided by the account, the key's scope, and the token's scope. func TestNoCommandIsHeldBackFromTheWeb(t *testing.T) { for _, cmd := range Commands() { if cmd.Run == nil { t.Errorf("%s has no handler", joinPath(cmd.Path)) } } } // A merge request's dedup key must not collide with a commit sha. It did: // a bare "#N" in a commit message recorded (issue, sha) first, and the // description's "Closes #N" then found the key taken and silently gave // up. That is how krz/gitbay-ios#8 stayed open after its own MR merged. func TestMRDedupKeyCannotCollideWithASHA(t *testing.T) { key := mrRefKey(24) if key == "51b6a14eab49ab08e890597653fcf02f8f38f3d6" || len(key) == 40 { t.Errorf("mrRefKey(24) = %q, which is shaped like a sha", key) } if key != "mr-24" { t.Errorf("mrRefKey(24) = %q, want \"mr-24\"", key) } if mrRefKey(24) == mrRefKey(25) { t.Error("different merge requests share a dedup key") } } // TestEveryCommandDocumentsItsUsage is what makes `help ` and // `gitbay --help` worth typing: both render Usage, so a command that // omits it documents nothing. Usage opens with the command path so the // printed line can be typed as-is, and the summary must not carry the // argument syntax it used to. func TestEveryCommandDocumentsItsUsage(t *testing.T) { for _, cmd := range Commands() { path := strings.Join(cmd.Path, " ") if cmd.Usage == "" { t.Errorf("command %q has no Usage", path) continue } if cmd.Usage != path && !strings.HasPrefix(cmd.Usage, path+" ") { t.Errorf("command %q has Usage %q, which does not open with the command path", path, cmd.Usage) } if strings.Contains(cmd.Summary, ": "+path) { t.Errorf("command %q still carries its usage in the summary: %q", path, cmd.Summary) } } } // TestHelpPrefixNarrowsToTheNoun covers the reason the command exists: // before this, reading one command's flags meant reading all of them. A // noun prefix now lists its verbs under READ/WRITE; a verb's own flags // are on `help ` (TestHelpVerb, help_test.go). func TestHelpPrefixNarrowsToTheNoun(t *testing.T) { var buf bytes.Buffer c := &Ctx{Stdout: &buf, Stderr: io.Discard} if code := runHelp(c, []string{"issue"}); code != protocol.ExitOK { t.Fatalf("help issue exited %d", code) } out := buf.String() for _, want := range []string{"READ\n", " list", "WRITE\n", " create", "issue --help for flags.\n"} { if !strings.Contains(out, want) { t.Errorf("missing %q in:\n%s", want, out) } } } func TestHelpUnknownPrefixIsNotFound(t *testing.T) { var buf bytes.Buffer c := &Ctx{Stdout: &buf, Stderr: io.Discard} if code := runHelp(c, []string{"nope"}); code != protocol.ExitNotFound { t.Errorf("help nope exited %d, want %d", code, protocol.ExitNotFound) } } // TestHelpListsEveryCommandSorted pins the unfiltered listing: one row per // registered command, ordered so a noun's commands sit together. func TestHelpListsEveryCommandSorted(t *testing.T) { var buf bytes.Buffer c := &Ctx{Stdout: &buf, Stderr: io.Discard, JSON: true} if code := runHelp(c, nil); code != protocol.ExitOK { t.Fatalf("help exited %d", code) } var env struct { Data []helpEntry `json:"data"` } if err := json.Unmarshal(buf.Bytes(), &env); err != nil { t.Fatalf("help --json: %v", err) } if len(env.Data) != len(Commands()) { t.Errorf("help listed %d commands, registry has %d", len(env.Data), len(Commands())) } if !slices.IsSortedFunc(env.Data, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) { t.Error("help output is not sorted by path") } } // TestStdinCommandsReadStdin: a command whose usage says its input arrives // on stdin must set ReadsStdin, or Dispatch hands it an empty reader and // --file - silently stores nothing (#127). func TestStdinCommandsReadStdin(t *testing.T) { for _, cmd := range Commands() { u := cmd.Usage wants := strings.Contains(u, "--file -") || strings.Contains(u, "< ") || strings.Contains(u, "stdin") || strings.Contains(u, "--key -") if wants && !cmd.ReadsStdin { t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u) } } } // TestAdminNounGatedInDispatch: every admin command is refused for a // non-admin by the dispatcher itself, before any handler runs. func TestAdminNounGatedInDispatch(t *testing.T) { for _, cmd := range Commands() { if cmd.Path[0] != "admin" { continue } var out, errOut bytes.Buffer c := &Ctx{User: store.User{Username: "nobody"}, Scope: "full", Stdout: &out, Stderr: &errOut} if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied { t.Errorf("%s: non-admin got exit %d, want %d", strings.Join(cmd.Path, " "), code, protocol.ExitDenied) } } } // TestRefusalsHonourJSON: a refusal from the dispatcher's own checks is a // JSON envelope when --json was given, like any other failure. The flag // used to be stripped after those checks, so a read-only token or a // pending account got plain text on stderr exactly when a script needed // to parse the error (#109). func TestRefusalsHonourJSON(t *testing.T) { cases := []struct { name string ctx Ctx argv []string }{ {"read-only token", Ctx{ReadOnly: true, Scope: "full", ViaAPI: true}, []string{"repo", "create", "a/b", "--json"}}, {"pending account", Ctx{User: store.User{Pending: true}, Scope: "full"}, []string{"repo", "list", "--json"}}, {"git-scoped key", Ctx{Scope: "git"}, []string{"whoami", "--json"}}, {"non-admin", Ctx{Scope: "full"}, []string{"admin", "stats", "--json"}}, } for _, tc := range cases { var out, errOut bytes.Buffer c := tc.ctx c.Stdout, c.Stderr = &out, &errOut if code := Dispatch(&c, tc.argv); code != protocol.ExitDenied { t.Errorf("%s: exit %d, want %d", tc.name, code, protocol.ExitDenied) } var env protocol.Envelope if err := json.Unmarshal(out.Bytes(), &env); err != nil || env.Error == "" { t.Errorf("%s: no JSON envelope on stdout: %q (stderr %q)", tc.name, out.String(), errOut.String()) } } } // TestFailErrExitCodes: a store error is not-found or a failure, never // usage (#211); an input error is usage unless the I/O beneath it failed // (#107). func TestFailErrExitCodes(t *testing.T) { ctx := func() *Ctx { return &Ctx{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} } notFound := fmt.Errorf("looking up: %w", store.ErrNotFound) refused := errors.New("the name is taken") ioErr := &fs.PathError{Op: "open", Path: "/x", Err: fs.ErrPermission} for _, tc := range []struct { name string fn func(*Ctx, error) int err error want int }{ {"failErr not found", (*Ctx).failErr, store.ErrNotFound, protocol.ExitNotFound}, {"failErr wrapped not found", (*Ctx).failErr, notFound, protocol.ExitNotFound}, {"failErr refusal", (*Ctx).failErr, refused, protocol.ExitFailure}, {"failErr i/o", (*Ctx).failErr, ioErr, protocol.ExitFailure}, {"failInput not found", (*Ctx).failInput, notFound, protocol.ExitNotFound}, {"failInput caller's mistake", (*Ctx).failInput, errors.New("name must be lowercase"), protocol.ExitUsage}, {"failInput i/o", (*Ctx).failInput, ioErr, protocol.ExitFailure}, } { if got := tc.fn(ctx(), tc.err); got != tc.want { t.Errorf("%s: exit %d, want %d", tc.name, got, tc.want) } } } // TestPathArgument: --path= is read only as a leading argument, in // either order with --term=, and never over HTTP. func TestPathArgument(t *testing.T) { cases := []struct { name string viaAPI bool argv []string want string wantArgv []string }{ {"leading", false, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}}, {"after term", false, []string{"--term=80", "--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}}, {"before term", false, []string{"--path=auth keys remove", "--term=80", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}}, {"later", false, []string{"keys", "remove", "abc", "--path=x"}, "", []string{"abc", "--path=x"}}, {"over HTTP", true, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "", []string{"abc"}}, } for _, tc := range cases { c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard} Dispatch(c, tc.argv) if c.CLIPath != tc.want { t.Errorf("%s: CLIPath %q, want %q", tc.name, c.CLIPath, tc.want) } if !slices.Equal(c.Argv, tc.wantArgv) { t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv) } } } // TestArgumentRefusalsNameTheUsage: a missing positional argument is a // usage error that prints the registered usage, the shared reference // helpers included (#215). func TestArgumentRefusalsNameTheUsage(t *testing.T) { for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} { var out, errOut bytes.Buffer c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut} c.Cfg.Server.SiteURL = "https://forge.test" if code := Dispatch(c, argv); code != protocol.ExitUsage { t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String()) continue } want := "usage: ssh git@forge.test " + strings.Join(argv, " ") if !strings.Contains(errOut.String(), want) { t.Errorf("%v: got %q, want it to contain %q", argv, errOut.String(), want) } } } // TestFlagRefusalsNameTheUsage: a bad flag prints its usage the same way // a missing positional does, program and CLI path included (#267). func TestFlagRefusalsNameTheUsage(t *testing.T) { cases := []struct { name string argv []string want string }{ {"cli", []string{"--path=auth token create", "token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: gitbay auth token create --name "}, {"stock ssh", []string{"token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: ssh git@forge.test token create --name "}, {"cli, repo", []string{"--term=80", "issue", "list", "a/b", "--bogus"}, "usage: gitbay issue list []"}, } for _, tc := range cases { var out, errOut bytes.Buffer c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut} c.Cfg.Server.SiteURL = "https://forge.test" if code := Dispatch(c, tc.argv); code != protocol.ExitUsage { t.Errorf("%s: exit %d, want %d (%s)", tc.name, code, protocol.ExitUsage, errOut.String()) continue } if !strings.Contains(errOut.String(), tc.want) { t.Errorf("%s: got %q, want it to contain %q", tc.name, errOut.String(), tc.want) } } } // TestTermArgument: --term= is read only as the first argument, and // never over HTTP. func TestTermArgument(t *testing.T) { cases := []struct { name string viaAPI bool argv []string want Term wantArgv []string }{ {"leading", false, []string{"--term=80,color", "issue", "list", "a/b"}, Term{Cols: 80, Color: true}, []string{"a/b"}}, {"later", false, []string{"issue", "list", "a/b", "--term=x"}, Term{}, []string{"a/b", "--term=x"}}, {"over HTTP", true, []string{"--term=80,color", "issue", "list", "a/b"}, Term{}, []string{"a/b"}}, } for _, tc := range cases { c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard} Dispatch(c, tc.argv) if c.Term != tc.want { t.Errorf("%s: Term %+v, want %+v", tc.name, c.Term, tc.want) } if !slices.Equal(c.Argv, tc.wantArgv) { t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv) } } }