Commit 0e460978da
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
CHANGELOG.org +4
| @@ -113,6 +113,10 @@ for the eighteen commands whose CLI path differs from the registry's | |||
| 113 | - Wiki documentation fixes: API.org clarifies token commands work on the | 113 | - Wiki documentation fixes: API.org clarifies token commands work on the |
| 114 | API, Parity.org documents batched review and web watch/pin dispatch, | 114 | API, Parity.org documents batched review and web watch/pin dispatch, |
| 115 | Threat-Model.org documents the login-link URL exception (#261). | 115 | 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). | ||
| 116 | 120 | ||
| 117 | * v1.36.0 — 2026-09-23 | 121 | * v1.36.0 — 2026-09-23 |
| 118 | 122 | ||
cmd/gitbay/templatecmds_test.go added +200
| @@ -0,0 +1,200 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 20 | var 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 `<name>`), | ||
| 26 | // since those mark the boundary between "what command" and "what | ||
| 27 | // argument". | ||
| 28 | func 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, "<") { | ||
| 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. | ||
| 51 | func 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. | ||
| 76 | func 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. | ||
| 103 | func 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 | |||
| 140 | func 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 <name> --key - < 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 | |||
| 160 | func 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 | |||
| 182 | func 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 | } | ||