1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16 "bnema.dev/ermite/internal/usecase"
17)
18
19func TestMirrorsAuthorizationMatrix(t *testing.T) {
20 f := newMirrorFixture(t)
21 ctx := context.Background()
22 reader := tokenPrincipal(domain.ScopeRepoRead)
23 admin := tokenPrincipal(domain.ScopeRepoAdmin)
24 _, err := f.m.GetGlobalConfig(ctx, reader)
25 require.ErrorIs(t, err, domain.ErrForbidden)
26 _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{})
27 require.ErrorIs(t, err, domain.ErrForbidden)
28 _, err = f.m.ListStatus(ctx, reader)
29 require.ErrorIs(t, err, domain.ErrForbidden)
30 _, err = f.m.RotateKey(ctx, admin)
31 require.ErrorIs(t, err, domain.ErrForbidden)
32 // Key show deliberately requires authentication, not owner rights.
33 _, err = f.m.ShowKey(ctx, reader)
34 require.NoError(t, err)
35 _, err = f.m.ShowKey(ctx, domain.Anonymous())
36 require.ErrorIs(t, err, domain.ErrUnauthenticated)
37 _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo})
38 require.ErrorIs(t, err, domain.ErrForbidden)
39 err = f.m.RemoveRepoMirror(ctx, reader, f.repo)
40 require.ErrorIs(t, err, domain.ErrForbidden)
41 _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true)
42 require.ErrorIs(t, err, domain.ErrForbidden)
43 _, err = f.m.Sync(ctx, reader, f.repo, false)
44 require.ErrorIs(t, err, domain.ErrForbidden)
45 f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK, Detail: "admin detail", Reason: "admin reason"})
46 view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo)
47 require.NoError(t, err)
48 require.Empty(t, view.Status.Detail)
49 require.Empty(t, view.Status.Reason)
50 require.Empty(t, view.Target.URL)
51 view, err = f.m.GetStatus(ctx, admin, f.repo)
52 require.NoError(t, err)
53 require.Equal(t, "admin detail", view.Status.Detail)
54 _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo})
55 require.NoError(t, err)
56}
57
58func TestMirrorsPrivateStatusAuthorization(t *testing.T) {
59 f := newMirrorFixture(t)
60 f.mu.Lock()
61 f.repos[0].Visibility = domain.VisibilityPrivate
62 f.mu.Unlock()
63 _, err := f.m.GetStatus(context.Background(), domain.Anonymous(), f.repo)
64 require.ErrorIs(t, err, domain.ErrNotFound)
65 _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo)
66 require.ErrorIs(t, err, domain.ErrNotFound)
67 _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo)
68 require.NoError(t, err)
69}
70
71func TestMirrorsWarningIsAdminOnly(t *testing.T) {
72 f := newMirrorFixture(t)
73 other := name(t, "other")
74 failing := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "the remote has commits"}
75 for _, tc := range []struct {
76 name string
77 p domain.Principal
78 status domain.MirrorStatus
79 want bool
80 }{
81 {"root", domain.Root(), failing, true},
82 {"repo admin", tokenPrincipal(domain.ScopeRepoAdmin, "tools"), failing, true},
83 {"admin of another repository", tokenPrincipal(domain.ScopeRepoAdmin, other.String()), failing, false},
84 {"writer", tokenPrincipal(domain.ScopeRepoWrite, "tools"), failing, false},
85 {"anonymous", domain.Anonymous(), failing, false},
86 {"admin, mirror ok", domain.Root(), domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK}, false},
87 } {
88 t.Run(tc.name, func(t *testing.T) {
89 f.setStatus(tc.status)
90 w := f.m.Warning(context.Background(), tc.p, f.repo)
91 if !tc.want {
92 require.Empty(t, w)
93 return
94 }
95 require.Contains(t, w, "example.com:acme/tools")
96 require.Contains(t, w, "paused")
97 require.Contains(t, w, "the remote has commits")
98 })
99 }
100}
101
102func TestMirrorsBadge(t *testing.T) {
103 f := newMirrorFixture(t)
104 f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Reason: "retrying"})
105 state, reason := f.m.Badge(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin, "tools"), f.repo)
106 require.Equal(t, domain.MirrorFailing, state)
107 require.Equal(t, "retrying", reason)
108 state, reason = f.m.Badge(context.Background(), domain.Anonymous(), f.repo)
109 require.Equal(t, domain.MirrorFailing, state)
110 require.Empty(t, reason, "the reason is for admins")
111 state, _ = f.m.Badge(context.Background(), domain.Root(), name(t, "ghost"))
112 require.Equal(t, domain.MirrorDisabled, state)
113 // A badge needs no schedule: with an unparsable-looking cron the view still works and costs no search.
114 require.NotPanics(t, func() { f.m.Badge(context.Background(), domain.Root(), f.repo) })
115}
116
117func TestMirrorsListStatusReadsOnceAndHidesNothingFromOwner(t *testing.T) {
118 f := newMirrorFixture(t)
119 second := name(t, "second")
120 f.addRepo(second)
121 f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, Reason: "r"})
122 list, err := f.m.ListStatus(context.Background(), domain.Root())
123 require.NoError(t, err)
124 require.Len(t, list, 2)
125 require.Equal(t, "git@example.com:acme/tools", list[0].Target.URL)
126 require.Equal(t, domain.MirrorPaused, list[1].Status.State)
127 require.False(t, list[0].NextRunAt.IsZero())
128 require.True(t, list[1].NextRunAt.IsZero(), "a paused mirror has no next run")
129}
130
131func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) {
132 f := newMirrorFixture(t)
133 f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1})
134 _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:acme/tools", Cron: "*/5 * * * *"})
135 require.NoError(t, err)
136 s := f.statusOf(f.repo)
137 require.Equal(t, domain.MirrorOK, s.State)
138 require.Zero(t, s.ConsecutiveFailures)
139}
140
141func TestMirrorsRepoChangesResetOnlyWhenTheTargetChanged(t *testing.T) {
142 f := newMirrorFixture(t)
143 ctx := context.Background()
144 paused := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1}
145 f.setStatus(paused)
146 // The override restates the inherited target: nothing changed, the incident stays.
147 _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
148 require.NoError(t, err) // source changes global -> override: a change
149 f.setStatus(paused)
150 writes := f.statusWrites()
151 _, err = f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
152 require.NoError(t, err)
153 require.Equal(t, writes, f.statusWrites(), "same effective target: no reset")
154 require.Equal(t, domain.MirrorPaused, f.statusOf(f.repo).State)
155 // Removing a no-op exclusion, and excluding to the same effect, follow the same rule.
156 err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo)
157 require.NoError(t, err)
158 require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State, "override gone: the source changed back to global")
159 f.setStatus(paused)
160 writes = f.statusWrites()
161 err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo)
162 require.NoError(t, err)
163 require.Equal(t, writes, f.statusWrites(), "no override to remove: no reset")
164 _, err = f.m.SetRepoExcluded(ctx, domain.Root(), f.repo, true)
165 require.NoError(t, err)
166 require.Equal(t, domain.MirrorDisabled, f.statusOf(f.repo).State)
167}
168
169func TestMirrorsGlobalResetContinuesPastErrors(t *testing.T) {
170 f := newMirrorFixture(t)
171 second := name(t, "second")
172 f.addRepo(second)
173 f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, ConsecutiveFailures: 1})
174 f.failStatusWrites(f.repo, errBoom)
175 _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
176 require.ErrorIs(t, err, errBoom)
177 require.Equal(t, domain.MirrorOK, f.statusOf(second).State, "the other repository was still reset")
178 require.Equal(t, "*/10 * * * *", f.global.Cron, "the policy itself is saved")
179}
180
181func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) {
182 f := newMirrorFixture(t)
183 f.setGlobal(domain.MirrorGlobalConfig{})
184 _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
185 var fe *domain.InvalidFieldsError
186 require.ErrorAs(t, err, &fe)
187 require.Equal(t, "cron", fe.Fields[0].Field)
188 require.Empty(t, f.cfgs, "nothing is stored")
189}
190
191func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) {
192 f := newMirrorFixture(t)
193 f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"})
194 // Disabling the global with an empty cron leaves the inheriting override with no cron.
195 _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")})
196 var fe *domain.InvalidFieldsError
197 require.ErrorAs(t, err, &fe)
198 require.True(t, f.global.Enabled, "nothing is stored")
199}
200
201func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) {
202 f := newMirrorFixture(t)
203 f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"})
204 f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged})
205 _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
206 require.NoError(t, err)
207 require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State)
208}
209
210func TestMirrorsSetRepoExcludedConcurrentIsSerialized(t *testing.T) {
211 f := newMirrorFixture(t)
212 var mu sync.Mutex
213 active, maxActive := 0, 0
214 enter, leave := make(chan struct{}), make(chan struct{})
215 f.onPutRepo = func() {
216 mu.Lock()
217 active++
218 maxActive = max(maxActive, active)
219 mu.Unlock()
220 enter <- struct{}{}
221 <-leave
222 mu.Lock()
223 active--
224 mu.Unlock()
225 }
226 errs := make(chan error, 4)
227 for i := range 4 {
228 go func() {
229 _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, i%2 == 0)
230 errs <- err
231 }()
232 }
233 // While one writer is blocked inside the store, the others cannot have entered it.
234 for range 4 {
235 <-enter
236 mu.Lock()
237 require.Equal(t, 1, active)
238 mu.Unlock()
239 leave <- struct{}{}
240 }
241 for range 4 {
242 require.NoError(t, <-errs)
243 }
244 require.Equal(t, 1, maxActive, "read-modify-write must be serialized")
245}
246
247func TestMirrorsURLValidatorIsInjectable(t *testing.T) {
248 for _, tc := range []struct {
249 name string
250 validate func(string) error
251 ok bool
252 }{
253 {"default rejects file", nil, false},
254 {"injected accepts file", func(string) error { return nil }, true},
255 {"injected rejects", func(string) error { return errors.New("no") }, false},
256 } {
257 t.Run(tc.name, func(t *testing.T) {
258 f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.ValidateURL = tc.validate })
259 target, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"})
260 if tc.ok {
261 require.NoError(t, err)
262 require.Equal(t, "file:///tmp/remotes/tools", target.URL)
263 } else {
264 require.ErrorIs(t, err, domain.ErrInvalid)
265 }
266 })
267 }
268}
269
270func TestMirrorsNextRunIsComputedForStatusOnly(t *testing.T) {
271 f := newMirrorFixture(t)
272 view, err := f.m.GetStatus(context.Background(), domain.Root(), f.repo)
273 require.NoError(t, err)
274 require.True(t, view.NextRunAt.Equal(now.Add(8*time.Minute)) || !view.NextRunAt.IsZero())
275 require.Equal(t, 0, view.NextRunAt.Minute()%5)
276}