internal/control/control_test.go

v1.20.0
gitbay/internal/control/control_test.go history · blame · raw

256 lines · 9350 bytes

  1package control
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"io/fs"
 10	"slices"
 11	"strings"
 12	"testing"
 13
 14	"gitbay.org/gitbay/internal/protocol"
 15	"gitbay.org/gitbay/internal/store"
 16)
 17
 18// TestEveryCommandReachableFromBareSSH asserts that each registered command's
 19// path, rendered exactly as a user would type it after `ssh <host>`, resolves
 20// back to that command through the tokenizer and Lookup. This is the guard
 21// that keeps the forge CLI optional.
 22func TestEveryCommandReachableFromBareSSH(t *testing.T) {
 23	cmds := Commands()
 24	if len(cmds) == 0 {
 25		t.Fatal("no commands registered")
 26	}
 27	for _, cmd := range cmds {
 28		line := strings.Join(cmd.Path, " ")
 29		argv, err := protocol.Tokenize(line)
 30		if err != nil {
 31			t.Errorf("command %q not tokenizable: %v", line, err)
 32			continue
 33		}
 34		got, rest, ok := Lookup(argv)
 35		if !ok {
 36			t.Errorf("command %q not found by Lookup", line)
 37			continue
 38		}
 39		if strings.Join(got.Path, " ") != line || len(rest) != 0 {
 40			t.Errorf("Lookup(%q) resolved to %q with rest %v", line, strings.Join(got.Path, " "), rest)
 41		}
 42		if cmd.Run == nil {
 43			t.Errorf("command %q has no Run", line)
 44		}
 45		if cmd.Summary == "" {
 46			t.Errorf("command %q has no summary", line)
 47		}
 48	}
 49}
 50
 51func TestLookupLongestMatch(t *testing.T) {
 52	// "keys list" must not resolve to a hypothetical shorter prefix and
 53	// unknown commands must not match.
 54	if _, _, ok := Lookup([]string{"keys"}); ok {
 55		t.Error("bare \"keys\" resolved; group prefixes must not be runnable")
 56	}
 57	if _, _, ok := Lookup([]string{"nope"}); ok {
 58		t.Error("unknown command resolved")
 59	}
 60	cmd, rest, ok := Lookup([]string{"keys", "list", "--json"})
 61	if !ok || strings.Join(cmd.Path, " ") != "keys list" || len(rest) != 1 {
 62		t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok)
 63	}
 64}
 65
 66// TestBuildJobsIsAReadCommand pins the properties the surfaces depend on:
 67// the web build page and a read-scoped API token both need it over GET.
 68func TestBuildJobsIsAReadCommand(t *testing.T) {
 69	cmd, _, ok := Lookup([]string{"build", "jobs"})
 70	if !ok {
 71		t.Fatal("build jobs not registered")
 72	}
 73	if !cmd.ReadOnly {
 74		t.Error("build jobs must be ReadOnly; listing jobs changes nothing")
 75	}
 76	if cmd.SSHOnly {
 77		t.Error("build jobs must not be SSHOnly; the web and the app need it")
 78	}
 79}
 80
 81// A merge request's dedup key must not collide with a commit sha. It did:
 82// a bare "#N" in a commit message recorded (issue, sha) first, and the
 83// description's "Closes #N" then found the key taken and silently gave
 84// up. That is how krz/gitbay-ios#8 stayed open after its own MR merged.
 85func TestMRDedupKeyCannotCollideWithASHA(t *testing.T) {
 86	key := mrRefKey(24)
 87	if key == "51b6a14eab49ab08e890597653fcf02f8f38f3d6" || len(key) == 40 {
 88		t.Errorf("mrRefKey(24) = %q, which is shaped like a sha", key)
 89	}
 90	if key != "mr-24" {
 91		t.Errorf("mrRefKey(24) = %q, want \"mr-24\"", key)
 92	}
 93	if mrRefKey(24) == mrRefKey(25) {
 94		t.Error("different merge requests share a dedup key")
 95	}
 96}
 97
 98// TestEveryCommandDocumentsItsUsage is what makes `help <prefix>` and
 99// `gitbay <cmd> --help` worth typing: both render Usage, so a command that
