internal/control/control_test.go

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

361 lines · 13838 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}
 77
 78// Nothing in the registry is reachable over SSH alone (#234). The flag
 79// that held commands back is gone, so this pins the replacement rule:
 80// every command runs on every surface, and what a caller may do is
 81// decided by the account, the key's scope, and the token's scope.
 82func TestNoCommandIsHeldBackFromTheWeb(t *testing.T) {
 83	for _, cmd := range Commands() {
 84		if cmd.Run == nil {
 85			t.Errorf("%s has no handler", joinPath(cmd.Path))
 86		}
 87	}
 88}
 89
 90// A merge request's dedup key must not collide with a commit sha. It did:
 91// a bare "#N" in a commit message recorded (issue, sha) first, and the
 92// description's "Closes #N" then found the key taken and silently gave
 93// up. That is how krz/gitbay-ios#8 stayed open after its own MR merged.
 94func TestMRDedupKeyCannotCollideWithASHA(t *testing.T) {
 95	key := mrRefKey(24)
 96	if key == "51b6a14eab49ab08e890597653fcf02f8f38f3d6" || len(key) == 40 {
 97		t.Errorf("mrRefKey(24) = %q, which is shaped like a sha", key)
 98	}
 99	if key != "mr-24" {
100		t.Errorf("mrRefKey(24) = %q, want \"mr-24\"", key)
101	}
102	if mrRefKey(24) == mrRefKey(25) {
103		t.Error("different merge requests share a dedup key")
104	}
105}
106
107// TestEveryCommandDocumentsItsUsage is what makes `help <prefix>` and
108// `gitbay <cmd> --help` worth typing: both render Usage, so a command that
109// omits it documents nothing. Usage opens with the command path so the
110// printed line can be typed as-is, and the summary must not carry the
111// argument syntax it used to.
112func TestEveryCommandDocumentsItsUsage(t *testing.T) {
113	for _, cmd := range Commands() {
114		path := strings.Join(cmd.Path, " ")
115		if cmd.Usage == "" {
116			t.Errorf("command %q has no Usage", path)
117			continue
118		}
119		if cmd.Usage != path && !strings.HasPrefix(cmd.Usage, path+" ") {
120			t.Errorf("command %q has Usage %q, which does not open with the command path", path, cmd.Usage)
121		}
122		if strings.Contains(cmd.Summary, ": "+path) {
123			t.Errorf("command %q still carries its usage in the summary: %q", path, cmd.Summary)
124		}
125	}
126}
127
128// TestHelpPrefixNarrowsToTheNoun covers the reason the command exists:
129// before this, reading one command's flags meant reading all of them. A
130// noun prefix now lists its verbs under READ/WRITE; a verb's own flags
131// are on `help <noun> <verb>` (TestHelpVerb, help_test.go).
132func TestHelpPrefixNarrowsToTheNoun(t *testing.T) {
133	var buf bytes.Buffer
134	c := &Ctx{Stdout: &buf, Stderr: io.Discard}
135	if code := runHelp(c, []string{"issue"}); code != protocol.ExitOK {
136		t.Fatalf("help issue exited %d", code)
137	}
138	out := buf.String()
139	for _, want := range []string{"READ\n", "  list", "WRITE\n", "  create", "issue <verb> --help for flags.\n"} {
140		if !strings.Contains(out, want) {
141			t.Errorf("missing %q in:\n%s", want, out)
142		}
143	}
144}
145
146func TestHelpUnknownPrefixIsNotFound(t *testing.T) {
147	var buf bytes.Buffer
148	c := &Ctx{Stdout: &buf, Stderr: io.Discard}
149	if code := runHelp(c, []string{"nope"}); code != protocol.ExitNotFound {
150		t.Errorf("help nope exited %d, want %d", code, protocol.ExitNotFound)
151	}
152}
153
154// TestHelpListsEveryCommandSorted pins the unfiltered listing: one row per
155// registered command, ordered so a noun's commands sit together.
156func TestHelpListsEveryCommandSorted(t *testing.T) {
157	var buf bytes.Buffer
158	c := &Ctx{Stdout: &buf, Stderr: io.Discard, JSON: true}
159	if code := runHelp(c, nil); code != protocol.ExitOK {
160		t.Fatalf("help exited %d", code)
161	}
162	var env struct {
163		Data []helpEntry `json:"data"`
164	}
165	if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
166		t.Fatalf("help --json: %v", err)
167	}
168	if len(env.Data) != len(Commands()) {
169		t.Errorf("help listed %d commands, registry has %d", len(env.Data), len(Commands()))
170	}
171	if !slices.IsSortedFunc(env.Data, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) {
172		t.Error("help output is not sorted by path")
173	}
174}
175
176// TestStdinCommandsReadStdin: a command whose usage says its input arrives
177// on stdin must set ReadsStdin, or Dispatch hands it an empty reader and
178// --file - silently stores nothing (#127).
179func TestStdinCommandsReadStdin(t *testing.T) {
180	for _, cmd := range Commands() {
181		u := cmd.Usage
182		wants := strings.Contains(u, "--file -") || strings.Contains(u, "< ") ||
183			strings.Contains(u, "stdin") || strings.Contains(u, "--key -") ||
184			strings.Contains(u, "--secret -")
185		if wants && !cmd.ReadsStdin {
186			t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u)
187		}
188	}
189}
190
191// TestAdminNounGatedInDispatch: every admin command is refused for a
192// non-admin by the dispatcher itself, before any handler runs.
193func TestAdminNounGatedInDispatch(t *testing.T) {
194	for _, cmd := range Commands() {
195		if cmd.Path[0] != "admin" {
196			continue
197		}
198		var out, errOut bytes.Buffer
199		c := &Ctx{User: store.User{Username: "nobody"}, Scope: "full", Stdout: &out, Stderr: &errOut}
200		if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied {
201			t.Errorf("%s: non-admin got exit %d, want %d", strings.Join(cmd.Path, " "), code, protocol.ExitDenied)
202		}
203	}
204}
205
206// TestRefusalsHonourJSON: a refusal from the dispatcher's own checks is a
207// JSON envelope when --json was given, like any other failure. The flag
208// used to be stripped after those checks, so a read-only token or a
209// pending account got plain text on stderr exactly when a script needed
210// to parse the error (#109).
211func TestRefusalsHonourJSON(t *testing.T) {
212	cases := []struct {
213		name string
214		ctx  Ctx
215		argv []string
216	}{
217		{"read-only token", Ctx{ReadOnly: true, Scope: "full", ViaAPI: true}, []string{"repo", "create", "a/b", "--json"}},
218		{"pending account", Ctx{User: store.User{Pending: true}, Scope: "full"}, []string{"repo", "list", "--json"}},
219		{"git-scoped key", Ctx{Scope: "git"}, []string{"whoami", "--json"}},
220		{"non-admin", Ctx{Scope: "full"}, []string{"admin", "stats", "--json"}},
221	}
222	for _, tc := range cases {
223		var out, errOut bytes.Buffer
224		c := tc.ctx
225		c.Stdout, c.Stderr = &out, &errOut
226		if code := Dispatch(&c, tc.argv); code != protocol.ExitDenied {
227			t.Errorf("%s: exit %d, want %d", tc.name, code, protocol.ExitDenied)
228		}
229		var env protocol.Envelope
230		if err := json.Unmarshal(out.Bytes(), &env); err != nil || env.Error == "" {
231			t.Errorf("%s: no JSON envelope on stdout: %q (stderr %q)", tc.name, out.String(), errOut.String())
232		}
233	}
234}
235
236// TestFailErrExitCodes: a store error is not-found or a failure, never
237// usage (#211); an input error is usage unless the I/O beneath it failed
238// (#107).
239func TestFailErrExitCodes(t *testing.T) {
240	ctx := func() *Ctx { return &Ctx{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} }
241	notFound := fmt.Errorf("looking up: %w", store.ErrNotFound)
242	refused := errors.New("the name is taken")
243	ioErr := &fs.PathError{Op: "open", Path: "/x", Err: fs.ErrPermission}
244	for _, tc := range []struct {
245		name string
246		fn   func(*Ctx, error) int
247		err  error
248		want int
249	}{
250		{"failErr not found", (*Ctx).failErr, store.ErrNotFound, protocol.ExitNotFound},
251		{"failErr wrapped not found", (*Ctx).failErr, notFound, protocol.ExitNotFound},
252		{"failErr refusal", (*Ctx).failErr, refused, protocol.ExitFailure},
253		{"failErr i/o", (*Ctx).failErr, ioErr, protocol.ExitFailure},
254		{"failInput not found", (*Ctx).failInput, notFound, protocol.ExitNotFound},
255		{"failInput caller's mistake", (*Ctx).failInput, errors.New("name must be lowercase"), protocol.ExitUsage},
256		{"failInput i/o", (*Ctx).failInput, ioErr, protocol.ExitFailure},
257	} {
258		if got := tc.fn(ctx(), tc.err); got != tc.want {
259			t.Errorf("%s: exit %d, want %d", tc.name, got, tc.want)
260		}
261	}
262}
263
264// TestPathArgument: --path= is read only as a leading argument, in
265// either order with --term=, and never over HTTP.
266func TestPathArgument(t *testing.T) {
267	cases := []struct {
268		name     string
269		viaAPI   bool
270		argv     []string
271		want     string
272		wantArgv []string
273	}{
274		{"leading", false, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
275		{"after term", false, []string{"--term=80", "--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
276		{"before term", false, []string{"--path=auth keys remove", "--term=80", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
277		{"later", false, []string{"keys", "remove", "abc", "--path=x"}, "", []string{"abc", "--path=x"}},
278		{"over HTTP", true, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "", []string{"abc"}},
279	}
280	for _, tc := range cases {
281		c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
282		Dispatch(c, tc.argv)
283		if c.CLIPath != tc.want {
284			t.Errorf("%s: CLIPath %q, want %q", tc.name, c.CLIPath, tc.want)
285		}
286		if !slices.Equal(c.Argv, tc.wantArgv) {
287			t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
288		}
289	}
290}
291
292// TestArgumentRefusalsNameTheUsage: a missing positional argument is a
293// usage error that prints the registered usage, the shared reference
294// helpers included (#215).
295func TestArgumentRefusalsNameTheUsage(t *testing.T) {
296	for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} {
297		var out, errOut bytes.Buffer
298		c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut}
299		c.Cfg.Server.SiteURL = "https://forge.test"
300		if code := Dispatch(c, argv); code != protocol.ExitUsage {
301			t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String())
302			continue
303		}
304		want := "usage: ssh git@forge.test " + strings.Join(argv, " ")
305		if !strings.Contains(errOut.String(), want) {
306			t.Errorf("%v: got %q, want it to contain %q", argv, errOut.String(), want)
307		}
308	}
309}
310
311// TestFlagRefusalsNameTheUsage: a bad flag prints its usage the same way
312// a missing positional does, program and CLI path included (#267).
313func TestFlagRefusalsNameTheUsage(t *testing.T) {
314	cases := []struct {
315		name string
316		argv []string
317		want string
318	}{
319		{"cli", []string{"--path=auth token create", "token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: gitbay auth token create --name <n>"},
320		{"stock ssh", []string{"token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: ssh git@forge.test token create --name <n>"},
321		{"cli, repo", []string{"--term=80", "issue", "list", "a/b", "--bogus"}, "usage: gitbay issue list [<owner/name>]"},
322	}
323	for _, tc := range cases {
324		var out, errOut bytes.Buffer
325		c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut}
326		c.Cfg.Server.SiteURL = "https://forge.test"
327		if code := Dispatch(c, tc.argv); code != protocol.ExitUsage {
328			t.Errorf("%s: exit %d, want %d (%s)", tc.name, code, protocol.ExitUsage, errOut.String())
329			continue
330		}
331		if !strings.Contains(errOut.String(), tc.want) {
332			t.Errorf("%s: got %q, want it to contain %q", tc.name, errOut.String(), tc.want)
333		}
334	}
335}
336
337// TestTermArgument: --term= is read only as the first argument, and
338// never over HTTP.
339func TestTermArgument(t *testing.T) {
340	cases := []struct {
341		name     string
342		viaAPI   bool
343		argv     []string
344		want     Term
345		wantArgv []string
346	}{
347		{"leading", false, []string{"--term=80,color", "issue", "list", "a/b"}, Term{Cols: 80, Color: true}, []string{"a/b"}},
348		{"later", false, []string{"issue", "list", "a/b", "--term=x"}, Term{}, []string{"a/b", "--term=x"}},
349		{"over HTTP", true, []string{"--term=80,color", "issue", "list", "a/b"}, Term{}, []string{"a/b"}},
350	}
351	for _, tc := range cases {
352		c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
353		Dispatch(c, tc.argv)
354		if c.Term != tc.want {
355			t.Errorf("%s: Term %+v, want %+v", tc.name, c.Term, tc.want)
356		}
357		if !slices.Equal(c.Argv, tc.wantArgv) {
358			t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
359		}
360	}
361}