1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 "golang.org/x/crypto/ssh"
15
16 "bnema.dev/ermite/internal/adapters/gitcli"
17 "bnema.dev/ermite/internal/adapters/gitpush"
18 "bnema.dev/ermite/internal/config"
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/usecase"
21)
22
23// Pushes mark mirrors dirty; a due cron tick performs the asynchronous sync.
24// Advancing the injected clock exercises that contract without waiting five minutes.
25func TestMirrorCLIEndToEnd(t *testing.T) {
26 clk := &testClock{}
27 clk.set("2025-12-31 23:59:00")
28 transport := t.TempDir()
29 var dataDir string
30 h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
31 o.mirrors = func(c *usecase.MirrorsConfig) {
32 c.ValidateURL = func(string) error { return nil } // file:// remotes
33 c.Clock = clk
34 c.ScheduleTick = time.Hour
35 c.Pusher = gitpush.New(gitpush.Config{
36 Bin: requireGit(t), Path: os.Getenv("PATH"), Home: transport,
37 KnownHosts: filepath.Join(transport, "known_hosts"), TempDir: filepath.Join(transport, "keys"),
38 AllowedProtocols: "file", RepoDir: func(repo domain.RepoName) string { return gitcli.RepoDir(dataDir, repo) },
39 })
40 }
41 })
42 // The transport needs the harness data directory, which is known after newForge.
43 // Replace the test-only closure's captured directory before any sync is queued.
44 dataDir = h.data
45 c := &cliHarness{harness: h, url: h.srv.URL}
46 run := func(args ...string) result { return c.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
47 h.createRepo("proj", domain.VisibilityPrivate)
48 remote := filepath.Join(t.TempDir(), "remote.git")
49 h.mustGit(transport, "init", "--bare", remote)
50 run("add", "proj", "file://"+remote, "--cron", "*/5 * * * *").ok(t)
51 key := run("key", "show").ok(t)
52 assert.Empty(t, key.stderr)
53 pub, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key.stdout))
54 require.NoError(t, err)
55 assert.Empty(t, rest)
56 assert.Equal(t, "ssh-ed25519", pub.Type())
57 assert.Equal(t, 1, strings.Count(key.stdout, "\n"))
58 assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")
59 h.forge.Mirrors.Start()
60 events := h.forge.Bus.Subscribe(h.ctx)
61 wt := h.workTree("main", "README.md")
62 h.mustGit(wt, "tag", "v1")
63 h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main", "--tags")
64 clk.set("2026-01-01 00:00:00")
65 h.forge.Mirrors.ScheduleNow(h.ctx)
66 require.Eventually(t, func() bool {
67 s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
68 return err == nil && !s.Status.LastSuccessAt.IsZero()
69 }, 10*time.Second, 10*time.Millisecond)
70 main := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
71 assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
72 assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/tags/v1")))
73 clk.set("2026-01-01 00:02:00")
74 assert.Contains(t, run("sync", "proj").ok(t).stdout, "true", "unchanged refs are a no-op")
75
76 // Advance the remote independently, so the next atomic mirror push is rejected.
77 other := filepath.Join(t.TempDir(), "other")
78 h.mustGit(transport, "clone", "--branch", "main", remote, other)
79 h.mustGit(other, "commit", "--allow-empty", "-m", "remote-only")
80 h.mustGit(other, "push", "origin", "main")
81 h.mustGit(wt, "commit", "--allow-empty", "-m", "forge-only")
82 h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
83 clk.set("2026-01-01 00:05:00")
84 h.forge.Mirrors.ScheduleNow(h.ctx)
85 require.Eventually(t, func() bool {
86 s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
87 return err == nil && s.Status.State == domain.MirrorPaused
88 }, 10*time.Second, 10*time.Millisecond)
89 status := run("status", "proj").ok(t).stdout
90 assert.Contains(t, status, "paused")
91 assert.Contains(t, status, "remote has commits")
92 assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class": "diverged"`)
93 h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")
94 warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
95 assert.Contains(t, warning, "remote: ermite: mirror")
96 assert.Contains(t, warning, "paused")
97 token := c.signIn(t)
98 page := (browser{h}).do("GET", "/proj", map[string]string{"Cookie": sessionCookie + "=" + token}, "")
99 assert.Equal(t, 200, page.status)
100 assert.Contains(t, page.body, "Mirror paused")
101 assert.Contains(t, page.body, "ermite mirror status proj")
102
103 // Repair the remote, then force a normal (never forced) Git push to recover.
104 latest := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
105 h.mustGit(wt, "push", "--force", remote, "main") // deliberate remote repair, not mirror behavior
106 run("sync", "proj", "--force").ok(t)
107 assert.Equal(t, latest, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
108 assert.Contains(t, run("status", "proj").ok(t).stdout, "ok")
109 run("sync", "--all", "--force").ok(t)
110 var kinds []string
111 for {
112 select {
113 case event := <-events:
114 if event.EventKind() == "mirror.failed" || event.EventKind() == "mirror.recovered" {
115 kinds = append(kinds, event.EventKind())
116 }
117 default:
118 assert.Equal(t, []string{"mirror.failed", "mirror.recovered"}, kinds)
119 return
120 }
121 }
122}