internal/control/control_test.go
304 lines · 11315 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 if wants && !cmd.ReadsStdin {
185 t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u)
186 }
187 }
188}
189
190// TestAdminNounGatedInDispatch: every admin command is refused for a
191// non-admin by the dispatcher itself, before any handler runs.
192func TestAdminNounGatedInDispatch(t *testing.T) {
193 for _, cmd := range Commands() {
194 if cmd.Path[0] != "admin" {
195 continue
196 }
197 var out, errOut bytes.Buffer
198 c := &Ctx{User: store.User{Username: "nobody"}, Scope: "full", Stdout: &out, Stderr: &errOut}
199 if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied {
200 t.Errorf("%s: non-admin got exit %d, want %d", strings.Join(cmd.Path, " "), code, protocol.ExitDenied)
201 }
202 }
203}
204
205// TestRefusalsHonourJSON: a refusal from the dispatcher's own checks is a
206// JSON envelope when --json was given, like any other failure. The flag
207// used to be stripped after those checks, so a read-only token or a
208// pending account got plain text on stderr exactly when a script needed
209// to parse the error (#109).
210func TestRefusalsHonourJSON(t *testing.T) {
211 cases := []struct {
212 name string
213 ctx Ctx
214 argv []string
215 }{
216 {"read-only token", Ctx{ReadOnly: true, Scope: "full", ViaAPI: true}, []string{"repo", "create", "a/b", "--json"}},
217 {"pending account", Ctx{User: store.User{Pending: true}, Scope: "full"}, []string{"repo", "list", "--json"}},
218 {"git-scoped key", Ctx{Scope: "git"}, []string{"whoami", "--json"}},
219 {"non-admin", Ctx{Scope: "full"}, []string{"admin", "stats", "--json"}},
220 }
221 for _, tc := range cases {
222 var out, errOut bytes.Buffer
223 c := tc.ctx
224 c.Stdout, c.Stderr = &out, &errOut
225 if code := Dispatch(&c, tc.argv); code != protocol.ExitDenied {
226 t.Errorf("%s: exit %d, want %d", tc.name, code, protocol.ExitDenied)
227 }
228 var env protocol.Envelope
229 if err := json.Unmarshal(out.Bytes(), &env); err != nil || env.Error == "" {
230 t.Errorf("%s: no JSON envelope on stdout: %q (stderr %q)", tc.name, out.String(), errOut.String())
231 }
232 }
233}
234
235// TestFailErrExitCodes: a store error is not-found or a failure, never
236// usage (#211); an input error is usage unless the I/O beneath it failed
237// (#107).
238func TestFailErrExitCodes(t *testing.T) {
239 ctx := func() *Ctx { return &Ctx{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} }
240 notFound := fmt.Errorf("looking up: %w", store.ErrNotFound)
241 refused := errors.New("the name is taken")
242 ioErr := &fs.PathError{Op: "open", Path: "/x", Err: fs.ErrPermission}
243 for _, tc := range []struct {
244 name string
245 fn func(*Ctx, error) int
246 err error
247 want int
248 }{
249 {"failErr not found", (*Ctx).failErr, store.ErrNotFound, protocol.ExitNotFound},
250 {"failErr wrapped not found", (*Ctx).failErr, notFound, protocol.ExitNotFound},
251 {"failErr refusal", (*Ctx).failErr, refused, protocol.ExitFailure},
252 {"failErr i/o", (*Ctx).failErr, ioErr, protocol.ExitFailure},
253 {"failInput not found", (*Ctx).failInput, notFound, protocol.ExitNotFound},
254 {"failInput caller's mistake", (*Ctx).failInput, errors.New("name must be lowercase"), protocol.ExitUsage},
255 {"failInput i/o", (*Ctx).failInput, ioErr, protocol.ExitFailure},
256 } {
257 if got := tc.fn(ctx(), tc.err); got != tc.want {
258 t.Errorf("%s: exit %d, want %d", tc.name, got, tc.want)
259 }
260 }
261}
262
263// TestArgumentRefusalsNameTheUsage: a missing positional argument is a
264// usage error that prints the registered usage, the shared reference
265// helpers included (#215).
266func TestArgumentRefusalsNameTheUsage(t *testing.T) {
267 for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} {
268 var out, errOut bytes.Buffer
269 c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut}
270 if code := Dispatch(c, argv); code != protocol.ExitUsage {
271 t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String())
272 continue
273 }
274 if !strings.Contains(errOut.String(), "usage: "+strings.Join(argv, " ")) {
275 t.Errorf("%v: no usage line: %q", argv, errOut.String())
276 }
277 }
278}
279
280// TestTermArgument: --term= is read only as the first argument, and
281// never over HTTP.
282func TestTermArgument(t *testing.T) {
283 cases := []struct {
284 name string
285 viaAPI bool
286 argv []string
287 want Term
288 wantArgv []string
289 }{
290 {"leading", false, []string{"--term=80,color", "issue", "list", "a/b"}, Term{Cols: 80, Color: true}, []string{"a/b"}},
291 {"later", false, []string{"issue", "list", "a/b", "--term=x"}, Term{}, []string{"a/b", "--term=x"}},
292 {"over HTTP", true, []string{"--term=80,color", "issue", "list", "a/b"}, Term{}, []string{"a/b"}},
293 }
294 for _, tc := range cases {
295 c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
296 Dispatch(c, tc.argv)
297 if c.Term != tc.want {
298 t.Errorf("%s: Term %+v, want %+v", tc.name, c.Term, tc.want)
299 }
300 if !slices.Equal(c.Argv, tc.wantArgv) {
301 t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
302 }
303 }
304}