internal/control/control_test.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/control/control_test.go history · blame · raw

195 lines · 6801 bytes

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