1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "errors"
7 "testing"
8
9 "connectrpc.com/connect"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 "google.golang.org/protobuf/proto"
13
14 "bnema.dev/ermite-api/gen/ermite"
15)
16
17var (
18 repoAlpha = &ermite.Repo{
19 Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21 Settings: &ermite.RepoSettings{
22 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
23 },
24 }
25 repoBeta = &ermite.Repo{
26 Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE,
27 DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"),
28 Settings: &ermite.RepoSettings{AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH}},
29 }
30)
31
32func envFor(url string) map[string]string {
33 return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}
34}
35
36func TestRepoList(t *testing.T) {
37 both := []*ermite.Repo{repoAlpha, repoBeta}
38 tests := []struct {
39 name string
40 repos []*ermite.Repo
41 args []string
42 wantStdout string
43 wantJSON bool // compare stdout as JSON rather than byte for byte
44 wantStderr string
45 }{
46 {
47 name: "table", repos: both, args: []string{"repo", "list"},
48 wantStdout: "" +
49 "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" +
50 "alpha public main 2026-03-04 05:06 The first one\n" +
51 "beta private trunk never \n",
52 },
53 {
54 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
55 wantStdout: `[
56 {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"allow_auto_merge":false,"issues":"off"},
57 {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}
58 ]`,
59 },
60 {
61 name: "empty, table", args: []string{"repo", "list"},
62 wantStderr: "ermite: no repositories\n",
63 },
64 {
65 name: "empty, json is [] not null", args: []string{"repo", "list", "--json"},
66 wantStdout: "[]\n",
67 },
68 }
69 for _, tt := range tests {
70 t.Run(tt.name, func(t *testing.T) {
71 f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
72 return &ermite.ListReposResponse{Repos: tt.repos}, nil
73 }}
74 res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t)
75 if tt.wantJSON {
76 assert.JSONEq(t, tt.wantStdout, res.stdout)
77 } else {
78 assert.Equal(t, tt.wantStdout, res.stdout)
79 }
80 assert.Equal(t, tt.wantStderr, res.stderr)
81 assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
82 assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
83 })
84 }
85}
86
87func TestRepoListFollowsPages(t *testing.T) {
88 f := &fakeAPI{listRepos: func(r *ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
89 if r.GetPageToken() == "" {
90 return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}, NextPageToken: "p2"}, nil
91 }
92 return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoBeta}}, nil
93 }}
94 res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
95 assert.Contains(t, res.stdout, `"alpha"`)
96 assert.Contains(t, res.stdout, `"beta"`)
97 _, reqs := f.snapshot()
98 require.Len(t, reqs, 2)
99 assert.Equal(t, "p2", reqs[1].(*ermite.ListReposRequest).GetPageToken())
100}
101
102func TestRepoShow(t *testing.T) {
103 f := &fakeAPI{getRepo: func(r *ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
104 return &ermite.GetRepoResponse{Repo: repoAlpha}, nil
105 }}
106 url := f.serve(t)
107 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
108 assert.Equal(t, ""+
109 "name alpha\n"+
110 "visibility public\n"+
111 "default branch main\n"+
112 "description The first one\n"+
113 "topics cli, go\n"+
114 "created 2026-01-02 03:04\n"+
115 "last push 2026-03-04 05:06\n"+
116 "merge modes fast-forward, squash\n"+
117 "allow auto-merge false\n"+
118 "issues off\n", res.stdout)
119 _, reqs := f.snapshot()
120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
121
122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
123 assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
124}
125
126func TestRepoShowNeedsOneArg(t *testing.T) {
127 res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "show")
128 require.Error(t, res.err)
129}
130
131func TestRepoCreateMapsFlags(t *testing.T) {
132 tests := []struct {
133 name string
134 args []string
135 want ermite.Visibility
136 desc string
137 topics []string
138 }{
139 {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, "", nil},
140 {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d", nil},
141 {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, "", nil},
142 {"--topics", []string{"x", "--topics", "go,cli"}, ermite.Visibility_VISIBILITY_PRIVATE, "", []string{"go", "cli"}},
143 }
144 for _, tt := range tests {
145 t.Run(tt.name, func(t *testing.T) {
146 f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
147 return &ermite.CreateRepoResponse{Repo: repoAlpha}, nil
148 }}
149 runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "create"}, tt.args...)...).ok(t)
150 _, reqs := f.snapshot()
151 require.Len(t, reqs, 1)
152 got := reqs[0].(*ermite.CreateRepoRequest)
153 assert.Equal(t, "x", got.GetName())
154 assert.Equal(t, tt.want, got.GetVisibility())
155 assert.Equal(t, tt.desc, got.GetDescription())
156 assert.Equal(t, tt.topics, got.GetTopics())
157 })
158 }
159}
160
161func TestRepoCreateRejectsBadFlagsWithoutCalling(t *testing.T) {
162 f := &fakeAPI{}
163 url := f.serve(t)
164 for _, args := range [][]string{
165 {"repo", "create", "x", "--visibility", "secret"},
166 } {
167 res := runCLI(t, envFor(url), "", args...)
168 require.Error(t, res.err, "%v", args)
169 }
170 calls, _ := f.snapshot()
171 assert.Empty(t, calls)
172}
173
174func TestRepoCreateJSON(t *testing.T) {
175 f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
177 }}
178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
179 assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
180}
181
182func TestRepoEditMask(t *testing.T) {
183 tests := []struct {
184 name string
185 args []string
186 paths []string
187 check func(*testing.T, *ermite.UpdateRepoRequest)
188 }{
189 {"description only", []string{"--description", "new"}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
190 assert.Equal(t, "new", r.GetDescription())
191 }},
192 {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
193 assert.Empty(t, r.GetDescription())
194 }},
195 {"topics", []string{"--topics", "Go,cli"}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
196 assert.Equal(t, []string{"Go", "cli"}, r.GetTopics())
197 }},
198 {"clear topics", []string{"--topics", ""}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
199 assert.Empty(t, r.GetTopics())
200 }},
201 {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
202 assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
203 }},
204 {"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
205 assert.Equal(t, "dev", r.GetDefaultBranch())
206 }},
207 {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
208 assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())
209 }},
210 {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
211 assert.True(t, r.GetSettings().GetAllowAutoMerge())
212 }},
213 {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
214 assert.False(t, r.GetSettings().GetAllowAutoMerge())
215 }},
216 {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
217 []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
218 }
219 for _, tt := range tests {
220 t.Run(tt.name, func(t *testing.T) {
221 f := &fakeAPI{updateRepo: func(r *ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
222 return &ermite.UpdateRepoResponse{Repo: repoAlpha}, nil
223 }}
224 runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "edit", "alpha"}, tt.args...)...).ok(t)
225 _, reqs := f.snapshot()
226 require.Len(t, reqs, 1)
227 got := reqs[0].(*ermite.UpdateRepoRequest)
228 assert.Equal(t, "alpha", got.GetRepo())
229 assert.ElementsMatch(t, tt.paths, got.GetUpdateMask().GetPaths())
230 tt.check(t, got)
231 })
232 }
233}
234
235func TestRepoEditRejects(t *testing.T) {
236 f := &fakeAPI{}
237 url := f.serve(t)
238 for _, tt := range []struct {
239 args []string
240 want string
241 }{
242 {[]string{"repo", "edit", "alpha"}, "nothing to change"},
243 {[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"},
244 {[]string{"repo", "edit", "alpha", "--default-branch", " "}, "must not be empty"},
245 {[]string{"repo", "edit", "alpha", "--merge-modes", "rebase"}, "invalid merge mode"},
246 {[]string{"repo", "edit", "alpha", "--merge-modes", ""}, "at least one mode"},
247 } {
248 res := runCLI(t, envFor(url), "", tt.args...)
249 require.Error(t, res.err)
250 assert.Contains(t, res.err.Error(), tt.want)
251 }
252 calls, _ := f.snapshot()
253 assert.Empty(t, calls)
254}
255
256func TestRepoDeleteConfirmation(t *testing.T) {
257 newFake := func() *fakeAPI {
258 return &fakeAPI{deleteRepo: func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error) {
259 return &ermite.DeleteRepoResponse{}, nil
260 }}
261 }
262 t.Run("typed name deletes", func(t *testing.T) {
263 f := newFake()
264 res := runCLI(t, envFor(f.serve(t)), "alpha\n", "repo", "delete", "alpha").ok(t)
265 assert.Equal(t, "deleted alpha\n", res.stdout)
266 assert.Contains(t, res.stderr, "Type its name to confirm")
267 calls, reqs := f.snapshot()
268 assert.Equal(t, []string{"DeleteRepo"}, calls)
269 assert.Equal(t, "alpha", reqs[0].(*ermite.DeleteRepoRequest).GetRepo())
270 })
271 t.Run("last line without newline is accepted", func(t *testing.T) {
272 f := newFake()
273 runCLI(t, envFor(f.serve(t)), "alpha", "repo", "delete", "alpha").ok(t)
274 calls, _ := f.snapshot()
275 assert.Equal(t, []string{"DeleteRepo"}, calls)
276 })
277 t.Run("wrong name aborts", func(t *testing.T) {
278 f := newFake()
279 res := runCLI(t, envFor(f.serve(t)), "beta\n", "repo", "delete", "alpha")
280 require.Error(t, res.err)
281 assert.Contains(t, res.err.Error(), "did not match")
282 calls, _ := f.snapshot()
283 assert.Empty(t, calls)
284 })
285 t.Run("empty stdin aborts", func(t *testing.T) {
286 f := newFake()
287 res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha")
288 require.Error(t, res.err)
289 assert.Contains(t, res.err.Error(), "--yes")
290 calls, _ := f.snapshot()
291 assert.Empty(t, calls)
292 })
293 t.Run("--yes skips the question", func(t *testing.T) {
294 f := newFake()
295 res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "--yes").ok(t)
296 assert.Equal(t, "deleted alpha\n", res.stdout)
297 assert.NotContains(t, res.stderr, "confirm")
298 })
299 t.Run("-y", func(t *testing.T) {
300 f := newFake()
301 runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "-y").ok(t)
302 })
303}
304
305func TestRepoErrorsAreFriendly(t *testing.T) {
306 f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
307 return nil, connect.NewError(connect.CodeNotFound, errors.New("repository \"nope\" not found"))
308 }}
309 res := runCLI(t, envFor(f.serve(t)), "", "repo", "show", "nope")
310 require.Error(t, res.err)
311 assert.Equal(t, "not found: repository \"nope\" not found", res.err.Error())
312}
313
314func TestUnauthenticatedHintsAtToken(t *testing.T) {
315 f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
316 return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
317 }}
318 res := runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "repo", "list")
319 require.Error(t, res.err)
320 assert.Contains(t, res.err.Error(), "ERMITE_TOKEN")
321}
322
323func TestMissingURL(t *testing.T) {
324 res := runCLI(t, nil, "", "repo", "list")
325 require.Error(t, res.err)
326 assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
327}
328
329func TestInvalidURL(t *testing.T) {
330 res := runCLI(t, map[string]string{"ERMITE_URL": "git.example.com"}, "", "repo", "list")
331 require.Error(t, res.err)
332 assert.Contains(t, res.err.Error(), "ERMITE_URL")
333 assert.Contains(t, res.err.Error(), "http")
334}
335
336func TestUnreachableServer(t *testing.T) {
337 res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "list")
338 require.Error(t, res.err)
339 assert.Contains(t, res.err.Error(), "cannot reach the server at http://127.0.0.1:1")
340 assert.NotContains(t, res.err.Error(), "\n")
341}
342
343func TestInsecureTokenWarningOnStderr(t *testing.T) {
344 // A non-loopback http URL: nothing listens there, the warning comes first.
345 res := runCLI(t, map[string]string{"ERMITE_URL": "http://ermite.invalid:1", "ERMITE_TOKEN": "ermite_tok"}, "", "repo", "list")
346 require.Error(t, res.err)
347 assert.Contains(t, res.stderr, "ermite: warning: ERMITE_TOKEN is sent over plain http to ermite.invalid")
348 assert.NotContains(t, res.stderr, "ermite_tok")
349}
350
351func TestLoopbackHTTPDoesNotWarn(t *testing.T) {
352 f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
353 return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
354 }}
355 res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
356 assert.Empty(t, res.stderr)
357}
358
359func TestTerminalControlCharactersAreNeutralised(t *testing.T) {
360 evil := &ermite.Repo{Name: "x", Description: "a\x1b[31mred\nline", Visibility: ermite.Visibility_VISIBILITY_PUBLIC}
361 f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
362 return &ermite.ListReposResponse{Repos: []*ermite.Repo{evil}}, nil
363 }}
364 res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
365 assert.NotContains(t, res.stdout, "\x1b")
366 assert.Equal(t, 2, len(splitLines(res.stdout)), "header and one row")
367}
368
369func splitLines(s string) []string {
370 var out []string
371 cur := ""
372 for _, r := range s {
373 if r == '\n' {
374 out = append(out, cur)
375 cur = ""
376 continue
377 }
378 cur += string(r)
379 }
380 if cur != "" {
381 out = append(out, cur)
382 }
383 return out
384}
385
386func TestRepoNameCompletion(t *testing.T) {
387 f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
388 return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
389 }}
390 vars := envFor(f.serve(t))
391 res := runCLI(t, vars, "", "__complete", "repo", "show", "a").ok(t)
392 assert.Contains(t, res.stdout, "alpha\tpublic, The first one")
393 assert.NotContains(t, res.stdout, "beta")
394
395 res = runCLI(t, vars, "", "__complete", "repo", "delete", "").ok(t)
396 assert.Contains(t, res.stdout, "alpha")
397 assert.Contains(t, res.stdout, "beta")
398
399 // A second argument gets no repository names.
400 res = runCLI(t, vars, "", "__complete", "repo", "show", "alpha", "").ok(t)
401 assert.NotContains(t, res.stdout, "beta")
402
403 res = runCLI(t, vars, "", "__complete", "watch", "").ok(t)
404 assert.Contains(t, res.stdout, "alpha")
405}
406
407func TestCompletionIsSilentOnErrors(t *testing.T) {
408 denied := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
409 return nil, connect.NewError(connect.CodeUnauthenticated, nil)
410 }}
411 for name, vars := range map[string]map[string]string{
412 "no config": nil,
413 "bad url": {"ERMITE_URL": "nope"},
414 "unreachable": envFor("http://ermite.invalid:1"),
415 "rejected": envFor(denied.serve(t)),
416 } {
417 t.Run(name, func(t *testing.T) {
418 res := runCLI(t, vars, "", "__complete", "repo", "show", "").ok(t)
419 assert.NotContains(t, res.stdout, "alpha")
420 assert.NotContains(t, res.stderr, "warning", "completion must not print warnings")
421 assert.Contains(t, res.stdout, ":4", "cobra's no-file-completion directive")
422 })
423 }
424}
425
426func TestShellCompletionScriptIsAvailable(t *testing.T) {
427 res := runCLI(t, nil, "", "completion", "bash").ok(t)
428 assert.Contains(t, res.stdout, "bash completion")
429}
430
431func TestToJSONRepoFromProto(t *testing.T) {
432 r := toJSONRepo(proto.Clone(repoBeta).(*ermite.Repo))
433 assert.Nil(t, r.PushedAt)
434 assert.Equal(t, "private", r.Visibility)
435 assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
436}
437
438func TestRepoEditAllowAutoMerge(t *testing.T) {
439 updated := &ermite.Repo{Name: "alpha", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, DefaultBranch: "main",
440 Settings: &ermite.RepoSettings{AllowAutoMerge: true}}
441 f := &fakeAPI{updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
442 return &ermite.UpdateRepoResponse{Repo: updated}, nil
443 }}
444 url := f.serve(t)
445
446 res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge").ok(t)
447 assert.Regexp(t, `(?m)^allow auto-merge\s+true$`, res.stdout)
448 _, reqs := f.snapshot()
449 got := reqs[0].(*ermite.UpdateRepoRequest)
450 assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
451 assert.True(t, got.GetSettings().GetAllowAutoMerge())
452
453 res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--json").ok(t)
454 assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
455 _, reqs = f.snapshot()
456 got = reqs[1].(*ermite.UpdateRepoRequest)
457 assert.ElementsMatch(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
458}