100// omits it documents nothing. Usage opens with the command path so the
101// printed line can be typed as-is, and the summary must not carry the
102// argument syntax it used to.
103func TestEveryCommandDocumentsItsUsage(t *testing.T) {
104	for _, cmd := range Commands() {
105		path := strings.Join(cmd.Path, " ")
106		if cmd.Usage == "" {
107			t.Errorf("command %q has no Usage", path)
108			continue
109		}
110		if cmd.Usage != path && !strings.HasPrefix(cmd.Usage, path+" ") {
111			t.Errorf("command %q has Usage %q, which does not open with the command path", path, cmd.Usage)
112		}
113		if strings.Contains(cmd.Summary, ": "+path) {
114			t.Errorf("command %q still carries its usage in the summary: %q", path, cmd.Summary)
115		}
116	}
117}
118
119// TestHelpPrefixNarrowsAndShowsFlags covers the reason the command exists:
120// before this, reading one command's flags meant reading all of them.
121func TestHelpPrefixNarrowsAndShowsFlags(t *testing.T) {
122	var buf bytes.Buffer
123	c := &Ctx{Stdout: &buf, Stderr: io.Discard}
124	if code := runHelp(c, []string{"issue"}); code != protocol.ExitOK {
125		t.Fatalf("help issue exited %d", code)
126	}
127	out := buf.String()
128	for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
129		if strings.HasPrefix(line, "  ") {
130			continue // the indented usage line
131		}
132		if !strings.HasPrefix(line, "issue ") {
133			t.Errorf("help issue listed an unrelated command: %q", line)
134		}
135	}
136	if !strings.Contains(out, "--state open|closed|all") {
137		t.Error("help issue did not print issue list's flags")
138	}
139}
140
141func TestHelpUnknownPrefixIsNotFound(t *testing.T) {
142	var buf bytes.Buffer
143	c := &Ctx{Stdout: &buf, Stderr: io.Discard}
144	if code := runHelp(c, []string{"nope"}); code != protocol.ExitNotFound {
145		t.Errorf("help nope exited %d, want %d", code, protocol.ExitNotFound)
146	}
147}
148
149// TestHelpListsEveryCommandSorted pins the unfiltered listing: one row per
150// registered command, ordered so a noun's commands sit together.
151func TestHelpListsEveryCommandSorted(t *testing.T) {
152	var buf bytes.Buffer
153	c := &Ctx{Stdout: &buf, Stderr: io.Discard, JSON: true}
154	if code := runHelp(c, nil); code != protocol.ExitOK {
155		t.Fatalf("help exited %d", code)
156	}
157	var env struct {
158		Data []helpEntry `json:"data"`
159	}
160	if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
161		t.Fatalf("help --json: %v", err)
162	}
163	if len(env.Data) != len(Commands()) {
164		t.Errorf("help listed %d commands, registry has %d", len(env.Data), len(Commands()))
165	}
166	if !slices.IsSortedFunc(env.Data, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) {
167		t.Error("help output is not sorted by path")
168	}
169}
170
171// TestStdinCommandsReadStdin: a command whose usage says its input arrives
172// on stdin must set ReadsStdin, or Dispatch hands it an empty reader and
173// --file - silently stores nothing (#127).
174func TestStdinCommandsReadStdin(t *testing.T) {
175	for _, cmd := range Commands() {
176		u := cmd.Usage
177		wants := strings.Contains(u, "--file -") || strings.Contains(u, "< ") ||
178			strings.Contains(u, "stdin") || strings.Contains(u, "--key -")
179		if wants && !cmd.ReadsStdin {
180			t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u)
181		}
182	}
183}
184
185// TestAdminNounGatedInDispatch: every admin command is refused for a
186// non-admin by the dispatcher itself, before any handler runs.
187func TestAdminNounGatedInDispatch(t *testing.T) {
188	for _, cmd := range Commands() {
189		if cmd.Path[0] != "admin" {
190			continue
191		}
192		var out, errOut bytes.Buffer
193		c := &Ctx{User: store.User{Username: "nobody"}, Scope: "full", Stdout: &out, Stderr: &errOut}
194		if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied {
195			t.Errorf("%s: non-admin got exit %d, want %d", strings.Join(cmd.Path, " "), code, protocol.ExitDenied)
196		}
197	}
198}
199
200// TestRefusalsHonourJSON: a refusal from the dispatcher's own checks is a
201// JSON envelope when --json was given, like any other failure. The flag
202// used to be stripped after those checks, so a read-only token or a
203// pending account got plain text on stderr exactly when a script needed
204// to parse the error (#109).
205func TestRefusalsHonourJSON(t *testing.T) {
206	cases := []struct {
207		name string
208		ctx  Ctx
209		argv []string
210	}{
211		{"read-only token", Ctx{ReadOnly: true, Scope: "full", ViaAPI: true}, []string{"repo", "create", "a/b", "--json"}},
212		{"pending account", Ctx{User: store.User{Pending: true}, Scope: "full"}, []string{"repo", "list", "--json"}},
213		{"git-scoped key", Ctx{Scope: "git"}, []string{"whoami", "--json"}},
214		{"non-admin", Ctx{Scope: "full"}, []string{"admin", "stats", "--json"}},
215	}
216	for _, tc := range cases {
217		var out, errOut bytes.Buffer
218		c := tc.ctx
219		c.Stdout, c.Stderr = &out, &errOut
220		if code := Dispatch(&c, tc.argv); code != protocol.ExitDenied {
221			t.Errorf("%s: exit %d, want %d", tc.name, code, protocol.ExitDenied)
222		}
223		var env protocol.Envelope
224		if err := json.Unmarshal(out.Bytes(), &env); err != nil || env.Error == "" {
225			t.Errorf("%s: no JSON envelope on stdout: %q (stderr %q)", tc.name, out.String(), errOut.String())
226		}
227	}
228}
229
230// TestFailErrExitCodes: a store error is not-found or a failure, never
231// usage (#211); an input error is usage unless the I/O beneath it failed
232// (#107).
233func TestFailErrExitCodes(t *testing.T) {
234	ctx := func() *Ctx { return &Ctx{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} }
235	notFound := fmt.Errorf("looking up: %w", store.ErrNotFound)
236	refused := errors.New("the name is taken")
237	ioErr := &fs.PathError{Op: "open", Path: "/x", Err: fs.ErrPermission}
238	for _, tc := range []struct {
239		name string
240		fn   func(*Ctx, error) int
241		err  error
242		want int
243	}{
244		{"failErr not found", (*Ctx).failErr, store.ErrNotFound, protocol.ExitNotFound},
245		{"failErr wrapped not found", (*Ctx).failErr, notFound, protocol.ExitNotFound},
246		{"failErr refusal", (*Ctx).failErr, refused, protocol.ExitFailure},
247		{"failErr i/o", (*Ctx).failErr, ioErr, protocol.ExitFailure},
248		{"failInput not found", (*Ctx).failInput, notFound, protocol.ExitNotFound},
249		{"failInput caller's mistake", (*Ctx).failInput, errors.New("name must be lowercase"), protocol.ExitUsage},
250		{"failInput i/o", (*Ctx).failInput, ioErr, protocol.ExitFailure},
251	} {
252		if got := tc.fn(ctx(), tc.err); got != tc.want {
253			t.Errorf("%s: exit %d, want %d", tc.name, got, tc.want)
254		}
255	}
256}