Commit 0e460978da

0e460978dad647a35a95c61058d4a10b3103a446

parent: 9bcf573200

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-28 09:13 UTC

cmd/gitbay: test every quoted web command against the registry

Runs every command quoted in a web template (<code>, <pre class="quickstart">,
and <pre class="message">) through the CLI's command tree or the
control registry, so a renamed command fails CI instead of shipping a
dead instruction.

Closes #263

Layout: unified · split

CHANGELOG.org +4
@@ -113,6 +113,10 @@ for the eighteen commands whose CLI path differs from the registry's
113113- Wiki documentation fixes: API.org clarifies token commands work on the
114114 API, Parity.org documents batched review and web watch/pin dispatch,
115115 Threat-Model.org documents the login-link URL exception (#261).
116- The account settings page quotes the CLI and SSH command forms that
117 actually resolve; a test runs every command a web page quotes against
118 the CLI and control registries so a renamed command fails CI instead
119 of shipping a dead instruction (#263).
116120
117121* v1.36.0 — 2026-09-23
118122
cmd/gitbay/templatecmds_test.go added +200
@@ -0,0 +1,200 @@
1package main
2
3import (
4 "fmt"
5 "regexp"
6 "strings"
7 "testing"
8
9 "github.com/spf13/cobra"
10
11 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/web"
13)
14
15// quotedRe finds the shapes a command appears in on a page: inline in
16// <code>, one per line in a <pre class="quickstart"> quickstart block, or
17// one per line in a <pre class="message"> block (settings/login pages quote
18// commands there without wrapping each one in <code>). All need (?s) so a
19// multi-line <pre> is captured as one match.
20var quotedRe = regexp.MustCompile(`(?s)<code>(.*?)</code>|<pre class="quickstart"[^>]*>(.*?)</pre>|<pre class="message"[^>]*>(.*?)</pre>`)
21
22// commandArgv reads the literal words at the front of a quoted command
23// line — the part naming the command rather than its arguments — and
24// stops at the first flag, template action, literal ellipsis, or
25// placeholder (a bare `<name>` or its HTML-escaped form `&lt;name&gt;`),
26// since those mark the boundary between "what command" and "what
27// argument".
28func commandArgv(rest string) []string {
29 var argv []string
30 for _, tok := range strings.Fields(rest) {
31 if strings.HasPrefix(tok, "-") || strings.Contains(tok, "{{") || strings.Contains(tok, "...") ||
32 strings.HasPrefix(tok, "<") || strings.HasPrefix(tok, "&lt;") {
33 break
34 }
35 argv = append(argv, tok)
36 }
37 return argv
38}
39
40// checkGitbayCommand resolves argv (the words after "gitbay ") against the
41// CLI's own cobra tree, the way a person would type it. cobra's Find walks
42// one token at a time and, on the first token that names no child, simply
43// returns the last command it did match with err == nil and the
44// unmatched tokens as leftovers — so `admin user creat` resolves to the
45// "user" group instead of failing, unless the caller checks for leftover
46// args itself. A leaf command (no subcommands of its own) legitimately
47// takes further words as positional arguments (a repository name, for
48// instance), so leftover args on a leaf are not an error — only leftover
49// args on a command that still has subcommands are, since that means the
50// next word failed to name one of them.
51func checkGitbayCommand(root *cobra.Command, argv []string) error {
52 found, rest, err := root.Find(argv)
53 if err != nil {
54 return err
55 }
56 if found == root {
57 return fmt.Errorf("does not resolve")
58 }
59 if found.HasSubCommands() && len(rest) > 0 {
60 return fmt.Errorf("%q is not a subcommand of %q", rest[0], found.CommandPath())
61 }
62 return nil
63}
64
65// checkSSHCommand resolves argv against the control registry the way
66// "ssh git@host ..." dispatches it. control.Lookup matches the longest
67// registered path that is a prefix of argv and reports ok=true even when
68// trailing words remain unconsumed — e.g. argv ["whoami", "bogus"]
69// matches the registered "whoami" leaf and silently drops "bogus".
70// Every quoted ssh command reaching this function has already had its
71// flags, template actions, ellipses and placeholders trimmed by
72// commandArgv, so nothing legitimate is left dangling after a real
73// command's words: any remaining word is either a typo'd attempt at a
74// deeper command (checked against the registry below, for a precise
75// message) or bare stray text, and both are bugs in the quoted line.
76func checkSSHCommand(argv []string) error {
77 found, rest, ok := control.Lookup(argv)
78 if !ok {
79 return fmt.Errorf("%s is not in the control registry", strings.Join(argv, " "))
80 }
81 if len(rest) == 0 {
82 return nil
83 }
84 next := rest[0]
85 for _, cmd := range control.Commands() {
86 if len(cmd.Path) > len(found.Path) && cmd.Path[len(found.Path)] == next &&
87 strings.Join(cmd.Path[:len(found.Path)], " ") == strings.Join(found.Path, " ") {
88 return fmt.Errorf("%q is not a word %s takes further — did you mean %s?", next, strings.Join(found.Path, " "), strings.Join(cmd.Path, " "))
89 }
90 }
91 return fmt.Errorf("%q is not consumed by %s", next, strings.Join(found.Path, " "))
92}
93
94// TestTemplateQuotedCommandsResolve runs every command quoted in a web
95// template through the same registry the server uses, so a renamed
96// command fails CI instead of shipping a dead instruction (#263).
97//
98// A line starting "gitbay " is checked against the CLI's own command
99// tree with cobra's Find, since the CLI's grouping words (like "auth")
100// are not part of the server's argv. A line starting "ssh git@{{.Host}}
101// " is checked directly against control.Lookup, since that is exactly
102// the argv the server receives.
103func TestTemplateQuotedCommandsResolve(t *testing.T) {
104 root := newRoot()
105 for _, name := range web.Pages() {
106 src, err := web.TemplateSource(name)
107 if err != nil {
108 t.Fatalf("%s: %v", name, err)
109 }
110 for _, m := range quotedRe.FindAllStringSubmatch(src, -1) {
111 block := m[1] + m[2] + m[3]
112 for _, line := range strings.Split(block, "\n") {
113 if i := strings.Index(line, "#"); i >= 0 {
114 line = line[:i]
115 }
116 line = strings.TrimSpace(line)
117 switch {
118 case strings.HasPrefix(line, "gitbay "):
119 argv := commandArgv(strings.TrimPrefix(line, "gitbay "))
120 if len(argv) == 0 {
121 continue
122 }
123 if err := checkGitbayCommand(root, argv); err != nil {
124 t.Errorf("%s: %q: gitbay %s: %v", name, line, strings.Join(argv, " "), err)
125 }
126 case strings.HasPrefix(line, "ssh git@{{.Host}} "):
127 argv := commandArgv(strings.TrimPrefix(line, "ssh git@{{.Host}} "))
128 if len(argv) == 0 {
129 continue
130 }
131 if err := checkSSHCommand(argv); err != nil {
132 t.Errorf("%s: %q: %v", name, line, err)
133 }
134 }
135 }
136 }
137 }
138}
139
140func TestCommandArgv(t *testing.T) {
141 cases := []struct {
142 in string
143 want []string
144 }{
145 {"org delete {{$org}} --yes", []string{"org", "delete"}},
146 {"admin user create &lt;name&gt; --key - &lt; key.pub", []string{"admin", "user", "create"}},
147 {"admin ...", []string{"admin"}},
148 {"whoami", []string{"whoami"}},
149 {"web sessions list", []string{"web", "sessions", "list"}},
150 {"snippet create <file> < file", []string{"snippet", "create"}},
151 }
152 for _, c := range cases {
153 got := commandArgv(c.in)
154 if strings.Join(got, " ") != strings.Join(c.want, " ") {
155 t.Errorf("commandArgv(%q) = %v, want %v", c.in, got, c.want)
156 }
157 }
158}
159
160func TestCheckGitbayCommand(t *testing.T) {
161 root := newRoot()
162 cases := []struct {
163 name string
164 argv []string
165 wantErr bool
166 }{
167 {"real leaf", []string{"auth", "export"}, false},
168 {"real leaf two groups deep", []string{"admin", "user", "create"}, false},
169 {"leaf with a positional leftover is fine", []string{"org", "delete", "krz/gitbay"}, false},
170 {"typo'd subcommand under a group", []string{"admin", "user", "creat"}, true},
171 {"top-level typo", []string{"bogus"}, true},
172 {"old wrong top-level path", []string{"account", "export"}, true},
173 }
174 for _, c := range cases {
175 err := checkGitbayCommand(root, c.argv)
176 if (err != nil) != c.wantErr {
177 t.Errorf("%s: checkGitbayCommand(%v) error = %v, wantErr %v", c.name, c.argv, err, c.wantErr)
178 }
179 }
180}
181
182func TestCheckSSHCommand(t *testing.T) {
183 cases := []struct {
184 name string
185 argv []string
186 wantErr bool
187 }{
188 {"real leaf", []string{"whoami"}, false},
189 {"real leaf three deep", []string{"web", "sessions", "list"}, false},
190 {"stray trailing word", []string{"whoami", "bogus"}, true},
191 {"unregistered path entirely", []string{"auth", "whoami"}, true},
192 {"typo'd word past a real prefix", []string{"web", "sessions", "listing"}, true},
193 }
194 for _, c := range cases {
195 err := checkSSHCommand(c.argv)
196 if (err != nil) != c.wantErr {
197 t.Errorf("%s: checkSSHCommand(%v) error = %v, wantErr %v", c.name, c.argv, err, c.wantErr)
198 }
199 }
200}