| @@ -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 | } |