1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/config"
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17 "bnema.dev/ermite/internal/usecase"
18)
19
20func TestMirrorCLIArguments(t *testing.T) {
21 for _, args := range [][]string{
22 {"mirror", "sync"}, {"mirror", "sync", "repo", "--all"}, {"mirror", "sync", "a", "b"},
23 {"mirror", "add", "repo"}, {"mirror", "remove"}, {"mirror", "exclude"}, {"mirror", "include"},
24 {"mirror", "key", "show", "extra"}, {"mirror", "key", "rotate", "extra"}, {"mirror", "status", "a", "b"},
25 } {
26 t.Run(strings.Join(args, " "), func(t *testing.T) {
27 r := runCLI(t, nil, "", args...)
28 require.Error(t, r.err)
29 assert.NotContains(t, r.err.Error(), "ERMITE_URL", "arguments must be rejected before loading credentials")
30 })
31 }
32 r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
33 assert.Contains(t, r.stdout, "never force-pushes")
34 assert.Contains(t, r.stdout, "lifts a pause")
35}
36
37func TestMirrorCLIConfiguration(t *testing.T) {
38 h := newSecretsCLI(t)
39 run := func(args ...string) result { return h.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
40 run("config", "--enabled", "--url", "git@example.test:owner/{repo}.git", "--cron", "*/5 * * * *", "--all-branches").ok(t)
41 r := run("config", "show", "--json").ok(t)
42 g := decode[map[string]any](t, r.stdout)
43 assert.Equal(t, true, g["enabled"])
44 assert.Equal(t, true, g["all_branches"])
45 assert.Equal(t, "git@example.test:owner/{repo}.git", g["url_template"])
46 run("config", "--enabled=false", "--all-branches=false").ok(t)
47 g = decode[map[string]any](t, run("config", "show", "--json").ok(t).stdout)
48 assert.Equal(t, false, g["enabled"])
49 assert.Equal(t, false, g["all_branches"])
50 assert.Equal(t, "*/5 * * * *", g["cron"], "omitted fields are not cleared")
51 run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)
52 r = run("status", "proj", "--json").ok(t)
53 s := decode[map[string]any](t, r.stdout)
54 assert.Equal(t, "override", s["source"])
55 assert.Equal(t, false, s["all_branches"])
56 assert.Equal(t, "ok", s["state"])
57 assert.Equal(t, "proj", s["repo"])
58 assert.Contains(t, run("status").ok(t).stdout, "FAILURES")
59 assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)
60 run("remove", "proj").ok(t)
61 run("exclude", "proj").ok(t)
62 s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)
63 assert.Equal(t, "excluded", s["source"])
64 run("include", "proj").ok(t)
65 first := run("key", "show").ok(t)
66 assert.Empty(t, first.stderr)
67 firstFingerprint := decode[map[string]any](t, run("key", "show", "--json").ok(t).stdout)["fingerprint"].(string)
68 rotated := run("key", "rotate").ok(t)
69 assert.Contains(t, rotated.stderr, "replaced key "+firstFingerprint)
70 assert.NotEqual(t, first.stdout, rotated.stdout)
71 assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")
72 assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
73 assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))
74}
75
76func TestMirrorCLISyncHelpDocumentsBackgroundSync(t *testing.T) {
77 r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
78 assert.Contains(t, r.stdout, "queued")
79 assert.Contains(t, r.stdout, "ermite mirror status")
80}
81
82type blockingPusher struct{ entered, release chan struct{} }
83
84func (p blockingPusher) Push(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
85 p.entered <- struct{}{}
86 select {
87 case <-p.release:
88 case <-ctx.Done():
89 }
90 return ports.MirrorPushResult{}, nil
91}
92
93// A push that outlasts the server's wait bound answers "queued"; the CLI says it continues.
94func TestMirrorCLISyncReportsBackgroundSync(t *testing.T) {
95 p := blockingPusher{entered: make(chan struct{}, 4), release: make(chan struct{})}
96 h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
97 o.mirrors = func(c *usecase.MirrorsConfig) {
98 c.ValidateURL = func(string) error { return nil }
99 c.Pusher = p
100 c.SyncWait = 50 * time.Millisecond
101 }
102 })
103 h.createRepo("proj", domain.VisibilityPrivate)
104 c := &cliHarness{harness: h, url: h.srv.URL}
105 wt := h.workTree("main", "README.md")
106 h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
107 c.cli(t, adminToken, "mirror", "add", "proj", "file:///nowhere", "--cron", "*/5 * * * *").ok(t)
108 for _, args := range [][]string{{"sync", "proj", "--force"}, {"sync", "--all", "--force"}} {
109 r := c.cli(t, adminToken, append([]string{"mirror"}, args...)...).ok(t)
110 assert.Contains(t, r.stdout, "proj")
111 assert.Contains(t, r.stdout, "true", "QUEUED column")
112 assert.Contains(t, r.stderr, "continue in the background")
113 assert.Contains(t, r.stderr, "ermite mirror status")
114 }
115 close(p.release)
116 h.forge.Mirrors.Wait(h.ctx)
117}