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