detached 5805840

fix(mirror): sync --all past failures, admin-only warnings, no config lock during pushes and deferred due repos

commit
580584075d9c42622dfc54f01807709cf43c326a
author
brice · · 2026-10-20 07:06 UTC
committer
brice · · 2026-10-20 10:02 UTC
parent
5e1949f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1579 -791
M internal/usecase/mirrors.go +41 -23
@@ -12,6 +12,8 @@ import (
1212 "bnema.dev/ermite/internal/ports"
1313 )
1414
15+const mirrorQueueSize = 64
16+
1517 // MirrorsConfig supplies the mirror use case's dependencies.
1618 type MirrorsConfig struct {
1719 Store ports.MirrorStore
@@ -26,25 +28,38 @@ type MirrorsConfig struct {
2628 PublicURL string
2729 ScheduleTick time.Duration
2830 Workers int
29- AllowAnyURL bool
31+ QueueSize int // jobs waiting for a worker; default 64
32+ // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of
33+ // local transports inject a permissive check.
34+ ValidateURL func(string) error
35+}
36+
37+// mirrorGen counts the changes of a repository (dirty) and the count the last successful
38+// sync saw (clean, meaningful once cleanKnown).
39+type mirrorGen struct {
40+ dirty, clean uint64
41+ cleanKnown bool
3042 }
3143
3244 // Mirrors manages outbound repository mirrors.
3345 type Mirrors struct {
34- cfg MirrorsConfig
35- configMu sync.RWMutex
36- mu sync.Mutex
37- dirty map[domain.RepoName]uint64
38- clean map[domain.RepoName]uint64
39- flights map[domain.RepoName]*mirrorFlight
40- ctx context.Context
41- cancel context.CancelFunc
42- wg sync.WaitGroup
43- start sync.Once
44- closed bool
45- jobs chan domain.RepoName
46+ cfg MirrorsConfig
47+ configMu sync.RWMutex // configuration changes; held for reads only around store reads and writes, never during a push
48+ mu sync.Mutex
49+ gens map[domain.RepoName]mirrorGen
50+ flights map[domain.RepoName]*mirrorFlight
51+ flightWG sync.WaitGroup // one per flight, for tests that wait for the queue to drain
52+ ctx context.Context
53+ cancel context.CancelFunc
54+ wg sync.WaitGroup
55+ start sync.Once
56+ closed bool
57+ jobs chan domain.RepoName
58+
4659 scheduleMu sync.Mutex
4760 lastMinute time.Time
61+ deferred map[domain.RepoName]struct{} // due, but the queue was full
62+
4863 keyMu sync.Mutex
4964 key ed25519.PrivateKey
5065 keyCreated time.Time
@@ -58,16 +73,19 @@ func NewMirrors(cfg MirrorsConfig) *Mirrors {
5873 if cfg.Workers <= 0 {
5974 cfg.Workers = 2
6075 }
76+ if cfg.QueueSize <= 0 {
77+ cfg.QueueSize = mirrorQueueSize
78+ }
79+ if cfg.ValidateURL == nil {
80+ cfg.ValidateURL = domain.ValidateMirrorURL
81+ }
6182 ctx, cancel := context.WithCancel(context.Background())
62- return &Mirrors{cfg: cfg, ctx: ctx, cancel: cancel, dirty: make(map[domain.RepoName]uint64), clean: make(map[domain.RepoName]uint64), flights: make(map[domain.RepoName]*mirrorFlight), jobs: make(chan domain.RepoName, 64), lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute)}
63-}
64-
65-// privateKey returns a copy so a transport cannot mutate the cached key.
66-func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) {
67- m.keyMu.Lock()
68- defer m.keyMu.Unlock()
69- if err := m.loadKey(ctx, false); err != nil {
70- return nil, err
83+ return &Mirrors{
84+ cfg: cfg, ctx: ctx, cancel: cancel,
85+ gens: make(map[domain.RepoName]mirrorGen),
86+ flights: make(map[domain.RepoName]*mirrorFlight),
87+ deferred: make(map[domain.RepoName]struct{}),
88+ jobs: make(chan domain.RepoName, cfg.QueueSize),
89+ lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute),
7190 }
72- return append(ed25519.PrivateKey(nil), m.key...), nil
7391 }
@@ -12,6 +12,8 @@ import (
12 "bnema.dev/ermite/internal/ports"12 "bnema.dev/ermite/internal/ports"
13 )13 )
14 14
15+const mirrorQueueSize = 64
16+
15 // MirrorsConfig supplies the mirror use case's dependencies.17 // MirrorsConfig supplies the mirror use case's dependencies.
16 type MirrorsConfig struct {18 type MirrorsConfig struct {
17 Store ports.MirrorStore19 Store ports.MirrorStore
@@ -26,25 +28,38 @@ type MirrorsConfig struct {
26 PublicURL string28 PublicURL string
27 ScheduleTick time.Duration29 ScheduleTick time.Duration
28 Workers int30 Workers int
29- AllowAnyURL bool31+ QueueSize int // jobs waiting for a worker; default 64
32+ // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of
33+ // local transports inject a permissive check.
34+ ValidateURL func(string) error
35+}
36+
37+// mirrorGen counts the changes of a repository (dirty) and the count the last successful
38+// sync saw (clean, meaningful once cleanKnown).
39+type mirrorGen struct {
40+ dirty, clean uint64
41+ cleanKnown bool
30 }42 }
31 43
32 // Mirrors manages outbound repository mirrors.44 // Mirrors manages outbound repository mirrors.
33 type Mirrors struct {45 type Mirrors struct {
34- cfg MirrorsConfig46+ cfg MirrorsConfig
35- configMu sync.RWMutex47+ configMu sync.RWMutex // configuration changes; held for reads only around store reads and writes, never during a push
36- mu sync.Mutex48+ mu sync.Mutex
37- dirty map[domain.RepoName]uint6449+ gens map[domain.RepoName]mirrorGen
38- clean map[domain.RepoName]uint6450+ flights map[domain.RepoName]*mirrorFlight
39- flights map[domain.RepoName]*mirrorFlight51+ flightWG sync.WaitGroup // one per flight, for tests that wait for the queue to drain
40- ctx context.Context52+ ctx context.Context
41- cancel context.CancelFunc53+ cancel context.CancelFunc
42- wg sync.WaitGroup54+ wg sync.WaitGroup
43- start sync.Once55+ start sync.Once
44- closed bool56+ closed bool
45- jobs chan domain.RepoName57+ jobs chan domain.RepoName
58+
46 scheduleMu sync.Mutex59 scheduleMu sync.Mutex
47 lastMinute time.Time60 lastMinute time.Time
61+ deferred map[domain.RepoName]struct{} // due, but the queue was full
62+
48 keyMu sync.Mutex63 keyMu sync.Mutex
49 key ed25519.PrivateKey64 key ed25519.PrivateKey
50 keyCreated time.Time65 keyCreated time.Time
@@ -58,16 +73,19 @@ func NewMirrors(cfg MirrorsConfig) *Mirrors {
58 if cfg.Workers <= 0 {73 if cfg.Workers <= 0 {
59 cfg.Workers = 274 cfg.Workers = 2
60 }75 }
76+ if cfg.QueueSize <= 0 {
77+ cfg.QueueSize = mirrorQueueSize
78+ }
79+ if cfg.ValidateURL == nil {
80+ cfg.ValidateURL = domain.ValidateMirrorURL
81+ }
61 ctx, cancel := context.WithCancel(context.Background())82 ctx, cancel := context.WithCancel(context.Background())
62- return &Mirrors{cfg: cfg, ctx: ctx, cancel: cancel, dirty: make(map[domain.RepoName]uint64), clean: make(map[domain.RepoName]uint64), flights: make(map[domain.RepoName]*mirrorFlight), jobs: make(chan domain.RepoName, 64), lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute)}83+ return &Mirrors{
63-}84+ cfg: cfg, ctx: ctx, cancel: cancel,
64-85+ gens: make(map[domain.RepoName]mirrorGen),
65-// privateKey returns a copy so a transport cannot mutate the cached key.86+ flights: make(map[domain.RepoName]*mirrorFlight),
66-func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) {87+ deferred: make(map[domain.RepoName]struct{}),
67- m.keyMu.Lock()88+ jobs: make(chan domain.RepoName, cfg.QueueSize),
68- defer m.keyMu.Unlock()89+ lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute),
69- if err := m.loadKey(ctx, false); err != nil {
70- return nil, err
71 }90 }
72- return append(ed25519.PrivateKey(nil), m.key...), nil
73 }91 }
M internal/usecase/mirrors_config.go +79 -47
@@ -5,7 +5,6 @@ package usecase
55 import (
66 "context"
77 "errors"
8- "strings"
98 "time"
109
1110 "bnema.dev/ermite/internal/domain"
@@ -122,12 +121,14 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch
122121 if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
123122 return old, err
124123 }
124+ // One failing reset must not leave the others stale: the policy is saved.
125+ var errs []error
125126 for _, name := range changed {
126127 if err := m.reset(ctx, name); err != nil {
127- return g, err
128+ errs = append(errs, err)
128129 }
129130 }
130- return g, nil
131+ return g, errors.Join(errs...)
131132 }
132133
133134 func invalidCron(e error) error {
@@ -141,6 +142,8 @@ func invalidCron(e error) error {
141142 func sameMirrorTarget(a, b domain.MirrorTarget) bool {
142143 return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source
143144 }
145+
146+// reset lifts the incident of a repository whose effective target changed and marks it dirty.
144147 func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
145148 t, err := m.target(ctx, repo)
146149 if err != nil {
@@ -157,11 +160,25 @@ func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
157160 s = domain.MirrorDisable(s, now)
158161 }
159162 m.mu.Lock()
160- m.dirty[repo]++
163+ g := m.gens[repo]
164+ g.dirty++
165+ m.gens[repo] = g
161166 m.mu.Unlock()
162167 return m.cfg.Store.PutStatus(ctx, s)
163168 }
164169
170+// resetIfChanged resets repo when its effective target is no longer before. configMu is held.
171+func (m *Mirrors) resetIfChanged(ctx context.Context, repo domain.RepoName, before domain.MirrorTarget, beforeErr error) (domain.MirrorTarget, error) {
172+ after, err := m.target(ctx, repo)
173+ if err != nil {
174+ return after, err
175+ }
176+ if beforeErr == nil && sameMirrorTarget(before, after) {
177+ return after, nil
178+ }
179+ return after, m.reset(ctx, repo)
180+}
181+
165182 // SetRepoMirror sets a repository override.
166183 func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
167184 if err := m.admin(ctx, p, c.Repo); err != nil {
@@ -184,14 +201,12 @@ func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig)
184201 if _, err := m.resolve(g, &c, c.Repo); err != nil {
185202 return domain.MirrorTarget{}, invalidCron(err)
186203 }
204+ before, beforeErr := m.target(ctx, c.Repo)
187205 c.UpdatedAt = m.cfg.Clock.Now().UTC()
188206 if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {
189207 return domain.MirrorTarget{}, err
190208 }
191- if err := m.reset(ctx, c.Repo); err != nil {
192- return domain.MirrorTarget{}, err
193- }
194- return m.target(ctx, c.Repo)
209+ return m.resetIfChanged(ctx, c.Repo, before, beforeErr)
195210 }
196211
197212 // RemoveRepoMirror restores inherited policy.
@@ -201,10 +216,12 @@ func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo
201216 }
202217 m.configMu.Lock()
203218 defer m.configMu.Unlock()
219+ before, beforeErr := m.target(ctx, repo)
204220 if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil {
205221 return err
206222 }
207- return m.reset(ctx, repo)
223+ _, err := m.resetIfChanged(ctx, repo, before, beforeErr)
224+ return err
208225 }
209226
210227 // SetRepoExcluded updates exclusion without discarding an override.
@@ -224,6 +241,11 @@ func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo
224241
225242 // GetStatus returns a read-authorized status, redacting administrative diagnostics.
226243 func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) {
244+ return m.statusView(ctx, p, repo, true)
245+}
246+
247+// statusView builds the view of one repository; withNext computes the next scheduled run.
248+func (m *Mirrors) statusView(ctx context.Context, p domain.Principal, repo domain.RepoName, withNext bool) (ports.MirrorStatusView, error) {
227249 r, err := m.cfg.Meta.Get(ctx, repo)
228250 if err != nil {
229251 return ports.MirrorStatusView{}, err
@@ -239,22 +261,26 @@ func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain
239261 if err != nil {
240262 return ports.MirrorStatusView{}, err
241263 }
264+ return m.view(p, t, s, withNext), nil
265+}
266+
267+func (m *Mirrors) view(p domain.Principal, t domain.MirrorTarget, s domain.MirrorStatus, withNext bool) ports.MirrorStatusView {
242268 var next time.Time
243- if t.Enabled && s.State != domain.MirrorPaused {
269+ if withNext && t.Enabled && s.State != domain.MirrorPaused {
244270 if s.State == domain.MirrorFailing {
245271 next = s.NextRetryAt
246272 } else {
247273 next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now())
248274 }
249275 }
250- if !p.Allows(domain.ScopeRepoAdmin, repo) {
276+ if !p.Allows(domain.ScopeRepoAdmin, t.Repo) {
251277 s.Detail, s.Reason = "", ""
252278 t.URL = ""
253279 }
254- return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}, nil
280+ return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}
255281 }
256282
257-// ListStatus returns all statuses to an owner.
283+// ListStatus returns all statuses to an owner, from three reads instead of three per repository.
258284 func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) {
259285 if err := mirrorOwner(p); err != nil {
260286 return nil, err
@@ -263,19 +289,51 @@ func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.M
263289 if err != nil {
264290 return nil, err
265291 }
292+ g, err := m.cfg.Store.GetGlobal(ctx)
293+ if err != nil {
294+ return nil, err
295+ }
296+ configs, err := m.cfg.Store.ListRepoConfigs(ctx)
297+ if err != nil {
298+ return nil, err
299+ }
300+ byRepo := make(map[domain.RepoName]domain.MirrorRepoConfig, len(configs))
301+ for _, c := range configs {
302+ byRepo[c.Repo] = c
303+ }
304+ statuses, err := m.cfg.Store.ListStatus(ctx)
305+ if err != nil {
306+ return nil, err
307+ }
308+ stored := make(map[domain.RepoName]domain.MirrorStatus, len(statuses))
309+ for _, s := range statuses {
310+ stored[s.Repo] = s
311+ }
266312 out := make([]ports.MirrorStatusView, 0, len(repos))
267313 for _, r := range repos {
268- v, err := m.GetStatus(ctx, p, r.Name)
314+ var cp *domain.MirrorRepoConfig
315+ if c, ok := byRepo[r.Name]; ok {
316+ cp = &c
317+ }
318+ t, err := m.resolve(g, cp, r.Name)
269319 if err != nil {
270320 return nil, err
271321 }
272- out = append(out, v)
322+ s, ok := stored[r.Name]
323+ if !ok {
324+ s = domain.MirrorStatus{Repo: r.Name, State: domain.MirrorDisabled}
325+ }
326+ out = append(out, m.view(p, t, s, true))
273327 }
274328 return out, nil
275329 }
276330
277-// Warning provides an administrative post-receive diagnostic.
278-func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string {
331+// Warning provides the post-receive diagnostic of a failing or paused mirror to the admins of
332+// the repository only: it names the remote and the reason.
333+func (m *Mirrors) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
334+ if !p.Allows(domain.ScopeRepoAdmin, repo) {
335+ return ""
336+ }
279337 s, err := m.status(ctx, repo)
280338 if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) {
281339 return ""
@@ -289,45 +347,19 @@ func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string {
289347
290348 // Badge returns a viewer-safe state and optional administrative reason.
291349 func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) {
292- v, err := m.GetStatus(ctx, p, repo)
350+ v, err := m.statusView(ctx, p, repo, false)
293351 if err != nil {
294352 return domain.MirrorDisabled, ""
295353 }
296354 return v.Status.State, v.Status.Reason
297355 }
298356
299-// testURL substitutes only during validation/resolution; stored and pushed URLs remain intact.
300-func (m *Mirrors) testURL(url string) string {
301- if m.cfg.AllowAnyURL && strings.HasPrefix(url, "file://") {
302- return "git@mirror-test:" + strings.TrimPrefix(url, "file://")
303- }
304- return url
305-}
306357 func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error {
307- g.URLTemplate = m.testURL(g.URLTemplate)
308- return domain.ValidateMirrorGlobalConfig(g)
358+ return domain.ValidateMirrorGlobalConfigWith(g, m.cfg.ValidateURL)
309359 }
310360 func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error {
311- c.URL = m.testURL(c.URL)
312- return domain.ValidateMirrorRepoConfig(c)
361+ return domain.ValidateMirrorRepoConfigWith(c, m.cfg.ValidateURL)
313362 }
314363 func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) {
315- originalGlobal := g.URLTemplate
316- originalRepo := ""
317- g.URLTemplate = m.testURL(g.URLTemplate)
318- if c != nil {
319- copyConfig := *c
320- originalRepo = c.URL
321- copyConfig.URL = m.testURL(c.URL)
322- c = &copyConfig
323- }
324- t, err := domain.ResolveMirror(g, c, repo)
325- if err == nil && t.Enabled && m.cfg.AllowAnyURL {
326- if originalRepo != "" {
327- t.URL = originalRepo
328- } else if strings.HasPrefix(originalGlobal, "file://") {
329- t.URL, err = domain.ExpandMirrorURL(originalGlobal, repo)
330- }
331- }
332- return t, err
364+ return domain.ResolveMirrorWith(g, c, repo, m.cfg.ValidateURL)
333365 }
@@ -5,7 +5,6 @@ package usecase
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8- "strings"
9 "time"8 "time"
10 9
11 "bnema.dev/ermite/internal/domain"10 "bnema.dev/ermite/internal/domain"
@@ -122,12 +121,14 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch
122 if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {121 if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
123 return old, err122 return old, err
124 }123 }
124+ // One failing reset must not leave the others stale: the policy is saved.
125+ var errs []error
125 for _, name := range changed {126 for _, name := range changed {
126 if err := m.reset(ctx, name); err != nil {127 if err := m.reset(ctx, name); err != nil {
127- return g, err128+ errs = append(errs, err)
128 }129 }
129 }130 }
130- return g, nil131+ return g, errors.Join(errs...)
131 }132 }
132 133
133 func invalidCron(e error) error {134 func invalidCron(e error) error {
@@ -141,6 +142,8 @@ func invalidCron(e error) error {
141 func sameMirrorTarget(a, b domain.MirrorTarget) bool {142 func sameMirrorTarget(a, b domain.MirrorTarget) bool {
142 return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source143 return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source
143 }144 }
145+
146+// reset lifts the incident of a repository whose effective target changed and marks it dirty.
144 func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {147 func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
145 t, err := m.target(ctx, repo)148 t, err := m.target(ctx, repo)
146 if err != nil {149 if err != nil {
@@ -157,11 +160,25 @@ func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
157 s = domain.MirrorDisable(s, now)160 s = domain.MirrorDisable(s, now)
158 }161 }
159 m.mu.Lock()162 m.mu.Lock()
160- m.dirty[repo]++163+ g := m.gens[repo]
164+ g.dirty++
165+ m.gens[repo] = g
161 m.mu.Unlock()166 m.mu.Unlock()
162 return m.cfg.Store.PutStatus(ctx, s)167 return m.cfg.Store.PutStatus(ctx, s)
163 }168 }
164 169
170+// resetIfChanged resets repo when its effective target is no longer before. configMu is held.
171+func (m *Mirrors) resetIfChanged(ctx context.Context, repo domain.RepoName, before domain.MirrorTarget, beforeErr error) (domain.MirrorTarget, error) {
172+ after, err := m.target(ctx, repo)
173+ if err != nil {
174+ return after, err
175+ }
176+ if beforeErr == nil && sameMirrorTarget(before, after) {
177+ return after, nil
178+ }
179+ return after, m.reset(ctx, repo)
180+}
181+
165 // SetRepoMirror sets a repository override.182 // SetRepoMirror sets a repository override.
166 func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {183 func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
167 if err := m.admin(ctx, p, c.Repo); err != nil {184 if err := m.admin(ctx, p, c.Repo); err != nil {
@@ -184,14 +201,12 @@ func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig)
184 if _, err := m.resolve(g, &c, c.Repo); err != nil {201 if _, err := m.resolve(g, &c, c.Repo); err != nil {
185 return domain.MirrorTarget{}, invalidCron(err)202 return domain.MirrorTarget{}, invalidCron(err)
186 }203 }
204+ before, beforeErr := m.target(ctx, c.Repo)
187 c.UpdatedAt = m.cfg.Clock.Now().UTC()205 c.UpdatedAt = m.cfg.Clock.Now().UTC()
188 if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {206 if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {
189 return domain.MirrorTarget{}, err207 return domain.MirrorTarget{}, err
190 }208 }
191- if err := m.reset(ctx, c.Repo); err != nil {209+ return m.resetIfChanged(ctx, c.Repo, before, beforeErr)
192- return domain.MirrorTarget{}, err
193- }
194- return m.target(ctx, c.Repo)
195 }210 }
196 211
197 // RemoveRepoMirror restores inherited policy.212 // RemoveRepoMirror restores inherited policy.
@@ -201,10 +216,12 @@ func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo
201 }216 }
202 m.configMu.Lock()217 m.configMu.Lock()
203 defer m.configMu.Unlock()218 defer m.configMu.Unlock()
219+ before, beforeErr := m.target(ctx, repo)
204 if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil {220 if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil {
205 return err221 return err
206 }222 }
207- return m.reset(ctx, repo)223+ _, err := m.resetIfChanged(ctx, repo, before, beforeErr)
224+ return err
208 }225 }
209 226
210 // SetRepoExcluded updates exclusion without discarding an override.227 // SetRepoExcluded updates exclusion without discarding an override.
@@ -224,6 +241,11 @@ func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo
224 241
225 // GetStatus returns a read-authorized status, redacting administrative diagnostics.242 // GetStatus returns a read-authorized status, redacting administrative diagnostics.
226 func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) {243 func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) {
244+ return m.statusView(ctx, p, repo, true)
245+}
246+
247+// statusView builds the view of one repository; withNext computes the next scheduled run.
248+func (m *Mirrors) statusView(ctx context.Context, p domain.Principal, repo domain.RepoName, withNext bool) (ports.MirrorStatusView, error) {
227 r, err := m.cfg.Meta.Get(ctx, repo)249 r, err := m.cfg.Meta.Get(ctx, repo)
228 if err != nil {250 if err != nil {
229 return ports.MirrorStatusView{}, err251 return ports.MirrorStatusView{}, err
@@ -239,22 +261,26 @@ func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain
239 if err != nil {261 if err != nil {
240 return ports.MirrorStatusView{}, err262 return ports.MirrorStatusView{}, err
241 }263 }
264+ return m.view(p, t, s, withNext), nil
265+}
266+
267+func (m *Mirrors) view(p domain.Principal, t domain.MirrorTarget, s domain.MirrorStatus, withNext bool) ports.MirrorStatusView {
242 var next time.Time268 var next time.Time
243- if t.Enabled && s.State != domain.MirrorPaused {269+ if withNext && t.Enabled && s.State != domain.MirrorPaused {
244 if s.State == domain.MirrorFailing {270 if s.State == domain.MirrorFailing {
245 next = s.NextRetryAt271 next = s.NextRetryAt
246 } else {272 } else {
247 next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now())273 next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now())
248 }274 }
249 }275 }
250- if !p.Allows(domain.ScopeRepoAdmin, repo) {276+ if !p.Allows(domain.ScopeRepoAdmin, t.Repo) {
251 s.Detail, s.Reason = "", ""277 s.Detail, s.Reason = "", ""
252 t.URL = ""278 t.URL = ""
253 }279 }
254- return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}, nil280+ return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}
255 }281 }
256 282
257-// ListStatus returns all statuses to an owner.283+// ListStatus returns all statuses to an owner, from three reads instead of three per repository.
258 func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) {284 func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) {
259 if err := mirrorOwner(p); err != nil {285 if err := mirrorOwner(p); err != nil {
260 return nil, err286 return nil, err
@@ -263,19 +289,51 @@ func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.M
263 if err != nil {289 if err != nil {
264 return nil, err290 return nil, err
265 }291 }
292+ g, err := m.cfg.Store.GetGlobal(ctx)
293+ if err != nil {
294+ return nil, err
295+ }
296+ configs, err := m.cfg.Store.ListRepoConfigs(ctx)
297+ if err != nil {
298+ return nil, err
299+ }
300+ byRepo := make(map[domain.RepoName]domain.MirrorRepoConfig, len(configs))
301+ for _, c := range configs {
302+ byRepo[c.Repo] = c
303+ }
304+ statuses, err := m.cfg.Store.ListStatus(ctx)
305+ if err != nil {
306+ return nil, err
307+ }
308+ stored := make(map[domain.RepoName]domain.MirrorStatus, len(statuses))
309+ for _, s := range statuses {
310+ stored[s.Repo] = s
311+ }
266 out := make([]ports.MirrorStatusView, 0, len(repos))312 out := make([]ports.MirrorStatusView, 0, len(repos))
267 for _, r := range repos {313 for _, r := range repos {
268- v, err := m.GetStatus(ctx, p, r.Name)314+ var cp *domain.MirrorRepoConfig
315+ if c, ok := byRepo[r.Name]; ok {
316+ cp = &c
317+ }
318+ t, err := m.resolve(g, cp, r.Name)
269 if err != nil {319 if err != nil {
270 return nil, err320 return nil, err
271 }321 }
272- out = append(out, v)322+ s, ok := stored[r.Name]
323+ if !ok {
324+ s = domain.MirrorStatus{Repo: r.Name, State: domain.MirrorDisabled}
325+ }
326+ out = append(out, m.view(p, t, s, true))
273 }327 }
274 return out, nil328 return out, nil
275 }329 }
276 330
277-// Warning provides an administrative post-receive diagnostic.331+// Warning provides the post-receive diagnostic of a failing or paused mirror to the admins of
278-func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string {332+// the repository only: it names the remote and the reason.
333+func (m *Mirrors) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
334+ if !p.Allows(domain.ScopeRepoAdmin, repo) {
335+ return ""
336+ }
279 s, err := m.status(ctx, repo)337 s, err := m.status(ctx, repo)
280 if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) {338 if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) {
281 return ""339 return ""
@@ -289,45 +347,19 @@ func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string {
289 347
290 // Badge returns a viewer-safe state and optional administrative reason.348 // Badge returns a viewer-safe state and optional administrative reason.
291 func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) {349 func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) {
292- v, err := m.GetStatus(ctx, p, repo)350+ v, err := m.statusView(ctx, p, repo, false)
293 if err != nil {351 if err != nil {
294 return domain.MirrorDisabled, ""352 return domain.MirrorDisabled, ""
295 }353 }
296 return v.Status.State, v.Status.Reason354 return v.Status.State, v.Status.Reason
297 }355 }
298 356
299-// testURL substitutes only during validation/resolution; stored and pushed URLs remain intact.
300-func (m *Mirrors) testURL(url string) string {
301- if m.cfg.AllowAnyURL && strings.HasPrefix(url, "file://") {
302- return "git@mirror-test:" + strings.TrimPrefix(url, "file://")
303- }
304- return url
305-}
306 func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error {357 func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error {
307- g.URLTemplate = m.testURL(g.URLTemplate)358+ return domain.ValidateMirrorGlobalConfigWith(g, m.cfg.ValidateURL)
308- return domain.ValidateMirrorGlobalConfig(g)
309 }359 }
310 func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error {360 func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error {
311- c.URL = m.testURL(c.URL)361+ return domain.ValidateMirrorRepoConfigWith(c, m.cfg.ValidateURL)
312- return domain.ValidateMirrorRepoConfig(c)
313 }362 }
314 func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) {363 func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) {
315- originalGlobal := g.URLTemplate364+ return domain.ResolveMirrorWith(g, c, repo, m.cfg.ValidateURL)
316- originalRepo := ""
317- g.URLTemplate = m.testURL(g.URLTemplate)
318- if c != nil {
319- copyConfig := *c
320- originalRepo = c.URL
321- copyConfig.URL = m.testURL(c.URL)
322- c = &copyConfig
323- }
324- t, err := domain.ResolveMirror(g, c, repo)
325- if err == nil && t.Enabled && m.cfg.AllowAnyURL {
326- if originalRepo != "" {
327- t.URL = originalRepo
328- } else if strings.HasPrefix(originalGlobal, "file://") {
329- t.URL, err = domain.ExpandMirrorURL(originalGlobal, repo)
330- }
331- }
332- return t, err
333 }365 }
A internal/usecase/mirrors_config_test.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
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+
19+func 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+
58+func 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+
71+func 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+
102+func 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+
117+func 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+
131+func 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+
141+func 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+
169+func 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+
181+func 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+
191+func 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+
201+func 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+
210+func 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+
247+func 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+
270+func 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+}
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
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+
19+func 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+
58+func 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+
71+func 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+
102+func 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+
117+func 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+
131+func 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+
141+func 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+
169+func 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+
181+func 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+
191+func 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+
201+func 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+
210+func 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+
247+func 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+
270+func 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+}
A internal/usecase/mirrors_fixture_test.go +266 -0
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/ed25519"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// mirrorFixture is a Mirrors use case over in-memory state behind the mocks. Every mock
21+// answers from the fields below under mu, so tests change the world with the setters and
22+// never mutate a mock after construction.
23+type mirrorFixture struct {
24+ m *usecase.Mirrors
25+ push *mocks.MirrorPusher
26+ keys *mocks.MirrorKeyStore
27+ sealer *mocks.SecretSealer
28+ repo domain.RepoName
29+
30+ mu sync.Mutex
31+ at time.Time
32+ global domain.MirrorGlobalConfig
33+ repos []domain.Repo
34+ cfgs map[domain.RepoName]domain.MirrorRepoConfig
35+ status map[domain.RepoName]domain.MirrorStatus
36+ snaps map[domain.RepoName]domain.MirrorSnapshot
37+ refs []domain.Ref
38+ refsErr error
39+ branchErr error
40+ listRefs int
41+ statusPuts int
42+ events []domain.Event
43+ onPutRepo func() // runs inside PutRepoConfig, set before concurrency starts
44+ statusErr map[domain.RepoName]error
45+}
46+
47+func newMirrorFixture(t *testing.T) *mirrorFixture {
48+ return newMirrorFixtureWith(t, nil)
49+}
50+
51+func newMirrorFixtureWith(t *testing.T, tweak func(*usecase.MirrorsConfig)) *mirrorFixture {
52+ t.Helper()
53+ f := &mirrorFixture{
54+ push: mocks.NewMirrorPusher(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t),
55+ at: now, repo: name(t, "tools"),
56+ global: domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"},
57+ cfgs: map[domain.RepoName]domain.MirrorRepoConfig{}, status: map[domain.RepoName]domain.MirrorStatus{}, snaps: map[domain.RepoName]domain.MirrorSnapshot{}, statusErr: map[domain.RepoName]error{},
58+ refs: []domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}},
59+ }
60+ f.addRepo(f.repo)
61+ store, meta, git, reader, bus, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewRepoReader(t), mocks.NewEventBus(t), mocks.NewClock(t)
62+ clock.EXPECT().Now().RunAndReturn(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }).Maybe()
63+ meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.Repo, error) {
64+ f.mu.Lock()
65+ defer f.mu.Unlock()
66+ for _, r := range f.repos {
67+ if r.Name == n {
68+ return r, nil
69+ }
70+ }
71+ return domain.Repo{}, domain.ErrNotFound
72+ }).Maybe()
73+ meta.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Repo, error) {
74+ f.mu.Lock()
75+ defer f.mu.Unlock()
76+ return append([]domain.Repo(nil), f.repos...), nil
77+ }).Maybe()
78+ store.EXPECT().GetGlobal(mock.Anything).RunAndReturn(func(context.Context) (domain.MirrorGlobalConfig, error) {
79+ f.mu.Lock()
80+ defer f.mu.Unlock()
81+ return f.global, nil
82+ }).Maybe()
83+ store.EXPECT().PutGlobal(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, g domain.MirrorGlobalConfig) error {
84+ f.mu.Lock()
85+ defer f.mu.Unlock()
86+ f.global = g
87+ return nil
88+ }).Maybe()
89+ store.EXPECT().GetRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorRepoConfig, error) {
90+ f.mu.Lock()
91+ defer f.mu.Unlock()
92+ if c, ok := f.cfgs[n]; ok {
93+ return c, nil
94+ }
95+ return domain.MirrorRepoConfig{}, domain.ErrNotFound
96+ }).Maybe()
97+ store.EXPECT().PutRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, c domain.MirrorRepoConfig) error {
98+ if f.onPutRepo != nil {
99+ f.onPutRepo()
100+ }
101+ f.mu.Lock()
102+ defer f.mu.Unlock()
103+ f.cfgs[c.Repo] = c
104+ return nil
105+ }).Maybe()
106+ store.EXPECT().DeleteRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) error {
107+ f.mu.Lock()
108+ defer f.mu.Unlock()
109+ delete(f.cfgs, n)
110+ return nil
111+ }).Maybe()
112+ store.EXPECT().ListRepoConfigs(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorRepoConfig, error) {
113+ f.mu.Lock()
114+ defer f.mu.Unlock()
115+ var out []domain.MirrorRepoConfig
116+ for _, c := range f.cfgs {
117+ out = append(out, c)
118+ }
119+ return out, nil
120+ }).Maybe()
121+ store.EXPECT().GetStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorStatus, error) {
122+ f.mu.Lock()
123+ defer f.mu.Unlock()
124+ if s, ok := f.status[n]; ok {
125+ return s, nil
126+ }
127+ return domain.MirrorStatus{}, domain.ErrNotFound
128+ }).Maybe()
129+ store.EXPECT().PutStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, s domain.MirrorStatus) error {
130+ f.mu.Lock()
131+ defer f.mu.Unlock()
132+ if err := f.statusErr[s.Repo]; err != nil {
133+ return err
134+ }
135+ f.status[s.Repo] = s
136+ f.statusPuts++
137+ return nil
138+ }).Maybe()
139+ store.EXPECT().ListStatus(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorStatus, error) {
140+ f.mu.Lock()
141+ defer f.mu.Unlock()
142+ var out []domain.MirrorStatus
143+ for _, s := range f.status {
144+ out = append(out, s)
145+ }
146+ return out, nil
147+ }).Maybe()
148+ store.EXPECT().GetSnapshot(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorSnapshot, error) {
149+ f.mu.Lock()
150+ defer f.mu.Unlock()
151+ if s, ok := f.snaps[n]; ok {
152+ return s, nil
153+ }
154+ return domain.MirrorSnapshot{}, domain.ErrNotFound
155+ }).Maybe()
156+ store.EXPECT().PutSnapshot(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName, s domain.MirrorSnapshot) error {
157+ f.mu.Lock()
158+ defer f.mu.Unlock()
159+ f.snaps[n] = s
160+ return nil
161+ }).Maybe()
162+ reader.EXPECT().ListRefs(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.Ref, error) {
163+ f.mu.Lock()
164+ defer f.mu.Unlock()
165+ f.listRefs++
166+ return f.refs, f.refsErr
167+ }).Maybe()
168+ git.EXPECT().DefaultBranch(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) (string, bool, error) {
169+ f.mu.Lock()
170+ defer f.mu.Unlock()
171+ return "main", true, f.branchErr
172+ }).Maybe()
173+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
174+ f.mu.Lock()
175+ defer f.mu.Unlock()
176+ f.events = append(f.events, ev)
177+ }).Maybe()
178+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("sealed"), now, nil).Maybe()
179+ f.sealer.EXPECT().Open([]byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe()
180+ cfg := usecase.MirrorsConfig{Store: store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: reader, Bus: bus, Clock: clock, ScheduleTick: time.Hour}
181+ if tweak != nil {
182+ tweak(&cfg)
183+ }
184+ f.m = usecase.NewMirrors(cfg)
185+ f.m.Start()
186+ t.Cleanup(func() {
187+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
188+ defer cancel()
189+ f.m.Close(ctx)
190+ })
191+ return f
192+}
193+
194+// addRepo declares a public repository whose mirror starts OK.
195+func (f *mirrorFixture) addRepo(n domain.RepoName) {
196+ f.mu.Lock()
197+ defer f.mu.Unlock()
198+ f.repos = append(f.repos, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now})
199+ f.status[n] = domain.MirrorStatus{Repo: n, State: domain.MirrorOK}
200+}
201+
202+func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() }
203+func (f *mirrorFixture) setStatus(s domain.MirrorStatus) {
204+ f.mu.Lock()
205+ defer f.mu.Unlock()
206+ f.status[s.Repo] = s
207+}
208+func (f *mirrorFixture) setSnapshot(n domain.RepoName, s domain.MirrorSnapshot) {
209+ f.mu.Lock()
210+ defer f.mu.Unlock()
211+ f.snaps[n] = s
212+}
213+func (f *mirrorFixture) setRepoConfig(c domain.MirrorRepoConfig) {
214+ f.mu.Lock()
215+ defer f.mu.Unlock()
216+ f.cfgs[c.Repo] = c
217+}
218+func (f *mirrorFixture) setGlobal(g domain.MirrorGlobalConfig) {
219+ f.mu.Lock()
220+ defer f.mu.Unlock()
221+ f.global = g
222+}
223+func (f *mirrorFixture) failStatusWrites(n domain.RepoName, err error) {
224+ f.mu.Lock()
225+ defer f.mu.Unlock()
226+ f.statusErr[n] = err
227+}
228+func (f *mirrorFixture) snapshotOf(n domain.RepoName) (domain.MirrorSnapshot, bool) {
229+ f.mu.Lock()
230+ defer f.mu.Unlock()
231+ s, ok := f.snaps[n]
232+ return s, ok
233+}
234+func (f *mirrorFixture) statusOf(n domain.RepoName) domain.MirrorStatus {
235+ f.mu.Lock()
236+ defer f.mu.Unlock()
237+ return f.status[n]
238+}
239+func (f *mirrorFixture) eventList() []domain.Event {
240+ f.mu.Lock()
241+ defer f.mu.Unlock()
242+ return append([]domain.Event(nil), f.events...)
243+}
244+func (f *mirrorFixture) refReads() int { f.mu.Lock(); defer f.mu.Unlock(); return f.listRefs }
245+func (f *mirrorFixture) statusWrites() int {
246+ f.mu.Lock()
247+ defer f.mu.Unlock()
248+ return f.statusPuts
249+}
250+
251+// drain waits until no sync is queued or running: the deterministic barrier before asserting
252+// that something did not happen.
253+func (f *mirrorFixture) drain(t *testing.T) {
254+ t.Helper()
255+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
256+ defer cancel()
257+ f.m.Wait(ctx)
258+ require.NoError(t, ctx.Err(), "syncs did not finish")
259+}
260+
261+func mirrorOID(t *testing.T) domain.ObjectID {
262+ t.Helper()
263+ id, err := domain.ParseObjectID(oidA)
264+ require.NoError(t, err)
265+ return id
266+}
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/ed25519"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// mirrorFixture is a Mirrors use case over in-memory state behind the mocks. Every mock
21+// answers from the fields below under mu, so tests change the world with the setters and
22+// never mutate a mock after construction.
23+type mirrorFixture struct {
24+ m *usecase.Mirrors
25+ push *mocks.MirrorPusher
26+ keys *mocks.MirrorKeyStore
27+ sealer *mocks.SecretSealer
28+ repo domain.RepoName
29+
30+ mu sync.Mutex
31+ at time.Time
32+ global domain.MirrorGlobalConfig
33+ repos []domain.Repo
34+ cfgs map[domain.RepoName]domain.MirrorRepoConfig
35+ status map[domain.RepoName]domain.MirrorStatus
36+ snaps map[domain.RepoName]domain.MirrorSnapshot
37+ refs []domain.Ref
38+ refsErr error
39+ branchErr error
40+ listRefs int
41+ statusPuts int
42+ events []domain.Event
43+ onPutRepo func() // runs inside PutRepoConfig, set before concurrency starts
44+ statusErr map[domain.RepoName]error
45+}
46+
47+func newMirrorFixture(t *testing.T) *mirrorFixture {
48+ return newMirrorFixtureWith(t, nil)
49+}
50+
51+func newMirrorFixtureWith(t *testing.T, tweak func(*usecase.MirrorsConfig)) *mirrorFixture {
52+ t.Helper()
53+ f := &mirrorFixture{
54+ push: mocks.NewMirrorPusher(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t),
55+ at: now, repo: name(t, "tools"),
56+ global: domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"},
57+ cfgs: map[domain.RepoName]domain.MirrorRepoConfig{}, status: map[domain.RepoName]domain.MirrorStatus{}, snaps: map[domain.RepoName]domain.MirrorSnapshot{}, statusErr: map[domain.RepoName]error{},
58+ refs: []domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}},
59+ }
60+ f.addRepo(f.repo)
61+ store, meta, git, reader, bus, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewRepoReader(t), mocks.NewEventBus(t), mocks.NewClock(t)
62+ clock.EXPECT().Now().RunAndReturn(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }).Maybe()
63+ meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.Repo, error) {
64+ f.mu.Lock()
65+ defer f.mu.Unlock()
66+ for _, r := range f.repos {
67+ if r.Name == n {
68+ return r, nil
69+ }
70+ }
71+ return domain.Repo{}, domain.ErrNotFound
72+ }).Maybe()
73+ meta.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Repo, error) {
74+ f.mu.Lock()
75+ defer f.mu.Unlock()
76+ return append([]domain.Repo(nil), f.repos...), nil
77+ }).Maybe()
78+ store.EXPECT().GetGlobal(mock.Anything).RunAndReturn(func(context.Context) (domain.MirrorGlobalConfig, error) {
79+ f.mu.Lock()
80+ defer f.mu.Unlock()
81+ return f.global, nil
82+ }).Maybe()
83+ store.EXPECT().PutGlobal(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, g domain.MirrorGlobalConfig) error {
84+ f.mu.Lock()
85+ defer f.mu.Unlock()
86+ f.global = g
87+ return nil
88+ }).Maybe()
89+ store.EXPECT().GetRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorRepoConfig, error) {
90+ f.mu.Lock()
91+ defer f.mu.Unlock()
92+ if c, ok := f.cfgs[n]; ok {
93+ return c, nil
94+ }
95+ return domain.MirrorRepoConfig{}, domain.ErrNotFound
96+ }).Maybe()
97+ store.EXPECT().PutRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, c domain.MirrorRepoConfig) error {
98+ if f.onPutRepo != nil {
99+ f.onPutRepo()
100+ }
101+ f.mu.Lock()
102+ defer f.mu.Unlock()
103+ f.cfgs[c.Repo] = c
104+ return nil
105+ }).Maybe()
106+ store.EXPECT().DeleteRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) error {
107+ f.mu.Lock()
108+ defer f.mu.Unlock()
109+ delete(f.cfgs, n)
110+ return nil
111+ }).Maybe()
112+ store.EXPECT().ListRepoConfigs(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorRepoConfig, error) {
113+ f.mu.Lock()
114+ defer f.mu.Unlock()
115+ var out []domain.MirrorRepoConfig
116+ for _, c := range f.cfgs {
117+ out = append(out, c)
118+ }
119+ return out, nil
120+ }).Maybe()
121+ store.EXPECT().GetStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorStatus, error) {
122+ f.mu.Lock()
123+ defer f.mu.Unlock()
124+ if s, ok := f.status[n]; ok {
125+ return s, nil
126+ }
127+ return domain.MirrorStatus{}, domain.ErrNotFound
128+ }).Maybe()
129+ store.EXPECT().PutStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, s domain.MirrorStatus) error {
130+ f.mu.Lock()
131+ defer f.mu.Unlock()
132+ if err := f.statusErr[s.Repo]; err != nil {
133+ return err
134+ }
135+ f.status[s.Repo] = s
136+ f.statusPuts++
137+ return nil
138+ }).Maybe()
139+ store.EXPECT().ListStatus(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorStatus, error) {
140+ f.mu.Lock()
141+ defer f.mu.Unlock()
142+ var out []domain.MirrorStatus
143+ for _, s := range f.status {
144+ out = append(out, s)
145+ }
146+ return out, nil
147+ }).Maybe()
148+ store.EXPECT().GetSnapshot(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorSnapshot, error) {
149+ f.mu.Lock()
150+ defer f.mu.Unlock()
151+ if s, ok := f.snaps[n]; ok {
152+ return s, nil
153+ }
154+ return domain.MirrorSnapshot{}, domain.ErrNotFound
155+ }).Maybe()
156+ store.EXPECT().PutSnapshot(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName, s domain.MirrorSnapshot) error {
157+ f.mu.Lock()
158+ defer f.mu.Unlock()
159+ f.snaps[n] = s
160+ return nil
161+ }).Maybe()
162+ reader.EXPECT().ListRefs(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.Ref, error) {
163+ f.mu.Lock()
164+ defer f.mu.Unlock()
165+ f.listRefs++
166+ return f.refs, f.refsErr
167+ }).Maybe()
168+ git.EXPECT().DefaultBranch(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) (string, bool, error) {
169+ f.mu.Lock()
170+ defer f.mu.Unlock()
171+ return "main", true, f.branchErr
172+ }).Maybe()
173+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
174+ f.mu.Lock()
175+ defer f.mu.Unlock()
176+ f.events = append(f.events, ev)
177+ }).Maybe()
178+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("sealed"), now, nil).Maybe()
179+ f.sealer.EXPECT().Open([]byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe()
180+ cfg := usecase.MirrorsConfig{Store: store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: reader, Bus: bus, Clock: clock, ScheduleTick: time.Hour}
181+ if tweak != nil {
182+ tweak(&cfg)
183+ }
184+ f.m = usecase.NewMirrors(cfg)
185+ f.m.Start()
186+ t.Cleanup(func() {
187+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
188+ defer cancel()
189+ f.m.Close(ctx)
190+ })
191+ return f
192+}
193+
194+// addRepo declares a public repository whose mirror starts OK.
195+func (f *mirrorFixture) addRepo(n domain.RepoName) {
196+ f.mu.Lock()
197+ defer f.mu.Unlock()
198+ f.repos = append(f.repos, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now})
199+ f.status[n] = domain.MirrorStatus{Repo: n, State: domain.MirrorOK}
200+}
201+
202+func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() }
203+func (f *mirrorFixture) setStatus(s domain.MirrorStatus) {
204+ f.mu.Lock()
205+ defer f.mu.Unlock()
206+ f.status[s.Repo] = s
207+}
208+func (f *mirrorFixture) setSnapshot(n domain.RepoName, s domain.MirrorSnapshot) {
209+ f.mu.Lock()
210+ defer f.mu.Unlock()
211+ f.snaps[n] = s
212+}
213+func (f *mirrorFixture) setRepoConfig(c domain.MirrorRepoConfig) {
214+ f.mu.Lock()
215+ defer f.mu.Unlock()
216+ f.cfgs[c.Repo] = c
217+}
218+func (f *mirrorFixture) setGlobal(g domain.MirrorGlobalConfig) {
219+ f.mu.Lock()
220+ defer f.mu.Unlock()
221+ f.global = g
222+}
223+func (f *mirrorFixture) failStatusWrites(n domain.RepoName, err error) {
224+ f.mu.Lock()
225+ defer f.mu.Unlock()
226+ f.statusErr[n] = err
227+}
228+func (f *mirrorFixture) snapshotOf(n domain.RepoName) (domain.MirrorSnapshot, bool) {
229+ f.mu.Lock()
230+ defer f.mu.Unlock()
231+ s, ok := f.snaps[n]
232+ return s, ok
233+}
234+func (f *mirrorFixture) statusOf(n domain.RepoName) domain.MirrorStatus {
235+ f.mu.Lock()
236+ defer f.mu.Unlock()
237+ return f.status[n]
238+}
239+func (f *mirrorFixture) eventList() []domain.Event {
240+ f.mu.Lock()
241+ defer f.mu.Unlock()
242+ return append([]domain.Event(nil), f.events...)
243+}
244+func (f *mirrorFixture) refReads() int { f.mu.Lock(); defer f.mu.Unlock(); return f.listRefs }
245+func (f *mirrorFixture) statusWrites() int {
246+ f.mu.Lock()
247+ defer f.mu.Unlock()
248+ return f.statusPuts
249+}
250+
251+// drain waits until no sync is queued or running: the deterministic barrier before asserting
252+// that something did not happen.
253+func (f *mirrorFixture) drain(t *testing.T) {
254+ t.Helper()
255+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
256+ defer cancel()
257+ f.m.Wait(ctx)
258+ require.NoError(t, ctx.Err(), "syncs did not finish")
259+}
260+
261+func mirrorOID(t *testing.T) domain.ObjectID {
262+ t.Helper()
263+ id, err := domain.ParseObjectID(oidA)
264+ require.NoError(t, err)
265+ return id
266+}
M internal/usecase/mirrors_key.go +81 -35
@@ -11,73 +11,119 @@ import (
1111 "strings"
1212
1313 "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
1415 )
1516
17+const noSecretsKeyDetail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key"
18+
1619 // ShowKey lazily generates the server's mirror key for authenticated callers.
1720 func (m *Mirrors) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {
1821 if p.IsAnonymous() {
1922 return domain.MirrorPublicKey{}, domain.ErrUnauthenticated
2023 }
21- return m.publicKey(ctx, false)
24+ if m.cfg.Sealer == nil {
25+ return domain.MirrorPublicKey{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail)
26+ }
27+ m.keyMu.Lock()
28+ defer m.keyMu.Unlock()
29+ if err := m.loadKey(ctx, true); err != nil {
30+ return domain.MirrorPublicKey{}, err
31+ }
32+ return m.publicKeyLocked(), nil
2233 }
2334
24-// RotateKey immediately replaces the mirror key. Remote authorization must be updated separately.
25-func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {
35+// RotateKey immediately replaces the mirror key and returns the replaced one (zero when there
36+// was none, or when it could not be opened). Remote authorization must be updated separately.
37+func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) {
2638 if p.IsAnonymous() {
27- return domain.MirrorPublicKey{}, domain.ErrUnauthenticated
39+ return ports.MirrorKeyRotation{}, domain.ErrUnauthenticated
2840 }
2941 if !p.HasOwnerRights() {
30- return domain.MirrorPublicKey{}, domain.ErrForbidden
42+ return ports.MirrorKeyRotation{}, domain.ErrForbidden
43+ }
44+ if m.cfg.Sealer == nil {
45+ return ports.MirrorKeyRotation{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail)
3146 }
32- return m.publicKey(ctx, true)
33-}
34-
35-func (m *Mirrors) publicKey(ctx context.Context, rotate bool) (domain.MirrorPublicKey, error) {
3647 m.keyMu.Lock()
3748 defer m.keyMu.Unlock()
38- if err := m.loadKey(ctx, rotate); err != nil {
39- return domain.MirrorPublicKey{}, err
49+ var previous domain.MirrorPublicKey
50+ // A missing key is not created just to be replaced, and a stored key that cannot be read
51+ // or opened must not prevent its replacement.
52+ if err := m.loadKey(ctx, false); err == nil {
53+ previous = m.publicKeyLocked()
54+ }
55+ if err := m.storeNewKey(ctx, false); err != nil {
56+ return ports.MirrorKeyRotation{}, err
4057 }
58+ return ports.MirrorKeyRotation{Key: m.publicKeyLocked(), Previous: previous}, nil
59+}
60+
61+func (m *Mirrors) publicKeyLocked() domain.MirrorPublicKey {
4162 host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@")
42- return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated), nil
63+ return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated)
4364 }
4465
45-// loadKey is called with keyMu held. Only successfully persisted material is cached.
46-func (m *Mirrors) loadKey(ctx context.Context, rotate bool) error {
66+// privateKey returns a copy so a transport cannot mutate the cached key. Failures are
67+// *domain.MirrorError of class internal.
68+func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) {
4769 if m.cfg.Sealer == nil {
48- return fmt.Errorf("%w: the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key", domain.ErrPreconditionFailed)
70+ return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: noSecretsKeyDetail}
4971 }
50- if !rotate && m.key != nil {
72+ m.keyMu.Lock()
73+ defer m.keyMu.Unlock()
74+ if err := m.loadKey(ctx, true); err != nil {
75+ detail := "cannot read the mirror key"
76+ if errors.Is(err, domain.ErrPreconditionFailed) {
77+ detail = err.Error()
78+ }
79+ return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
80+ }
81+ return append(ed25519.PrivateKey(nil), m.key...), nil
82+}
83+
84+// loadKey is called with keyMu held and a sealer present: it caches the stored key, creating
85+// one when none exists and create is set (ErrNotFound otherwise).
86+func (m *Mirrors) loadKey(ctx context.Context, create bool) error {
87+ if m.key != nil {
5188 return nil
5289 }
53- if !rotate {
54- sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx)
55- if err == nil {
56- seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath))
57- if err != nil {
58- return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed)
59- }
60- if len(seed) != ed25519.SeedSize {
61- return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed)
62- }
63- m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created
64- return nil
90+ sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx)
91+ if err == nil {
92+ seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath))
93+ if err != nil {
94+ return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed)
6595 }
66- if !errors.Is(err, domain.ErrNotFound) {
67- return fmt.Errorf("read mirror key: %w", err)
96+ if len(seed) != ed25519.SeedSize {
97+ return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed)
6898 }
99+ m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created
100+ return nil
101+ }
102+ if !errors.Is(err, domain.ErrNotFound) {
103+ return fmt.Errorf("read mirror key: %w", err)
69104 }
105+ if !create {
106+ return err
107+ }
108+ err = m.storeNewKey(ctx, true)
109+ if errors.Is(err, domain.ErrAlreadyExists) {
110+ return m.loadKey(ctx, true) // another instance created it first
111+ }
112+ return err
113+}
114+
115+// storeNewKey generates, seals and persists a key, caching it only once persisted.
116+func (m *Mirrors) storeNewKey(ctx context.Context, ifAbsent bool) error {
70117 _, key, err := ed25519.GenerateKey(rand.Reader)
71118 if err != nil {
72119 return fmt.Errorf("generate mirror key: %w", err)
73120 }
74121 created := m.cfg.Clock.Now().UTC()
75122 sealed := m.cfg.Sealer.Seal(key.Seed(), []byte(domain.MirrorKeyPath))
76- err = m.cfg.Keys.PutMirrorKey(ctx, sealed, created, !rotate)
77- if !rotate && errors.Is(err, domain.ErrAlreadyExists) {
78- return m.loadKey(ctx, false)
79- }
80- if err != nil {
123+ if err := m.cfg.Keys.PutMirrorKey(ctx, sealed, created, ifAbsent); err != nil {
124+ if errors.Is(err, domain.ErrAlreadyExists) {
125+ return err
126+ }
81127 return fmt.Errorf("store mirror key: %w", err)
82128 }
83129 m.key, m.keyCreated = key, created
@@ -11,73 +11,119 @@ import (
11 "strings"11 "strings"
12 12
13 "bnema.dev/ermite/internal/domain"13 "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
14 )15 )
15 16
17+const noSecretsKeyDetail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key"
18+
16 // ShowKey lazily generates the server's mirror key for authenticated callers.19 // ShowKey lazily generates the server's mirror key for authenticated callers.
17 func (m *Mirrors) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {20 func (m *Mirrors) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {
18 if p.IsAnonymous() {21 if p.IsAnonymous() {
19 return domain.MirrorPublicKey{}, domain.ErrUnauthenticated22 return domain.MirrorPublicKey{}, domain.ErrUnauthenticated
20 }23 }
21- return m.publicKey(ctx, false)24+ if m.cfg.Sealer == nil {
25+ return domain.MirrorPublicKey{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail)
26+ }
27+ m.keyMu.Lock()
28+ defer m.keyMu.Unlock()
29+ if err := m.loadKey(ctx, true); err != nil {
30+ return domain.MirrorPublicKey{}, err
31+ }
32+ return m.publicKeyLocked(), nil
22 }33 }
23 34
24-// RotateKey immediately replaces the mirror key. Remote authorization must be updated separately.35+// RotateKey immediately replaces the mirror key and returns the replaced one (zero when there
25-func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {36+// was none, or when it could not be opened). Remote authorization must be updated separately.
37+func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) {
26 if p.IsAnonymous() {38 if p.IsAnonymous() {
27- return domain.MirrorPublicKey{}, domain.ErrUnauthenticated39+ return ports.MirrorKeyRotation{}, domain.ErrUnauthenticated
28 }40 }
29 if !p.HasOwnerRights() {41 if !p.HasOwnerRights() {
30- return domain.MirrorPublicKey{}, domain.ErrForbidden42+ return ports.MirrorKeyRotation{}, domain.ErrForbidden
43+ }
44+ if m.cfg.Sealer == nil {
45+ return ports.MirrorKeyRotation{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail)
31 }46 }
32- return m.publicKey(ctx, true)
33-}
34-
35-func (m *Mirrors) publicKey(ctx context.Context, rotate bool) (domain.MirrorPublicKey, error) {
36 m.keyMu.Lock()47 m.keyMu.Lock()
37 defer m.keyMu.Unlock()48 defer m.keyMu.Unlock()
38- if err := m.loadKey(ctx, rotate); err != nil {49+ var previous domain.MirrorPublicKey
39- return domain.MirrorPublicKey{}, err50+ // A missing key is not created just to be replaced, and a stored key that cannot be read
51+ // or opened must not prevent its replacement.
52+ if err := m.loadKey(ctx, false); err == nil {
53+ previous = m.publicKeyLocked()
54+ }
55+ if err := m.storeNewKey(ctx, false); err != nil {
56+ return ports.MirrorKeyRotation{}, err
40 }57 }
58+ return ports.MirrorKeyRotation{Key: m.publicKeyLocked(), Previous: previous}, nil
59+}
60+
61+func (m *Mirrors) publicKeyLocked() domain.MirrorPublicKey {
41 host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@")62 host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@")
42- return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated), nil63+ return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated)
43 }64 }
44 65
45-// loadKey is called with keyMu held. Only successfully persisted material is cached.66+// privateKey returns a copy so a transport cannot mutate the cached key. Failures are
46-func (m *Mirrors) loadKey(ctx context.Context, rotate bool) error {67+// *domain.MirrorError of class internal.
68+func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) {
47 if m.cfg.Sealer == nil {69 if m.cfg.Sealer == nil {
48- return fmt.Errorf("%w: the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key", domain.ErrPreconditionFailed)70+ return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: noSecretsKeyDetail}
49 }71 }
50- if !rotate && m.key != nil {72+ m.keyMu.Lock()
73+ defer m.keyMu.Unlock()
74+ if err := m.loadKey(ctx, true); err != nil {
75+ detail := "cannot read the mirror key"
76+ if errors.Is(err, domain.ErrPreconditionFailed) {
77+ detail = err.Error()
78+ }
79+ return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
80+ }
81+ return append(ed25519.PrivateKey(nil), m.key...), nil
82+}
83+
84+// loadKey is called with keyMu held and a sealer present: it caches the stored key, creating
85+// one when none exists and create is set (ErrNotFound otherwise).
86+func (m *Mirrors) loadKey(ctx context.Context, create bool) error {
87+ if m.key != nil {
51 return nil88 return nil
52 }89 }
53- if !rotate {90+ sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx)
54- sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx)91+ if err == nil {
55- if err == nil {92+ seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath))
56- seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath))93+ if err != nil {
57- if err != nil {94+ return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed)
58- return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed)
59- }
60- if len(seed) != ed25519.SeedSize {
61- return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed)
62- }
63- m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created
64- return nil
65 }95 }
66- if !errors.Is(err, domain.ErrNotFound) {96+ if len(seed) != ed25519.SeedSize {
67- return fmt.Errorf("read mirror key: %w", err)97+ return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed)
68 }98 }
99+ m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created
100+ return nil
101+ }
102+ if !errors.Is(err, domain.ErrNotFound) {
103+ return fmt.Errorf("read mirror key: %w", err)
69 }104 }
105+ if !create {
106+ return err
107+ }
108+ err = m.storeNewKey(ctx, true)
109+ if errors.Is(err, domain.ErrAlreadyExists) {
110+ return m.loadKey(ctx, true) // another instance created it first
111+ }
112+ return err
113+}
114+
115+// storeNewKey generates, seals and persists a key, caching it only once persisted.
116+func (m *Mirrors) storeNewKey(ctx context.Context, ifAbsent bool) error {
70 _, key, err := ed25519.GenerateKey(rand.Reader)117 _, key, err := ed25519.GenerateKey(rand.Reader)
71 if err != nil {118 if err != nil {
72 return fmt.Errorf("generate mirror key: %w", err)119 return fmt.Errorf("generate mirror key: %w", err)
73 }120 }
74 created := m.cfg.Clock.Now().UTC()121 created := m.cfg.Clock.Now().UTC()
75 sealed := m.cfg.Sealer.Seal(key.Seed(), []byte(domain.MirrorKeyPath))122 sealed := m.cfg.Sealer.Seal(key.Seed(), []byte(domain.MirrorKeyPath))
76- err = m.cfg.Keys.PutMirrorKey(ctx, sealed, created, !rotate)123+ if err := m.cfg.Keys.PutMirrorKey(ctx, sealed, created, ifAbsent); err != nil {
77- if !rotate && errors.Is(err, domain.ErrAlreadyExists) {124+ if errors.Is(err, domain.ErrAlreadyExists) {
78- return m.loadKey(ctx, false)125+ return err
79- }126+ }
80- if err != nil {
81 return fmt.Errorf("store mirror key: %w", err)127 return fmt.Errorf("store mirror key: %w", err)
82 }128 }
83 m.key, m.keyCreated = key, created129 m.key, m.keyCreated = key, created
A internal/usecase/mirrors_key_fixture_test.go +52 -0
@@ -0,0 +1,52 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "errors"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+// keyFixture is a Mirrors with only the key store, a sealing fake and a clock.
18+type keyFixture struct {
19+ m *usecase.Mirrors
20+ keys *mocks.MirrorKeyStore
21+ sealerOpenFails bool
22+ mu sync.Mutex
23+ sealed [][]byte
24+}
25+
26+func newKeyFixture(t *testing.T) *keyFixture {
27+ t.Helper()
28+ f := &keyFixture{keys: mocks.NewMirrorKeyStore(t)}
29+ sealer, clock := mocks.NewSecretSealer(t), mocks.NewClock(t)
30+ clock.EXPECT().Now().Return(now).Maybe()
31+ sealer.EXPECT().Seal(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(seed, _ []byte) []byte {
32+ f.mu.Lock()
33+ defer f.mu.Unlock()
34+ f.sealed = append(f.sealed, append([]byte(nil), seed...))
35+ return append([]byte("sealed:"), seed...)
36+ }).Maybe()
37+ sealer.EXPECT().Open(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(b, _ []byte) ([]byte, error) {
38+ if f.sealerOpenFails {
39+ return nil, errors.New("cannot open")
40+ }
41+ return b[len("sealed:"):], nil
42+ }).Maybe()
43+ f.m = usecase.NewMirrors(usecase.MirrorsConfig{Keys: f.keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"})
44+ return f
45+}
46+
47+// seeds are the seeds generated so far.
48+func (f *keyFixture) seeds() [][]byte {
49+ f.mu.Lock()
50+ defer f.mu.Unlock()
51+ return append([][]byte(nil), f.sealed...)
52+}
@@ -0,0 +1,52 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "errors"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+// keyFixture is a Mirrors with only the key store, a sealing fake and a clock.
18+type keyFixture struct {
19+ m *usecase.Mirrors
20+ keys *mocks.MirrorKeyStore
21+ sealerOpenFails bool
22+ mu sync.Mutex
23+ sealed [][]byte
24+}
25+
26+func newKeyFixture(t *testing.T) *keyFixture {
27+ t.Helper()
28+ f := &keyFixture{keys: mocks.NewMirrorKeyStore(t)}
29+ sealer, clock := mocks.NewSecretSealer(t), mocks.NewClock(t)
30+ clock.EXPECT().Now().Return(now).Maybe()
31+ sealer.EXPECT().Seal(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(seed, _ []byte) []byte {
32+ f.mu.Lock()
33+ defer f.mu.Unlock()
34+ f.sealed = append(f.sealed, append([]byte(nil), seed...))
35+ return append([]byte("sealed:"), seed...)
36+ }).Maybe()
37+ sealer.EXPECT().Open(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(b, _ []byte) ([]byte, error) {
38+ if f.sealerOpenFails {
39+ return nil, errors.New("cannot open")
40+ }
41+ return b[len("sealed:"):], nil
42+ }).Maybe()
43+ f.m = usecase.NewMirrors(usecase.MirrorsConfig{Keys: f.keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"})
44+ return f
45+}
46+
47+// seeds are the seeds generated so far.
48+func (f *keyFixture) seeds() [][]byte {
49+ f.mu.Lock()
50+ defer f.mu.Unlock()
51+ return append([][]byte(nil), f.sealed...)
52+}
A internal/usecase/mirrors_notify_test.go +112 -0
@@ -0,0 +1,112 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "testing/synctest"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestMirrorsPushRecordNotification(t *testing.T) {
20+ f := newPushFixture(t)
21+ n := name(t, "tools")
22+ trigger := mocks.NewMirrorTrigger(t)
23+ updates := []domain.RefUpdate{}
24+ f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil)
25+ f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil)
26+ f.bus.On("Publish", mock.Anything, mock.Anything).Return()
27+ f.sched.On("Schedule", mock.Anything, n).Return()
28+ trigger.EXPECT().OnPush(mock.Anything, n, updates).Return().Once() // synchronous: verified when Record returns
29+ require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates))
30+}
31+
32+func TestMirrorsMergeNotifiesMirror(t *testing.T) {
33+ r := newMRRig(t)
34+ r.seed()
35+ trigger := mocks.NewMirrorTrigger(t)
36+ notified := make(chan []domain.RefUpdate, 1)
37+ trigger.EXPECT().OnPush(mock.Anything, r.repo, mock.Anything).Run(func(_ context.Context, _ domain.RepoName, u []domain.RefUpdate) { notified <- u }).Return().Once()
38+ r.mrs.WithMirror(trigger)
39+ head := r.feature("f0")
40+ mr := r.open("f0")
41+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head})
42+ require.NoError(t, err)
43+ r.mrs.Wait(r.ctx)
44+ select {
45+ case u := <-notified:
46+ require.Equal(t, "refs/heads/main", u[0].Ref)
47+ case <-time.After(time.Second):
48+ t.Fatal("merge did not notify mirrors")
49+ }
50+}
51+
52+func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) {
53+ f := newFixture(t)
54+ ctx := context.Background()
55+ n := name(t, "tools")
56+ repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
57+ trigger := mocks.NewMirrorTrigger(t)
58+ trigger.EXPECT().OnPush(mock.Anything, n, mock.Anything).Return().Once()
59+ f.repos.WithMirror(trigger)
60+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
61+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
62+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
63+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
64+ _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
65+ require.NoError(t, err)
66+}
67+
68+func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) {
69+ f := newMirrorFixture(t)
70+ f.pushes(t, nil)
71+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
72+ require.NoError(t, err)
73+ f.advance(5 * time.Minute)
74+ f.m.ScheduleNow(context.Background())
75+ f.drain(t)
76+ require.Equal(t, 1, f.refReads(), "due and clean: skipped")
77+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
78+ f.advance(5 * time.Minute)
79+ f.m.ScheduleNow(context.Background())
80+ f.drain(t)
81+ require.Equal(t, 2, f.refReads(), "dirty repository synced on the next due tick")
82+}
83+
84+func TestMirrorsEventsAreDeliveredByVisibility(t *testing.T) {
85+ synctest.Test(t, func(t *testing.T) {
86+ r := newEventRig(t, 0)
87+ pub := r.repo("pub", domain.VisibilityPublic)
88+ priv := r.repo("priv", domain.VisibilityPrivate)
89+ ctx, cancel := context.WithCancel(context.Background())
90+ defer cancel()
91+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
92+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
93+ synctest.Wait()
94+ for _, n := range []domain.RepoName{priv, pub} {
95+ vis := domain.VisibilityPublic
96+ if n == priv {
97+ vis = domain.VisibilityPrivate
98+ }
99+ r.feed <- domain.MirrorFailed{Repo: n, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now, Visibility: vis, CreatedAt: now}
100+ r.feed <- domain.MirrorRecovered{Repo: n, At: now, Visibility: vis, CreatedAt: now}
101+ }
102+ for _, n := range []domain.RepoName{priv, pub} {
103+ failed := root.next(t).Event.(domain.MirrorFailed)
104+ require.Equal(t, n, failed.Repo)
105+ require.Equal(t, n, root.next(t).Event.(domain.MirrorRecovered).Repo)
106+ }
107+ failed := anon.next(t).Event.(domain.MirrorFailed)
108+ require.Equal(t, pub, failed.Repo, "a private repository's mirror events stay private")
109+ require.Equal(t, pub, anon.next(t).Event.(domain.MirrorRecovered).Repo)
110+ anon.quiet(t, r, pub)
111+ })
112+}
@@ -0,0 +1,112 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "testing/synctest"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestMirrorsPushRecordNotification(t *testing.T) {
20+ f := newPushFixture(t)
21+ n := name(t, "tools")
22+ trigger := mocks.NewMirrorTrigger(t)
23+ updates := []domain.RefUpdate{}
24+ f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil)
25+ f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil)
26+ f.bus.On("Publish", mock.Anything, mock.Anything).Return()
27+ f.sched.On("Schedule", mock.Anything, n).Return()
28+ trigger.EXPECT().OnPush(mock.Anything, n, updates).Return().Once() // synchronous: verified when Record returns
29+ require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates))
30+}
31+
32+func TestMirrorsMergeNotifiesMirror(t *testing.T) {
33+ r := newMRRig(t)
34+ r.seed()
35+ trigger := mocks.NewMirrorTrigger(t)
36+ notified := make(chan []domain.RefUpdate, 1)
37+ trigger.EXPECT().OnPush(mock.Anything, r.repo, mock.Anything).Run(func(_ context.Context, _ domain.RepoName, u []domain.RefUpdate) { notified <- u }).Return().Once()
38+ r.mrs.WithMirror(trigger)
39+ head := r.feature("f0")
40+ mr := r.open("f0")
41+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head})
42+ require.NoError(t, err)
43+ r.mrs.Wait(r.ctx)
44+ select {
45+ case u := <-notified:
46+ require.Equal(t, "refs/heads/main", u[0].Ref)
47+ case <-time.After(time.Second):
48+ t.Fatal("merge did not notify mirrors")
49+ }
50+}
51+
52+func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) {
53+ f := newFixture(t)
54+ ctx := context.Background()
55+ n := name(t, "tools")
56+ repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
57+ trigger := mocks.NewMirrorTrigger(t)
58+ trigger.EXPECT().OnPush(mock.Anything, n, mock.Anything).Return().Once()
59+ f.repos.WithMirror(trigger)
60+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
61+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
62+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
63+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
64+ _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
65+ require.NoError(t, err)
66+}
67+
68+func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) {
69+ f := newMirrorFixture(t)
70+ f.pushes(t, nil)
71+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
72+ require.NoError(t, err)
73+ f.advance(5 * time.Minute)
74+ f.m.ScheduleNow(context.Background())
75+ f.drain(t)
76+ require.Equal(t, 1, f.refReads(), "due and clean: skipped")
77+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
78+ f.advance(5 * time.Minute)
79+ f.m.ScheduleNow(context.Background())
80+ f.drain(t)
81+ require.Equal(t, 2, f.refReads(), "dirty repository synced on the next due tick")
82+}
83+
84+func TestMirrorsEventsAreDeliveredByVisibility(t *testing.T) {
85+ synctest.Test(t, func(t *testing.T) {
86+ r := newEventRig(t, 0)
87+ pub := r.repo("pub", domain.VisibilityPublic)
88+ priv := r.repo("priv", domain.VisibilityPrivate)
89+ ctx, cancel := context.WithCancel(context.Background())
90+ defer cancel()
91+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
92+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
93+ synctest.Wait()
94+ for _, n := range []domain.RepoName{priv, pub} {
95+ vis := domain.VisibilityPublic
96+ if n == priv {
97+ vis = domain.VisibilityPrivate
98+ }
99+ r.feed <- domain.MirrorFailed{Repo: n, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now, Visibility: vis, CreatedAt: now}
100+ r.feed <- domain.MirrorRecovered{Repo: n, At: now, Visibility: vis, CreatedAt: now}
101+ }
102+ for _, n := range []domain.RepoName{priv, pub} {
103+ failed := root.next(t).Event.(domain.MirrorFailed)
104+ require.Equal(t, n, failed.Repo)
105+ require.Equal(t, n, root.next(t).Event.(domain.MirrorRecovered).Repo)
106+ }
107+ failed := anon.next(t).Event.(domain.MirrorFailed)
108+ require.Equal(t, pub, failed.Repo, "a private repository's mirror events stay private")
109+ require.Equal(t, pub, anon.next(t).Event.(domain.MirrorRecovered).Repo)
110+ anon.quiet(t, r, pub)
111+ })
112+}
M internal/usecase/mirrors_schedule.go +38 -19
@@ -4,6 +4,7 @@ package usecase
44
55 import (
66 "context"
7+ "errors"
78 "time"
89
910 "bnema.dev/ermite/internal/domain"
@@ -67,31 +68,44 @@ func (m *Mirrors) OnPush(_ context.Context, repo domain.RepoName, _ []domain.Ref
6768 m.mu.Lock()
6869 defer m.mu.Unlock()
6970 if !m.closed {
70- m.dirty[repo]++
71+ g := m.gens[repo]
72+ g.dirty++
73+ m.gens[repo] = g
7174 }
7275 }
7376
74-// ScheduleNow examines one current minute; missed minutes are not replayed.
77+// ScheduleNow examines the current minute; missed minutes are not replayed. A repository that
78+// was due but found the queue full is kept and retried on the next calls, so a full queue
79+// delays a sync instead of skipping a period. Workers must be running (Start).
7580 func (m *Mirrors) ScheduleNow(ctx context.Context) {
76- m.Start()
7781 m.scheduleMu.Lock()
7882 defer m.scheduleMu.Unlock()
7983 now := m.cfg.Clock.Now().UTC()
8084 minute := now.Truncate(time.Minute)
81- if !minute.After(m.lastMinute) {
85+ newMinute := minute.After(m.lastMinute)
86+ if !newMinute && len(m.deferred) == 0 {
8287 return
8388 }
84- m.lastMinute = minute
89+ log := zerowrap.FromCtx(ctx)
8590 repos, err := m.cfg.Meta.List(ctx)
8691 if err != nil {
92+ log.Warn().Err(err).Msg("mirror schedule: cannot list repositories")
8793 return
8894 }
95+ if newMinute {
96+ m.lastMinute = minute
97+ }
8998 live := make(map[domain.RepoName]bool, len(repos))
9099 for _, r := range repos {
91100 if ctx.Err() != nil {
92101 return
93102 }
94103 live[r.Name] = true
104+ _, wasDeferred := m.deferred[r.Name]
105+ if !newMinute && !wasDeferred {
106+ continue
107+ }
108+ delete(m.deferred, r.Name)
95109 t, err := m.target(ctx, r.Name)
96110 if err != nil || !t.Enabled {
97111 continue
@@ -100,35 +114,40 @@ func (m *Mirrors) ScheduleNow(ctx context.Context) {
100114 if err != nil || s.State == domain.MirrorPaused {
101115 continue
102116 }
103- due := false
104- if s.State == domain.MirrorFailing {
105- due = !now.Before(s.NextRetryAt)
106- } else {
107- due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute)
117+ due := wasDeferred
118+ if !due {
119+ if s.State == domain.MirrorFailing {
120+ due = !now.Before(s.NextRetryAt)
121+ } else {
122+ due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute)
123+ }
108124 }
109125 if !due {
110126 continue
111127 }
112128 m.mu.Lock()
113- clean, known := m.clean[r.Name]
114- dirty := m.dirty[r.Name]
129+ g := m.gens[r.Name]
115130 m.mu.Unlock()
116- if s.State == domain.MirrorOK && known && clean == dirty {
131+ if s.State == domain.MirrorOK && g.cleanKnown && g.clean == g.dirty {
117132 snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name)
118133 if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches {
119134 continue
120135 }
121136 }
122- if _, _, err := m.enqueue(r.Name, false, false); err != nil {
123- log := zerowrap.FromCtx(ctx)
124- log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full or closed")
137+ if _, _, err := m.enqueue(r.Name, mirrorReq{}); errors.Is(err, domain.ErrResourceExhausted) {
138+ m.deferred[r.Name] = struct{}{}
139+ log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full: sync deferred")
140+ }
141+ }
142+ for repo := range m.deferred {
143+ if !live[repo] {
144+ delete(m.deferred, repo)
125145 }
126146 }
127147 m.mu.Lock()
128- for repo := range m.dirty {
148+ for repo := range m.gens {
129149 if !live[repo] {
130- delete(m.dirty, repo)
131- delete(m.clean, repo)
150+ delete(m.gens, repo)
132151 }
133152 }
134153 m.mu.Unlock()
@@ -4,6 +4,7 @@ package usecase
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "time"8 "time"
8 9
9 "bnema.dev/ermite/internal/domain"10 "bnema.dev/ermite/internal/domain"
@@ -67,31 +68,44 @@ func (m *Mirrors) OnPush(_ context.Context, repo domain.RepoName, _ []domain.Ref
67 m.mu.Lock()68 m.mu.Lock()
68 defer m.mu.Unlock()69 defer m.mu.Unlock()
69 if !m.closed {70 if !m.closed {
70- m.dirty[repo]++71+ g := m.gens[repo]
72+ g.dirty++
73+ m.gens[repo] = g
71 }74 }
72 }75 }
73 76
74-// ScheduleNow examines one current minute; missed minutes are not replayed.77+// ScheduleNow examines the current minute; missed minutes are not replayed. A repository that
78+// was due but found the queue full is kept and retried on the next calls, so a full queue
79+// delays a sync instead of skipping a period. Workers must be running (Start).
75 func (m *Mirrors) ScheduleNow(ctx context.Context) {80 func (m *Mirrors) ScheduleNow(ctx context.Context) {
76- m.Start()
77 m.scheduleMu.Lock()81 m.scheduleMu.Lock()
78 defer m.scheduleMu.Unlock()82 defer m.scheduleMu.Unlock()
79 now := m.cfg.Clock.Now().UTC()83 now := m.cfg.Clock.Now().UTC()
80 minute := now.Truncate(time.Minute)84 minute := now.Truncate(time.Minute)
81- if !minute.After(m.lastMinute) {85+ newMinute := minute.After(m.lastMinute)
86+ if !newMinute && len(m.deferred) == 0 {
82 return87 return
83 }88 }
84- m.lastMinute = minute89+ log := zerowrap.FromCtx(ctx)
85 repos, err := m.cfg.Meta.List(ctx)90 repos, err := m.cfg.Meta.List(ctx)
86 if err != nil {91 if err != nil {
92+ log.Warn().Err(err).Msg("mirror schedule: cannot list repositories")
87 return93 return
88 }94 }
95+ if newMinute {
96+ m.lastMinute = minute
97+ }
89 live := make(map[domain.RepoName]bool, len(repos))98 live := make(map[domain.RepoName]bool, len(repos))
90 for _, r := range repos {99 for _, r := range repos {
91 if ctx.Err() != nil {100 if ctx.Err() != nil {
92 return101 return
93 }102 }
94 live[r.Name] = true103 live[r.Name] = true
104+ _, wasDeferred := m.deferred[r.Name]
105+ if !newMinute && !wasDeferred {
106+ continue
107+ }
108+ delete(m.deferred, r.Name)
95 t, err := m.target(ctx, r.Name)109 t, err := m.target(ctx, r.Name)
96 if err != nil || !t.Enabled {110 if err != nil || !t.Enabled {
97 continue111 continue
@@ -100,35 +114,40 @@ func (m *Mirrors) ScheduleNow(ctx context.Context) {
100 if err != nil || s.State == domain.MirrorPaused {114 if err != nil || s.State == domain.MirrorPaused {
101 continue115 continue
102 }116 }
103- due := false117+ due := wasDeferred
104- if s.State == domain.MirrorFailing {118+ if !due {
105- due = !now.Before(s.NextRetryAt)119+ if s.State == domain.MirrorFailing {
106- } else {120+ due = !now.Before(s.NextRetryAt)
107- due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute)121+ } else {
122+ due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute)
123+ }
108 }124 }
109 if !due {125 if !due {
110 continue126 continue
111 }127 }
112 m.mu.Lock()128 m.mu.Lock()
113- clean, known := m.clean[r.Name]129+ g := m.gens[r.Name]
114- dirty := m.dirty[r.Name]
115 m.mu.Unlock()130 m.mu.Unlock()
116- if s.State == domain.MirrorOK && known && clean == dirty {131+ if s.State == domain.MirrorOK && g.cleanKnown && g.clean == g.dirty {
117 snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name)132 snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name)
118 if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches {133 if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches {
119 continue134 continue
120 }135 }
121 }136 }
122- if _, _, err := m.enqueue(r.Name, false, false); err != nil {137+ if _, _, err := m.enqueue(r.Name, mirrorReq{}); errors.Is(err, domain.ErrResourceExhausted) {
123- log := zerowrap.FromCtx(ctx)138+ m.deferred[r.Name] = struct{}{}
124- log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full or closed")139+ log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full: sync deferred")
140+ }
141+ }
142+ for repo := range m.deferred {
143+ if !live[repo] {
144+ delete(m.deferred, repo)
125 }145 }
126 }146 }
127 m.mu.Lock()147 m.mu.Lock()
128- for repo := range m.dirty {148+ for repo := range m.gens {
129 if !live[repo] {149 if !live[repo] {
130- delete(m.dirty, repo)150+ delete(m.gens, repo)
131- delete(m.clean, repo)
132 }151 }
133 }152 }
134 m.mu.Unlock()153 m.mu.Unlock()
M internal/usecase/mirrors_sync.go +141 -69
@@ -6,19 +6,28 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "time"
910
1011 "bnema.dev/ermite/internal/domain"
1112 "bnema.dev/ermite/internal/ports"
1213 )
1314
15+// mirrorReq is what a sync request asks for. Requests that coalesce merge by OR.
16+type mirrorReq struct{ force, manual bool }
17+
18+func (r mirrorReq) merge(o mirrorReq) mirrorReq {
19+ return mirrorReq{force: r.force || o.force, manual: r.manual || o.manual}
20+}
21+
22+// mirrorFlight is the single in-flight sync of a repository: cur is running, next is the
23+// merged request that arrived meanwhile (one rerun, whatever the number of requests).
1424 type mirrorFlight struct {
15- pending, force, manual bool
16- pendingForce bool
17- pendingManual bool
18- done chan struct{}
19- completed bool
20- result ports.MirrorSyncResult
21- err error
25+ cur mirrorReq
26+ next *mirrorReq
27+ done chan struct{} // closed with the result of cur's first run
28+ completed bool
29+ result ports.MirrorSyncResult
30+ err error
2231 }
2332
2433 var (
@@ -33,12 +42,12 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo
3342 return ports.MirrorSyncResult{}, err
3443 }
3544 m.Start()
36- f, queued, err := m.enqueue(repo, force, true)
45+ f, queued, err := m.enqueue(repo, mirrorReq{force: force, manual: true})
3746 if err != nil {
3847 return ports.MirrorSyncResult{}, err
3948 }
4049 if queued {
41- return ports.MirrorSyncResult{Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil
50+ return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil
4251 }
4352 select {
4453 case <-m.ctx.Done():
@@ -46,11 +55,14 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo
4655 case <-ctx.Done():
4756 return ports.MirrorSyncResult{}, ctx.Err()
4857 case <-f.done:
58+ f.result.Repo = repo
4959 return f.result, f.err
5060 }
5161 }
5262
53-// SyncAll requests a sync for every repository as an owner.
63+// SyncAll syncs every repository whose mirror is enabled, as an owner. A failing repository
64+// does not stop the others: its outcome carries the error. The returned error is only for
65+// what prevents the whole request (authorization, listing, cancellation).
5466 func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {
5567 if err := mirrorOwner(p); err != nil {
5668 return nil, err
@@ -61,103 +73,159 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) (
6173 }
6274 out := make([]ports.MirrorSyncResult, 0, len(repos))
6375 for _, r := range repos {
64- v, err := m.Sync(ctx, p, r.Name, force)
65- if err != nil {
76+ if err := ctx.Err(); err != nil {
6677 return out, err
6778 }
68- out = append(out, v)
79+ t, err := m.target(ctx, r.Name)
80+ if err == nil && !t.Enabled {
81+ continue
82+ }
83+ var res ports.MirrorSyncResult
84+ if err == nil {
85+ res, err = m.Sync(ctx, p, r.Name, force)
86+ }
87+ if ctxErr := ctx.Err(); ctxErr != nil {
88+ return out, ctxErr
89+ }
90+ res.Repo = r.Name
91+ if res.Status.Repo == (domain.RepoName{}) {
92+ res.Status.Repo = r.Name
93+ }
94+ res.Err = err
95+ out = append(out, res)
6996 }
7097 return out, nil
7198 }
72-func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlight, bool, error) {
99+
100+// Wait blocks until no sync is queued or running, or ctx is done. It is for tests and shutdown
101+// paths that need a deterministic barrier.
102+func (m *Mirrors) Wait(ctx context.Context) {
103+ done := make(chan struct{})
104+ go func() { m.flightWG.Wait(); close(done) }()
105+ select {
106+ case <-done:
107+ case <-ctx.Done():
108+ }
109+}
110+
111+func (m *Mirrors) enqueue(repo domain.RepoName, req mirrorReq) (*mirrorFlight, bool, error) {
73112 m.mu.Lock()
74113 defer m.mu.Unlock()
75114 if m.closed {
76115 return nil, false, context.Canceled
77116 }
78117 if f := m.flights[repo]; f != nil {
79- f.pending = true
80- f.pendingForce = f.pendingForce || force
81- f.pendingManual = f.pendingManual || manual
118+ next := req
119+ if f.next != nil {
120+ next = f.next.merge(req)
121+ }
122+ f.next = &next
82123 return f, true, nil
83124 }
84- f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})}
85- m.flights[repo] = f
125+ f := &mirrorFlight{cur: req, done: make(chan struct{})}
86126 select {
87127 case m.jobs <- repo:
128+ m.flights[repo] = f
129+ m.flightWG.Add(1)
88130 return f, false, nil
89131 default:
90- delete(m.flights, repo)
91132 return nil, false, domain.ErrResourceExhausted
92133 }
93134 }
135+
94136 func (m *Mirrors) run(repo domain.RepoName) {
95137 m.mu.Lock()
96138 f := m.flights[repo]
97139 m.mu.Unlock()
98140 for {
99- result, err := m.attempt(m.ctx, repo, f.force, f.manual)
141+ result, err := m.attempt(m.ctx, repo, f.cur)
100142 m.mu.Lock()
101143 if !f.completed {
102144 f.result, f.err = result, err
103145 f.completed = true
104146 close(f.done)
105147 }
106- if !f.pending || m.closed {
148+ if f.next == nil || m.closed {
107149 delete(m.flights, repo)
150+ m.flightWG.Done()
108151 m.mu.Unlock()
109152 return
110153 }
111- f.force, f.manual = f.pendingForce, f.pendingManual
112- f.pending, f.pendingForce, f.pendingManual = false, false, false
154+ f.cur, f.next = *f.next, nil
113155 m.mu.Unlock()
114156 }
115157 }
116158
117-func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) {
159+// mirrorPlan is what attempt decided under the configuration lock, before pushing.
160+type mirrorPlan struct {
161+ meta domain.Repo
162+ target domain.MirrorTarget
163+ prev domain.MirrorStatus
164+ start time.Time
165+ generation uint64
166+}
167+
168+// plan reads the target and status and applies the skip and throttle rules. When done is set
169+// the attempt ends with result and err.
170+func (m *Mirrors) plan(ctx context.Context, repo domain.RepoName, req mirrorReq) (pl mirrorPlan, result ports.MirrorSyncResult, done bool, err error) {
118171 m.configMu.RLock()
119172 defer m.configMu.RUnlock()
120173 meta, err := m.cfg.Meta.Get(ctx, repo)
121174 if err != nil {
122- return ports.MirrorSyncResult{}, err
175+ return pl, result, true, err
123176 }
124177 t, err := m.target(ctx, repo)
125178 if err != nil {
126- return ports.MirrorSyncResult{}, err
179+ return pl, result, true, err
127180 }
128181 prev, err := m.status(ctx, repo)
129182 if err != nil {
130- return ports.MirrorSyncResult{}, err
183+ return pl, result, true, err
131184 }
132- start := m.cfg.Clock.Now().UTC()
133- now := start
185+ now := m.cfg.Clock.Now().UTC()
134186 if !t.Enabled {
135187 s := domain.MirrorDisable(prev, now)
136188 if prev.State != domain.MirrorDisabled {
137189 err = m.cfg.Store.PutStatus(ctx, s)
138190 }
139- return ports.MirrorSyncResult{Status: s}, err
191+ return pl, ports.MirrorSyncResult{Status: s}, true, err
140192 }
141- if !force && (prev.State == domain.MirrorPaused || (!manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) {
142- return ports.MirrorSyncResult{Status: prev}, nil
193+ if !req.force && (prev.State == domain.MirrorPaused || (!req.manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) {
194+ return pl, ports.MirrorSyncResult{Status: prev}, true, nil
143195 }
144- if manual && !force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval {
145- return ports.MirrorSyncResult{Status: prev}, fmt.Errorf("%w: last sync %s ago; wait 60s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt))
196+ if req.manual && !req.force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval {
197+ return pl, ports.MirrorSyncResult{Status: prev}, true, fmt.Errorf("%w: last sync %s ago; wait %s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt).Round(time.Second), domain.MirrorManualInterval)
146198 }
147199 m.mu.Lock()
148- generation := m.dirty[repo]
200+ generation := m.gens[repo].dirty
149201 m.mu.Unlock()
150- refs, runErr := m.cfg.Reader.ListRefs(ctx, repo)
202+ return mirrorPlan{meta: meta, target: t, prev: prev, start: now, generation: generation}, result, false, nil
203+}
204+
205+// attempt runs one sync. The configuration lock is held to read and to record, never while
206+// pushing: a slow remote must not block configuration changes.
207+func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, req mirrorReq) (ports.MirrorSyncResult, error) {
208+ pl, result, done, err := m.plan(ctx, repo, req)
209+ if done || err != nil {
210+ return result, err
211+ }
212+ t := pl.target
213+ // A missing sealer or an unreadable key fails the attempt before any repository I/O.
214+ key, runErr := m.privateKey(ctx)
215+ var refs []domain.Ref
151216 var selected []domain.MirrorRef
152217 if runErr == nil {
153- branch, _, e := m.cfg.Git.DefaultBranch(ctx, repo)
154- runErr = e
155- if e == nil {
218+ refs, runErr = m.cfg.Reader.ListRefs(ctx, repo)
219+ }
220+ if runErr == nil {
221+ var branch string
222+ branch, _, runErr = m.cfg.Git.DefaultBranch(ctx, repo)
223+ if runErr == nil {
156224 selected = domain.MirrorRefspecs(refs, branch, t.AllBranches)
157225 }
158226 }
159227 noOp := false
160- if runErr == nil && !force {
228+ if runErr == nil && !req.force {
161229 snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo)
162230 if e != nil && !errors.Is(e, domain.ErrNotFound) {
163231 return ports.MirrorSyncResult{}, e
@@ -165,17 +233,13 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
165233 noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches)
166234 }
167235 if runErr == nil && !noOp {
168- key, e := m.privateKey(ctx)
169- runErr = e
170- if e == nil {
171- specs := make([]string, len(selected))
172- for i, ref := range selected {
173- specs[i] = ref.Name + ":" + ref.Name
174- }
175- // An empty ref list must not invoke git's implicit default push behavior.
176- if len(specs) > 0 {
177- _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key})
178- }
236+ specs := make([]string, len(selected))
237+ for i, ref := range selected {
238+ specs[i] = ref.Name + ":" + ref.Name
239+ }
240+ // An empty ref list must not invoke git's implicit default push behavior.
241+ if len(specs) > 0 {
242+ _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key})
179243 }
180244 }
181245 if ctx.Err() != nil {
@@ -183,24 +247,30 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
183247 }
184248 var failure *domain.MirrorError
185249 if runErr != nil && !errors.As(runErr, &failure) {
186- detail := "local mirror operation failed"
187- if m.cfg.Sealer == nil {
188- detail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key"
189- }
190- failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
250+ failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: "local mirror operation failed"}
191251 runErr = failure
192- if m.cfg.Sealer == nil {
193- runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail)
194- }
252+ }
253+ return m.record(ctx, repo, pl, selected, noOp, failure, runErr)
254+}
255+
256+// record stores the outcome, unless the effective target changed during the push: the result
257+// then describes a target that no longer applies and is discarded.
258+func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) {
259+ m.configMu.RLock()
260+ defer m.configMu.RUnlock()
261+ t, err := m.target(ctx, repo)
262+ if err != nil {
263+ return ports.MirrorSyncResult{}, err
264+ }
265+ if !sameMirrorTarget(pl.target, t) {
266+ s, err := m.status(ctx, repo)
267+ return ports.MirrorSyncResult{Status: s}, err
195268 }
196269 // The push may be slow: completion, retry and success times come from the
197270 // clock after it returns; only LastAttemptAt marks when the attempt began.
198- now = m.cfg.Clock.Now().UTC()
199- transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)
200- transition.Status.LastAttemptAt = start
201- if failure != nil && m.cfg.Sealer == nil {
202- transition.Status.Reason = failure.Detail
203- }
271+ now := m.cfg.Clock.Now().UTC()
272+ transition := domain.ApplyMirrorOutcome(pl.prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)
273+ transition.Status.LastAttemptAt = pl.start
204274 if failure == nil {
205275 if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil {
206276 return ports.MirrorSyncResult{}, err
@@ -211,15 +281,17 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
211281 }
212282 if failure == nil {
213283 m.mu.Lock()
214- m.clean[repo] = generation
284+ g := m.gens[repo]
285+ g.clean, g.cleanKnown = pl.generation, true
286+ m.gens[repo] = g
215287 m.mu.Unlock()
216288 }
217289 if m.cfg.Bus != nil {
218290 if transition.FailedEvent {
219- m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, Reason: transition.Status.Reason, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt})
291+ m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt})
220292 }
221293 if transition.Recovered {
222- m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt})
294+ m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt})
223295 }
224296 }
225297 return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr
@@ -6,19 +6,28 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "time"
9 10
10 "bnema.dev/ermite/internal/domain"11 "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"12 "bnema.dev/ermite/internal/ports"
12 )13 )
13 14
15+// mirrorReq is what a sync request asks for. Requests that coalesce merge by OR.
16+type mirrorReq struct{ force, manual bool }
17+
18+func (r mirrorReq) merge(o mirrorReq) mirrorReq {
19+ return mirrorReq{force: r.force || o.force, manual: r.manual || o.manual}
20+}
21+
22+// mirrorFlight is the single in-flight sync of a repository: cur is running, next is the
23+// merged request that arrived meanwhile (one rerun, whatever the number of requests).
14 type mirrorFlight struct {24 type mirrorFlight struct {
15- pending, force, manual bool25+ cur mirrorReq
16- pendingForce bool26+ next *mirrorReq
17- pendingManual bool27+ done chan struct{} // closed with the result of cur's first run
18- done chan struct{}28+ completed bool
19- completed bool29+ result ports.MirrorSyncResult
20- result ports.MirrorSyncResult30+ err error
21- err error
22 }31 }
23 32
24 var (33 var (
@@ -33,12 +42,12 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo
33 return ports.MirrorSyncResult{}, err42 return ports.MirrorSyncResult{}, err
34 }43 }
35 m.Start()44 m.Start()
36- f, queued, err := m.enqueue(repo, force, true)45+ f, queued, err := m.enqueue(repo, mirrorReq{force: force, manual: true})
37 if err != nil {46 if err != nil {
38 return ports.MirrorSyncResult{}, err47 return ports.MirrorSyncResult{}, err
39 }48 }
40 if queued {49 if queued {
41- return ports.MirrorSyncResult{Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil50+ return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil
42 }51 }
43 select {52 select {
44 case <-m.ctx.Done():53 case <-m.ctx.Done():
@@ -46,11 +55,14 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo
46 case <-ctx.Done():55 case <-ctx.Done():
47 return ports.MirrorSyncResult{}, ctx.Err()56 return ports.MirrorSyncResult{}, ctx.Err()
48 case <-f.done:57 case <-f.done:
58+ f.result.Repo = repo
49 return f.result, f.err59 return f.result, f.err
50 }60 }
51 }61 }
52 62
53-// SyncAll requests a sync for every repository as an owner.63+// SyncAll syncs every repository whose mirror is enabled, as an owner. A failing repository
64+// does not stop the others: its outcome carries the error. The returned error is only for
65+// what prevents the whole request (authorization, listing, cancellation).
54 func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {66 func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {
55 if err := mirrorOwner(p); err != nil {67 if err := mirrorOwner(p); err != nil {
56 return nil, err68 return nil, err
@@ -61,103 +73,159 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) (
61 }73 }
62 out := make([]ports.MirrorSyncResult, 0, len(repos))74 out := make([]ports.MirrorSyncResult, 0, len(repos))
63 for _, r := range repos {75 for _, r := range repos {
64- v, err := m.Sync(ctx, p, r.Name, force)76+ if err := ctx.Err(); err != nil {
65- if err != nil {
66 return out, err77 return out, err
67 }78 }
68- out = append(out, v)79+ t, err := m.target(ctx, r.Name)
80+ if err == nil && !t.Enabled {
81+ continue
82+ }
83+ var res ports.MirrorSyncResult
84+ if err == nil {
85+ res, err = m.Sync(ctx, p, r.Name, force)
86+ }
87+ if ctxErr := ctx.Err(); ctxErr != nil {
88+ return out, ctxErr
89+ }
90+ res.Repo = r.Name
91+ if res.Status.Repo == (domain.RepoName{}) {
92+ res.Status.Repo = r.Name
93+ }
94+ res.Err = err
95+ out = append(out, res)
69 }96 }
70 return out, nil97 return out, nil
71 }98 }
72-func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlight, bool, error) {99+
100+// Wait blocks until no sync is queued or running, or ctx is done. It is for tests and shutdown
101+// paths that need a deterministic barrier.
102+func (m *Mirrors) Wait(ctx context.Context) {
103+ done := make(chan struct{})
104+ go func() { m.flightWG.Wait(); close(done) }()
105+ select {
106+ case <-done:
107+ case <-ctx.Done():
108+ }
109+}
110+
111+func (m *Mirrors) enqueue(repo domain.RepoName, req mirrorReq) (*mirrorFlight, bool, error) {
73 m.mu.Lock()112 m.mu.Lock()
74 defer m.mu.Unlock()113 defer m.mu.Unlock()
75 if m.closed {114 if m.closed {
76 return nil, false, context.Canceled115 return nil, false, context.Canceled
77 }116 }
78 if f := m.flights[repo]; f != nil {117 if f := m.flights[repo]; f != nil {
79- f.pending = true118+ next := req
80- f.pendingForce = f.pendingForce || force119+ if f.next != nil {
81- f.pendingManual = f.pendingManual || manual120+ next = f.next.merge(req)
121+ }
122+ f.next = &next
82 return f, true, nil123 return f, true, nil
83 }124 }
84- f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})}125+ f := &mirrorFlight{cur: req, done: make(chan struct{})}
85- m.flights[repo] = f
86 select {126 select {
87 case m.jobs <- repo:127 case m.jobs <- repo:
128+ m.flights[repo] = f
129+ m.flightWG.Add(1)
88 return f, false, nil130 return f, false, nil
89 default:131 default:
90- delete(m.flights, repo)
91 return nil, false, domain.ErrResourceExhausted132 return nil, false, domain.ErrResourceExhausted
92 }133 }
93 }134 }
135+
94 func (m *Mirrors) run(repo domain.RepoName) {136 func (m *Mirrors) run(repo domain.RepoName) {
95 m.mu.Lock()137 m.mu.Lock()
96 f := m.flights[repo]138 f := m.flights[repo]
97 m.mu.Unlock()139 m.mu.Unlock()
98 for {140 for {
99- result, err := m.attempt(m.ctx, repo, f.force, f.manual)141+ result, err := m.attempt(m.ctx, repo, f.cur)
100 m.mu.Lock()142 m.mu.Lock()
101 if !f.completed {143 if !f.completed {
102 f.result, f.err = result, err144 f.result, f.err = result, err
103 f.completed = true145 f.completed = true
104 close(f.done)146 close(f.done)
105 }147 }
106- if !f.pending || m.closed {148+ if f.next == nil || m.closed {
107 delete(m.flights, repo)149 delete(m.flights, repo)
150+ m.flightWG.Done()
108 m.mu.Unlock()151 m.mu.Unlock()
109 return152 return
110 }153 }
111- f.force, f.manual = f.pendingForce, f.pendingManual154+ f.cur, f.next = *f.next, nil
112- f.pending, f.pendingForce, f.pendingManual = false, false, false
113 m.mu.Unlock()155 m.mu.Unlock()
114 }156 }
115 }157 }
116 158
117-func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) {159+// mirrorPlan is what attempt decided under the configuration lock, before pushing.
160+type mirrorPlan struct {
161+ meta domain.Repo
162+ target domain.MirrorTarget
163+ prev domain.MirrorStatus
164+ start time.Time
165+ generation uint64
166+}
167+
168+// plan reads the target and status and applies the skip and throttle rules. When done is set
169+// the attempt ends with result and err.
170+func (m *Mirrors) plan(ctx context.Context, repo domain.RepoName, req mirrorReq) (pl mirrorPlan, result ports.MirrorSyncResult, done bool, err error) {
118 m.configMu.RLock()171 m.configMu.RLock()
119 defer m.configMu.RUnlock()172 defer m.configMu.RUnlock()
120 meta, err := m.cfg.Meta.Get(ctx, repo)173 meta, err := m.cfg.Meta.Get(ctx, repo)
121 if err != nil {174 if err != nil {
122- return ports.MirrorSyncResult{}, err175+ return pl, result, true, err
123 }176 }
124 t, err := m.target(ctx, repo)177 t, err := m.target(ctx, repo)
125 if err != nil {178 if err != nil {
126- return ports.MirrorSyncResult{}, err179+ return pl, result, true, err
127 }180 }
128 prev, err := m.status(ctx, repo)181 prev, err := m.status(ctx, repo)
129 if err != nil {182 if err != nil {
130- return ports.MirrorSyncResult{}, err183+ return pl, result, true, err
131 }184 }
132- start := m.cfg.Clock.Now().UTC()185+ now := m.cfg.Clock.Now().UTC()
133- now := start
134 if !t.Enabled {186 if !t.Enabled {
135 s := domain.MirrorDisable(prev, now)187 s := domain.MirrorDisable(prev, now)
136 if prev.State != domain.MirrorDisabled {188 if prev.State != domain.MirrorDisabled {
137 err = m.cfg.Store.PutStatus(ctx, s)189 err = m.cfg.Store.PutStatus(ctx, s)
138 }190 }
139- return ports.MirrorSyncResult{Status: s}, err191+ return pl, ports.MirrorSyncResult{Status: s}, true, err
140 }192 }
141- if !force && (prev.State == domain.MirrorPaused || (!manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) {193+ if !req.force && (prev.State == domain.MirrorPaused || (!req.manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) {
142- return ports.MirrorSyncResult{Status: prev}, nil194+ return pl, ports.MirrorSyncResult{Status: prev}, true, nil
143 }195 }
144- if manual && !force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval {196+ if req.manual && !req.force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval {
145- return ports.MirrorSyncResult{Status: prev}, fmt.Errorf("%w: last sync %s ago; wait 60s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt))197+ return pl, ports.MirrorSyncResult{Status: prev}, true, fmt.Errorf("%w: last sync %s ago; wait %s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt).Round(time.Second), domain.MirrorManualInterval)
146 }198 }
147 m.mu.Lock()199 m.mu.Lock()
148- generation := m.dirty[repo]200+ generation := m.gens[repo].dirty
149 m.mu.Unlock()201 m.mu.Unlock()
150- refs, runErr := m.cfg.Reader.ListRefs(ctx, repo)202+ return mirrorPlan{meta: meta, target: t, prev: prev, start: now, generation: generation}, result, false, nil
203+}
204+
205+// attempt runs one sync. The configuration lock is held to read and to record, never while
206+// pushing: a slow remote must not block configuration changes.
207+func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, req mirrorReq) (ports.MirrorSyncResult, error) {
208+ pl, result, done, err := m.plan(ctx, repo, req)
209+ if done || err != nil {
210+ return result, err
211+ }
212+ t := pl.target
213+ // A missing sealer or an unreadable key fails the attempt before any repository I/O.
214+ key, runErr := m.privateKey(ctx)
215+ var refs []domain.Ref
151 var selected []domain.MirrorRef216 var selected []domain.MirrorRef
152 if runErr == nil {217 if runErr == nil {
153- branch, _, e := m.cfg.Git.DefaultBranch(ctx, repo)218+ refs, runErr = m.cfg.Reader.ListRefs(ctx, repo)
154- runErr = e219+ }
155- if e == nil {220+ if runErr == nil {
221+ var branch string
222+ branch, _, runErr = m.cfg.Git.DefaultBranch(ctx, repo)
223+ if runErr == nil {
156 selected = domain.MirrorRefspecs(refs, branch, t.AllBranches)224 selected = domain.MirrorRefspecs(refs, branch, t.AllBranches)
157 }225 }
158 }226 }
159 noOp := false227 noOp := false
160- if runErr == nil && !force {228+ if runErr == nil && !req.force {
161 snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo)229 snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo)
162 if e != nil && !errors.Is(e, domain.ErrNotFound) {230 if e != nil && !errors.Is(e, domain.ErrNotFound) {
163 return ports.MirrorSyncResult{}, e231 return ports.MirrorSyncResult{}, e
@@ -165,17 +233,13 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
165 noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches)233 noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches)
166 }234 }
167 if runErr == nil && !noOp {235 if runErr == nil && !noOp {
168- key, e := m.privateKey(ctx)236+ specs := make([]string, len(selected))
169- runErr = e237+ for i, ref := range selected {
170- if e == nil {238+ specs[i] = ref.Name + ":" + ref.Name
171- specs := make([]string, len(selected))239+ }
172- for i, ref := range selected {240+ // An empty ref list must not invoke git's implicit default push behavior.
173- specs[i] = ref.Name + ":" + ref.Name241+ if len(specs) > 0 {
174- }242+ _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key})
175- // An empty ref list must not invoke git's implicit default push behavior.
176- if len(specs) > 0 {
177- _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key})
178- }
179 }243 }
180 }244 }
181 if ctx.Err() != nil {245 if ctx.Err() != nil {
@@ -183,24 +247,30 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
183 }247 }
184 var failure *domain.MirrorError248 var failure *domain.MirrorError
185 if runErr != nil && !errors.As(runErr, &failure) {249 if runErr != nil && !errors.As(runErr, &failure) {
186- detail := "local mirror operation failed"250+ failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: "local mirror operation failed"}
187- if m.cfg.Sealer == nil {
188- detail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key"
189- }
190- failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
191 runErr = failure251 runErr = failure
192- if m.cfg.Sealer == nil {252+ }
193- runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail)253+ return m.record(ctx, repo, pl, selected, noOp, failure, runErr)
194- }254+}
255+
256+// record stores the outcome, unless the effective target changed during the push: the result
257+// then describes a target that no longer applies and is discarded.
258+func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) {
259+ m.configMu.RLock()
260+ defer m.configMu.RUnlock()
261+ t, err := m.target(ctx, repo)
262+ if err != nil {
263+ return ports.MirrorSyncResult{}, err
264+ }
265+ if !sameMirrorTarget(pl.target, t) {
266+ s, err := m.status(ctx, repo)
267+ return ports.MirrorSyncResult{Status: s}, err
195 }268 }
196 // The push may be slow: completion, retry and success times come from the269 // The push may be slow: completion, retry and success times come from the
197 // clock after it returns; only LastAttemptAt marks when the attempt began.270 // clock after it returns; only LastAttemptAt marks when the attempt began.
198- now = m.cfg.Clock.Now().UTC()271+ now := m.cfg.Clock.Now().UTC()
199- transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)272+ transition := domain.ApplyMirrorOutcome(pl.prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)
200- transition.Status.LastAttemptAt = start273+ transition.Status.LastAttemptAt = pl.start
201- if failure != nil && m.cfg.Sealer == nil {
202- transition.Status.Reason = failure.Detail
203- }
204 if failure == nil {274 if failure == nil {
205 if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil {275 if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil {
206 return ports.MirrorSyncResult{}, err276 return ports.MirrorSyncResult{}, err
@@ -211,15 +281,17 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
211 }281 }
212 if failure == nil {282 if failure == nil {
213 m.mu.Lock()283 m.mu.Lock()
214- m.clean[repo] = generation284+ g := m.gens[repo]
285+ g.clean, g.cleanKnown = pl.generation, true
286+ m.gens[repo] = g
215 m.mu.Unlock()287 m.mu.Unlock()
216 }288 }
217 if m.cfg.Bus != nil {289 if m.cfg.Bus != nil {
218 if transition.FailedEvent {290 if transition.FailedEvent {
219- m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, Reason: transition.Status.Reason, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt})291+ m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt})
220 }292 }
221 if transition.Recovered {293 if transition.Recovered {
222- m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt})294+ m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt})
223 }295 }
224 }296 }
225 return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr297 return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr
A internal/usecase/mirrors_sync_test.go +492 -0
@@ -0,0 +1,492 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+var errBoom = errors.New("boom")
24+
25+func (f *mirrorFixture) pushes(t *testing.T, result error) *int {
26+ t.Helper()
27+ var mu sync.Mutex
28+ n := new(int)
29+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
30+ mu.Lock()
31+ defer mu.Unlock()
32+ *n++
33+ return ports.MirrorPushResult{}, result
34+ }).Maybe()
35+ return n
36+}
37+
38+func TestMirrorsNoOpThrottleForce(t *testing.T) {
39+ f := newMirrorFixture(t)
40+ f.setSnapshot(f.repo, domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}})
41+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
42+ require.NoError(t, err)
43+ require.True(t, v.NoOp)
44+ _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false)
45+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
46+ require.Contains(t, err.Error(), "wait 1m0s", "the interval comes from the domain constant")
47+ require.Contains(t, err.Error(), "last sync 0s ago")
48+ f.push.EXPECT().Push(mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool {
49+ return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main"
50+ })).Return(ports.MirrorPushResult{}, nil).Once()
51+ v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true)
52+ require.NoError(t, err)
53+ require.False(t, v.NoOp)
54+}
55+
56+func TestMirrorsIncidentAndRecovery(t *testing.T) {
57+ f := newMirrorFixture(t)
58+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5)
59+ for i := 1; i <= 5; i++ {
60+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
61+ require.Error(t, err)
62+ require.Equal(t, i, v.Status.ConsecutiveFailures)
63+ if i < 5 {
64+ require.Equal(t, domain.MirrorFailing, v.Status.State)
65+ require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt))
66+ } else {
67+ require.Equal(t, domain.MirrorPaused, v.Status.State)
68+ }
69+ }
70+ events := f.eventList()
71+ require.Len(t, events, 1, "mirror.failed once per incident")
72+ failed, ok := events[0].(domain.MirrorFailed)
73+ require.True(t, ok)
74+ require.Equal(t, domain.MirrorFailing, failed.State)
75+ // A paused mirror is not retried by the schedule.
76+ f.advance(24 * time.Hour)
77+ f.m.ScheduleNow(context.Background())
78+ f.drain(t)
79+ f.push.AssertNumberOfCalls(t, "Push", 5)
80+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
81+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
82+ require.NoError(t, err)
83+ require.Equal(t, domain.MirrorOK, v.Status.State)
84+ events = f.eventList()
85+ require.Len(t, events, 2)
86+ require.IsType(t, domain.MirrorRecovered{}, events[1])
87+}
88+
89+func TestMirrorsSingleFlight(t *testing.T) {
90+ f := newMirrorFixture(t)
91+ entered, release := make(chan struct{}), make(chan struct{})
92+ var mu sync.Mutex
93+ calls := 0
94+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
95+ mu.Lock()
96+ calls++
97+ first := calls == 1
98+ mu.Unlock()
99+ if first {
100+ close(entered)
101+ <-release
102+ }
103+ return ports.MirrorPushResult{}, nil
104+ })
105+ done := make(chan error, 1)
106+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
107+ <-entered
108+ type outcome struct {
109+ res ports.MirrorSyncResult
110+ err error
111+ }
112+ results := make(chan outcome, 20)
113+ var wg sync.WaitGroup
114+ for range 20 {
115+ wg.Add(1)
116+ go func() {
117+ defer wg.Done()
118+ res, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
119+ results <- outcome{res, err}
120+ }()
121+ }
122+ wg.Wait()
123+ close(results)
124+ for o := range results {
125+ require.NoError(t, o.err)
126+ require.True(t, o.res.Queued)
127+ require.Equal(t, f.repo, o.res.Repo)
128+ }
129+ close(release)
130+ require.NoError(t, <-done)
131+ f.drain(t)
132+ mu.Lock()
133+ defer mu.Unlock()
134+ require.Equal(t, 2, calls, "twenty overlapping requests coalesce into one rerun")
135+}
136+
137+func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) {
138+ f := newMirrorFixture(t)
139+ entered, release := make(chan struct{}), make(chan struct{})
140+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
141+ close(entered)
142+ <-release
143+ return ports.MirrorPushResult{}, nil
144+ }).Once()
145+ done := make(chan error, 1)
146+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
147+ <-entered
148+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
149+ require.NoError(t, err)
150+ require.True(t, v.Queued)
151+ close(release)
152+ require.NoError(t, <-done)
153+ f.drain(t)
154+ // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push
155+ // (a second call would fail the Once expectation).
156+ f.push.AssertNumberOfCalls(t, "Push", 1)
157+}
158+
159+func TestMirrorsDivergedPausedAtOnce(t *testing.T) {
160+ f := newMirrorFixture(t)
161+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once()
162+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
163+ require.Error(t, err)
164+ require.Equal(t, domain.MirrorPaused, v.Status.State)
165+ for range 3 {
166+ f.advance(time.Hour)
167+ f.m.ScheduleNow(context.Background())
168+ }
169+ f.drain(t)
170+ f.push.AssertNumberOfCalls(t, "Push", 1)
171+ require.Len(t, f.eventList(), 1)
172+}
173+
174+func TestMirrorsCloseCancelsWorker(t *testing.T) {
175+ f := newMirrorFixture(t)
176+ entered, exited := make(chan struct{}), make(chan struct{})
177+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
178+ close(entered)
179+ <-ctx.Done()
180+ close(exited)
181+ return ports.MirrorPushResult{}, context.Canceled
182+ }).Once()
183+ done := make(chan error, 1)
184+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
185+ <-entered
186+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
187+ defer cancel()
188+ f.m.Close(ctx)
189+ require.NoError(t, ctx.Err())
190+ select {
191+ case <-exited:
192+ default:
193+ t.Fatal("Close returned before worker exited")
194+ }
195+ require.ErrorIs(t, <-done, context.Canceled)
196+ f.m.Close(ctx) // idempotent; Start cannot resurrect workers.
197+ f.m.Start()
198+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
199+ require.ErrorIs(t, err, context.Canceled)
200+}
201+
202+func TestMirrorsCronBackoff(t *testing.T) {
203+ f := newMirrorFixture(t)
204+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Twice()
205+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
206+ require.Error(t, err)
207+ f.advance(time.Minute)
208+ f.m.ScheduleNow(context.Background())
209+ f.drain(t)
210+ f.push.AssertNumberOfCalls(t, "Push", 1)
211+ f.advance(4 * time.Minute)
212+ f.m.ScheduleNow(context.Background())
213+ f.drain(t)
214+ f.push.AssertNumberOfCalls(t, "Push", 2)
215+}
216+
217+func TestMirrorsCronDueAndDirtySkip(t *testing.T) {
218+ f := newMirrorFixture(t)
219+ f.pushes(t, nil)
220+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
221+ require.NoError(t, err)
222+ require.Equal(t, 1, f.refReads())
223+ f.advance(5 * time.Minute)
224+ f.m.ScheduleNow(context.Background()) // due and clean: skipped without reading refs
225+ f.drain(t)
226+ require.Equal(t, 1, f.refReads())
227+ // Merges and default branch changes both end in OnPush: an idempotent dirty mark.
228+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
229+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}})
230+ f.m.ScheduleNow(context.Background()) // same minute: nothing runs, although dirty
231+ f.drain(t)
232+ require.Equal(t, 1, f.refReads())
233+ f.advance(5 * time.Minute)
234+ f.m.ScheduleNow(context.Background())
235+ f.drain(t)
236+ require.Equal(t, 2, f.refReads(), "the dirty repository is examined on the next due tick")
237+}
238+
239+func TestMirrorsScheduleKeepsFullQueueRepositoriesForNextTicks(t *testing.T) {
240+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 })
241+ repos := []domain.RepoName{f.repo, name(t, "b"), name(t, "c"), name(t, "d")}
242+ for _, r := range repos[1:] {
243+ f.addRepo(r)
244+ }
245+ gate := make(chan struct{})
246+ var mu sync.Mutex
247+ pushed := map[string]int{}
248+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) {
249+ <-gate
250+ mu.Lock()
251+ pushed[p.URL]++
252+ mu.Unlock()
253+ return ports.MirrorPushResult{}, nil
254+ })
255+ f.advance(5 * time.Minute)
256+ f.m.ScheduleNow(context.Background())
257+ // The worker holds one repository and the queue one: the other two found it full.
258+ close(gate)
259+ for range 6 { // later ticks of the same minute drain the deferred set
260+ f.drain(t)
261+ f.m.ScheduleNow(context.Background())
262+ }
263+ f.drain(t)
264+ mu.Lock()
265+ defer mu.Unlock()
266+ for _, r := range repos {
267+ require.Equal(t, 1, pushed["git@example.com:acme/"+r.String()], r.String())
268+ }
269+}
270+
271+func TestMirrorsSyncRefusedWhenQueueFull(t *testing.T) {
272+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 })
273+ second, third := name(t, "b"), name(t, "c")
274+ f.addRepo(second)
275+ f.addRepo(third)
276+ gate := make(chan struct{})
277+ entered := make(chan struct{}, 8)
278+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
279+ entered <- struct{}{}
280+ <-gate
281+ return ports.MirrorPushResult{}, nil
282+ })
283+ first := make(chan error, 1)
284+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); first <- err }()
285+ <-entered // the single worker is busy
286+ // Enqueueing is synchronous: this request takes the only queue slot, then waits and gives up.
287+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
288+ defer cancel()
289+ _, err := f.m.Sync(ctx, domain.Root(), second, true)
290+ require.ErrorIs(t, err, context.DeadlineExceeded)
291+ _, err = f.m.Sync(context.Background(), domain.Root(), third, true)
292+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
293+ close(gate)
294+ require.NoError(t, <-first)
295+ f.drain(t)
296+}
297+
298+func TestMirrorsSlowPushTimestamps(t *testing.T) {
299+ f := newMirrorFixture(t)
300+ start := f.at
301+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
302+ f.advance(10 * time.Minute)
303+ return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}
304+ }).Once()
305+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
306+ require.Error(t, err)
307+ require.True(t, v.Status.LastAttemptAt.Equal(start))
308+ require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1))))
309+}
310+
311+func TestMirrorsPushDoesNotHoldConfigLock(t *testing.T) {
312+ f := newMirrorFixture(t)
313+ entered, release := make(chan struct{}), make(chan struct{})
314+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
315+ close(entered)
316+ <-release
317+ return ports.MirrorPushResult{}, nil
318+ }).Once()
319+ done := make(chan error, 1)
320+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
321+ <-entered
322+ changed := make(chan error, 1)
323+ go func() {
324+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
325+ changed <- err
326+ }()
327+ select {
328+ case err := <-changed:
329+ require.NoError(t, err)
330+ case <-time.After(5 * time.Second): // only on failure: the push holds the configuration lock
331+ t.Fatal("configuration change blocked by a running push")
332+ }
333+ close(release)
334+ require.NoError(t, <-done)
335+}
336+
337+func TestMirrorsResultDiscardedWhenTargetChangedDuringPush(t *testing.T) {
338+ f := newMirrorFixture(t)
339+ f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, ConsecutiveFailures: 1})
340+ entered, release := make(chan struct{}), make(chan struct{})
341+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
342+ close(entered)
343+ <-release
344+ return ports.MirrorPushResult{}, nil
345+ }).Once()
346+ done := make(chan error, 1)
347+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
348+ <-entered
349+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{URLTemplate: ptr("git@other.example:acme/{repo}")})
350+ require.NoError(t, err)
351+ close(release)
352+ require.NoError(t, <-done)
353+ _, ok := f.snapshotOf(f.repo)
354+ require.False(t, ok, "no snapshot for a target that no longer applies")
355+ require.Empty(t, f.eventList(), "no mirror.recovered for the old target")
356+ s := f.statusOf(f.repo)
357+ require.Equal(t, domain.MirrorOK, s.State)
358+ require.True(t, s.LastSuccessAt.IsZero(), "the push result was not recorded")
359+}
360+
361+func TestMirrorsSyncAllContinuesPastFailures(t *testing.T) {
362+ f := newMirrorFixture(t)
363+ bad, off := name(t, "bad"), name(t, "off")
364+ f.addRepo(bad)
365+ f.addRepo(off)
366+ f.setRepoConfig(domain.MirrorRepoConfig{Repo: off, Excluded: true})
367+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) {
368+ if strings.HasSuffix(p.URL, "/bad") {
369+ return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged, Detail: "rejected"}
370+ }
371+ return ports.MirrorPushResult{}, nil
372+ })
373+ got, err := f.m.SyncAll(context.Background(), domain.Root(), true)
374+ require.NoError(t, err)
375+ require.Len(t, got, 2, "the excluded repository is skipped")
376+ byRepo := map[domain.RepoName]ports.MirrorSyncResult{}
377+ for _, r := range got {
378+ byRepo[r.Repo] = r
379+ }
380+ require.NoError(t, byRepo[f.repo].Err)
381+ require.Equal(t, domain.MirrorOK, byRepo[f.repo].Status.State)
382+ var me *domain.MirrorError
383+ require.ErrorAs(t, byRepo[bad].Err, &me)
384+ require.Equal(t, domain.MirrorPaused, byRepo[bad].Status.State)
385+ _, listed := byRepo[off]
386+ require.False(t, listed)
387+ _, err = f.m.SyncAll(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin), true)
388+ require.ErrorIs(t, err, domain.ErrForbidden)
389+}
390+
391+func TestMirrorsLocalFailuresAreInternalAndNeverPush(t *testing.T) {
392+ for _, tc := range []struct {
393+ name string
394+ set func(*mirrorFixture)
395+ }{
396+ {"ListRefs", func(f *mirrorFixture) { f.mu.Lock(); f.refsErr = errBoom; f.mu.Unlock() }},
397+ {"DefaultBranch", func(f *mirrorFixture) { f.mu.Lock(); f.branchErr = errBoom; f.mu.Unlock() }},
398+ } {
399+ t.Run(tc.name, func(t *testing.T) {
400+ f := newMirrorFixture(t)
401+ tc.set(f)
402+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) // a Push call would fail the strict mock
403+ var me *domain.MirrorError
404+ require.ErrorAs(t, err, &me)
405+ require.Equal(t, domain.MirrorClassInternal, me.Class)
406+ require.NotContains(t, me.Detail, "boom")
407+ require.Equal(t, domain.MirrorFailing, v.Status.State)
408+ })
409+ }
410+}
411+
412+func TestMirrorsMissingSecretsKeyFailsBeforeReadingRefs(t *testing.T) {
413+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil })
414+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
415+ var me *domain.MirrorError
416+ require.ErrorAs(t, err, &me)
417+ require.Equal(t, domain.MirrorClassInternal, me.Class)
418+ require.Contains(t, me.Detail, "ERMITE_SECRETS_KEY")
419+ require.Equal(t, domain.MirrorFailing, v.Status.State)
420+ require.Zero(t, f.refReads())
421+}
422+
423+func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) {
424+ f := newMirrorFixture(t)
425+ var logs bytes.Buffer
426+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
427+ credentialURL := "ssh://alice:secret-password@example.com/acme/tools"
428+ _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL})
429+ require.Error(t, err)
430+ require.NotContains(t, err.Error(), "secret-password")
431+ require.NotContains(t, err.Error(), "alice")
432+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once()
433+ _, err = f.m.Sync(ctx, domain.Root(), f.repo, true)
434+ require.Error(t, err)
435+ require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER")
436+ require.NotContains(t, err.Error(), "secret-password")
437+ require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER")
438+ require.NotContains(t, logs.String(), "secret-password")
439+}
440+
441+func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) {
442+ f := newKeyFixture(t)
443+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
444+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, true).Return(nil).Once()
445+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once()
446+ first, err := f.m.ShowKey(context.Background(), domain.Root())
447+ require.NoError(t, err)
448+ cached, err := f.m.ShowKey(context.Background(), domain.Root())
449+ require.NoError(t, err)
450+ require.Equal(t, first, cached)
451+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
452+ require.NoError(t, err)
453+ require.Equal(t, first.Fingerprint, rotated.Previous.Fingerprint)
454+ require.NotEqual(t, first.Fingerprint, rotated.Key.Fingerprint)
455+ require.Contains(t, rotated.Key.AuthorizedKey, "ermite-mirror@forge.example.com")
456+ require.Len(t, f.seeds(), 2)
457+ require.NotEqual(t, f.seeds()[0], f.seeds()[1])
458+}
459+
460+func TestMirrorsRotateWithoutExistingKeyGeneratesOnce(t *testing.T) {
461+ f := newKeyFixture(t)
462+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
463+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() // never ifAbsent=true
464+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
465+ require.NoError(t, err)
466+ require.Empty(t, rotated.Previous.Fingerprint)
467+ require.NotEmpty(t, rotated.Key.Fingerprint)
468+ require.Len(t, f.seeds(), 1, "no throwaway key")
469+}
470+
471+func TestMirrorsRotateReplacesUnreadableKey(t *testing.T) {
472+ f := newKeyFixture(t)
473+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("garbage"), now, nil).Once()
474+ f.sealerOpenFails = true
475+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once()
476+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
477+ require.NoError(t, err)
478+ require.Empty(t, rotated.Previous.Fingerprint)
479+}
480+
481+func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) {
482+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil })
483+ _, err := f.m.ShowKey(context.Background(), domain.Anonymous())
484+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
485+ _, err = f.m.ShowKey(context.Background(), domain.Root())
486+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
487+ require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY")
488+ _, err = f.m.RotateKey(context.Background(), domain.Root())
489+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
490+ _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))
491+ require.ErrorIs(t, err, domain.ErrForbidden)
492+}
@@ -0,0 +1,492 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+var errBoom = errors.New("boom")
24+
25+func (f *mirrorFixture) pushes(t *testing.T, result error) *int {
26+ t.Helper()
27+ var mu sync.Mutex
28+ n := new(int)
29+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
30+ mu.Lock()
31+ defer mu.Unlock()
32+ *n++
33+ return ports.MirrorPushResult{}, result
34+ }).Maybe()
35+ return n
36+}
37+
38+func TestMirrorsNoOpThrottleForce(t *testing.T) {
39+ f := newMirrorFixture(t)
40+ f.setSnapshot(f.repo, domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}})
41+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
42+ require.NoError(t, err)
43+ require.True(t, v.NoOp)
44+ _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false)
45+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
46+ require.Contains(t, err.Error(), "wait 1m0s", "the interval comes from the domain constant")
47+ require.Contains(t, err.Error(), "last sync 0s ago")
48+ f.push.EXPECT().Push(mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool {
49+ return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main"
50+ })).Return(ports.MirrorPushResult{}, nil).Once()
51+ v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true)
52+ require.NoError(t, err)
53+ require.False(t, v.NoOp)
54+}
55+
56+func TestMirrorsIncidentAndRecovery(t *testing.T) {
57+ f := newMirrorFixture(t)
58+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5)
59+ for i := 1; i <= 5; i++ {
60+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
61+ require.Error(t, err)
62+ require.Equal(t, i, v.Status.ConsecutiveFailures)
63+ if i < 5 {
64+ require.Equal(t, domain.MirrorFailing, v.Status.State)
65+ require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt))
66+ } else {
67+ require.Equal(t, domain.MirrorPaused, v.Status.State)
68+ }
69+ }
70+ events := f.eventList()
71+ require.Len(t, events, 1, "mirror.failed once per incident")
72+ failed, ok := events[0].(domain.MirrorFailed)
73+ require.True(t, ok)
74+ require.Equal(t, domain.MirrorFailing, failed.State)
75+ // A paused mirror is not retried by the schedule.
76+ f.advance(24 * time.Hour)
77+ f.m.ScheduleNow(context.Background())
78+ f.drain(t)
79+ f.push.AssertNumberOfCalls(t, "Push", 5)
80+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
81+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
82+ require.NoError(t, err)
83+ require.Equal(t, domain.MirrorOK, v.Status.State)
84+ events = f.eventList()
85+ require.Len(t, events, 2)
86+ require.IsType(t, domain.MirrorRecovered{}, events[1])
87+}
88+
89+func TestMirrorsSingleFlight(t *testing.T) {
90+ f := newMirrorFixture(t)
91+ entered, release := make(chan struct{}), make(chan struct{})
92+ var mu sync.Mutex
93+ calls := 0
94+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
95+ mu.Lock()
96+ calls++
97+ first := calls == 1
98+ mu.Unlock()
99+ if first {
100+ close(entered)
101+ <-release
102+ }
103+ return ports.MirrorPushResult{}, nil
104+ })
105+ done := make(chan error, 1)
106+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
107+ <-entered
108+ type outcome struct {
109+ res ports.MirrorSyncResult
110+ err error
111+ }
112+ results := make(chan outcome, 20)
113+ var wg sync.WaitGroup
114+ for range 20 {
115+ wg.Add(1)
116+ go func() {
117+ defer wg.Done()
118+ res, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
119+ results <- outcome{res, err}
120+ }()
121+ }
122+ wg.Wait()
123+ close(results)
124+ for o := range results {
125+ require.NoError(t, o.err)
126+ require.True(t, o.res.Queued)
127+ require.Equal(t, f.repo, o.res.Repo)
128+ }
129+ close(release)
130+ require.NoError(t, <-done)
131+ f.drain(t)
132+ mu.Lock()
133+ defer mu.Unlock()
134+ require.Equal(t, 2, calls, "twenty overlapping requests coalesce into one rerun")
135+}
136+
137+func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) {
138+ f := newMirrorFixture(t)
139+ entered, release := make(chan struct{}), make(chan struct{})
140+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
141+ close(entered)
142+ <-release
143+ return ports.MirrorPushResult{}, nil
144+ }).Once()
145+ done := make(chan error, 1)
146+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
147+ <-entered
148+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
149+ require.NoError(t, err)
150+ require.True(t, v.Queued)
151+ close(release)
152+ require.NoError(t, <-done)
153+ f.drain(t)
154+ // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push
155+ // (a second call would fail the Once expectation).
156+ f.push.AssertNumberOfCalls(t, "Push", 1)
157+}
158+
159+func TestMirrorsDivergedPausedAtOnce(t *testing.T) {
160+ f := newMirrorFixture(t)
161+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once()
162+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
163+ require.Error(t, err)
164+ require.Equal(t, domain.MirrorPaused, v.Status.State)
165+ for range 3 {
166+ f.advance(time.Hour)
167+ f.m.ScheduleNow(context.Background())
168+ }
169+ f.drain(t)
170+ f.push.AssertNumberOfCalls(t, "Push", 1)
171+ require.Len(t, f.eventList(), 1)
172+}
173+
174+func TestMirrorsCloseCancelsWorker(t *testing.T) {
175+ f := newMirrorFixture(t)
176+ entered, exited := make(chan struct{}), make(chan struct{})
177+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
178+ close(entered)
179+ <-ctx.Done()
180+ close(exited)
181+ return ports.MirrorPushResult{}, context.Canceled
182+ }).Once()
183+ done := make(chan error, 1)
184+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
185+ <-entered
186+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
187+ defer cancel()
188+ f.m.Close(ctx)
189+ require.NoError(t, ctx.Err())
190+ select {
191+ case <-exited:
192+ default:
193+ t.Fatal("Close returned before worker exited")
194+ }
195+ require.ErrorIs(t, <-done, context.Canceled)
196+ f.m.Close(ctx) // idempotent; Start cannot resurrect workers.
197+ f.m.Start()
198+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
199+ require.ErrorIs(t, err, context.Canceled)
200+}
201+
202+func TestMirrorsCronBackoff(t *testing.T) {
203+ f := newMirrorFixture(t)
204+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Twice()
205+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
206+ require.Error(t, err)
207+ f.advance(time.Minute)
208+ f.m.ScheduleNow(context.Background())
209+ f.drain(t)
210+ f.push.AssertNumberOfCalls(t, "Push", 1)
211+ f.advance(4 * time.Minute)
212+ f.m.ScheduleNow(context.Background())
213+ f.drain(t)
214+ f.push.AssertNumberOfCalls(t, "Push", 2)
215+}
216+
217+func TestMirrorsCronDueAndDirtySkip(t *testing.T) {
218+ f := newMirrorFixture(t)
219+ f.pushes(t, nil)
220+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
221+ require.NoError(t, err)
222+ require.Equal(t, 1, f.refReads())
223+ f.advance(5 * time.Minute)
224+ f.m.ScheduleNow(context.Background()) // due and clean: skipped without reading refs
225+ f.drain(t)
226+ require.Equal(t, 1, f.refReads())
227+ // Merges and default branch changes both end in OnPush: an idempotent dirty mark.
228+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
229+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}})
230+ f.m.ScheduleNow(context.Background()) // same minute: nothing runs, although dirty
231+ f.drain(t)
232+ require.Equal(t, 1, f.refReads())
233+ f.advance(5 * time.Minute)
234+ f.m.ScheduleNow(context.Background())
235+ f.drain(t)
236+ require.Equal(t, 2, f.refReads(), "the dirty repository is examined on the next due tick")
237+}
238+
239+func TestMirrorsScheduleKeepsFullQueueRepositoriesForNextTicks(t *testing.T) {
240+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 })
241+ repos := []domain.RepoName{f.repo, name(t, "b"), name(t, "c"), name(t, "d")}
242+ for _, r := range repos[1:] {
243+ f.addRepo(r)
244+ }
245+ gate := make(chan struct{})
246+ var mu sync.Mutex
247+ pushed := map[string]int{}
248+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) {
249+ <-gate
250+ mu.Lock()
251+ pushed[p.URL]++
252+ mu.Unlock()
253+ return ports.MirrorPushResult{}, nil
254+ })
255+ f.advance(5 * time.Minute)
256+ f.m.ScheduleNow(context.Background())
257+ // The worker holds one repository and the queue one: the other two found it full.
258+ close(gate)
259+ for range 6 { // later ticks of the same minute drain the deferred set
260+ f.drain(t)
261+ f.m.ScheduleNow(context.Background())
262+ }
263+ f.drain(t)
264+ mu.Lock()
265+ defer mu.Unlock()
266+ for _, r := range repos {
267+ require.Equal(t, 1, pushed["git@example.com:acme/"+r.String()], r.String())
268+ }
269+}
270+
271+func TestMirrorsSyncRefusedWhenQueueFull(t *testing.T) {
272+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 })
273+ second, third := name(t, "b"), name(t, "c")
274+ f.addRepo(second)
275+ f.addRepo(third)
276+ gate := make(chan struct{})
277+ entered := make(chan struct{}, 8)
278+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
279+ entered <- struct{}{}
280+ <-gate
281+ return ports.MirrorPushResult{}, nil
282+ })
283+ first := make(chan error, 1)
284+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); first <- err }()
285+ <-entered // the single worker is busy
286+ // Enqueueing is synchronous: this request takes the only queue slot, then waits and gives up.
287+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
288+ defer cancel()
289+ _, err := f.m.Sync(ctx, domain.Root(), second, true)
290+ require.ErrorIs(t, err, context.DeadlineExceeded)
291+ _, err = f.m.Sync(context.Background(), domain.Root(), third, true)
292+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
293+ close(gate)
294+ require.NoError(t, <-first)
295+ f.drain(t)
296+}
297+
298+func TestMirrorsSlowPushTimestamps(t *testing.T) {
299+ f := newMirrorFixture(t)
300+ start := f.at
301+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
302+ f.advance(10 * time.Minute)
303+ return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}
304+ }).Once()
305+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
306+ require.Error(t, err)
307+ require.True(t, v.Status.LastAttemptAt.Equal(start))
308+ require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1))))
309+}
310+
311+func TestMirrorsPushDoesNotHoldConfigLock(t *testing.T) {
312+ f := newMirrorFixture(t)
313+ entered, release := make(chan struct{}), make(chan struct{})
314+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
315+ close(entered)
316+ <-release
317+ return ports.MirrorPushResult{}, nil
318+ }).Once()
319+ done := make(chan error, 1)
320+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
321+ <-entered
322+ changed := make(chan error, 1)
323+ go func() {
324+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
325+ changed <- err
326+ }()
327+ select {
328+ case err := <-changed:
329+ require.NoError(t, err)
330+ case <-time.After(5 * time.Second): // only on failure: the push holds the configuration lock
331+ t.Fatal("configuration change blocked by a running push")
332+ }
333+ close(release)
334+ require.NoError(t, <-done)
335+}
336+
337+func TestMirrorsResultDiscardedWhenTargetChangedDuringPush(t *testing.T) {
338+ f := newMirrorFixture(t)
339+ f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, ConsecutiveFailures: 1})
340+ entered, release := make(chan struct{}), make(chan struct{})
341+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
342+ close(entered)
343+ <-release
344+ return ports.MirrorPushResult{}, nil
345+ }).Once()
346+ done := make(chan error, 1)
347+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
348+ <-entered
349+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{URLTemplate: ptr("git@other.example:acme/{repo}")})
350+ require.NoError(t, err)
351+ close(release)
352+ require.NoError(t, <-done)
353+ _, ok := f.snapshotOf(f.repo)
354+ require.False(t, ok, "no snapshot for a target that no longer applies")
355+ require.Empty(t, f.eventList(), "no mirror.recovered for the old target")
356+ s := f.statusOf(f.repo)
357+ require.Equal(t, domain.MirrorOK, s.State)
358+ require.True(t, s.LastSuccessAt.IsZero(), "the push result was not recorded")
359+}
360+
361+func TestMirrorsSyncAllContinuesPastFailures(t *testing.T) {
362+ f := newMirrorFixture(t)
363+ bad, off := name(t, "bad"), name(t, "off")
364+ f.addRepo(bad)
365+ f.addRepo(off)
366+ f.setRepoConfig(domain.MirrorRepoConfig{Repo: off, Excluded: true})
367+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) {
368+ if strings.HasSuffix(p.URL, "/bad") {
369+ return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged, Detail: "rejected"}
370+ }
371+ return ports.MirrorPushResult{}, nil
372+ })
373+ got, err := f.m.SyncAll(context.Background(), domain.Root(), true)
374+ require.NoError(t, err)
375+ require.Len(t, got, 2, "the excluded repository is skipped")
376+ byRepo := map[domain.RepoName]ports.MirrorSyncResult{}
377+ for _, r := range got {
378+ byRepo[r.Repo] = r
379+ }
380+ require.NoError(t, byRepo[f.repo].Err)
381+ require.Equal(t, domain.MirrorOK, byRepo[f.repo].Status.State)
382+ var me *domain.MirrorError
383+ require.ErrorAs(t, byRepo[bad].Err, &me)
384+ require.Equal(t, domain.MirrorPaused, byRepo[bad].Status.State)
385+ _, listed := byRepo[off]
386+ require.False(t, listed)
387+ _, err = f.m.SyncAll(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin), true)
388+ require.ErrorIs(t, err, domain.ErrForbidden)
389+}
390+
391+func TestMirrorsLocalFailuresAreInternalAndNeverPush(t *testing.T) {
392+ for _, tc := range []struct {
393+ name string
394+ set func(*mirrorFixture)
395+ }{
396+ {"ListRefs", func(f *mirrorFixture) { f.mu.Lock(); f.refsErr = errBoom; f.mu.Unlock() }},
397+ {"DefaultBranch", func(f *mirrorFixture) { f.mu.Lock(); f.branchErr = errBoom; f.mu.Unlock() }},
398+ } {
399+ t.Run(tc.name, func(t *testing.T) {
400+ f := newMirrorFixture(t)
401+ tc.set(f)
402+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) // a Push call would fail the strict mock
403+ var me *domain.MirrorError
404+ require.ErrorAs(t, err, &me)
405+ require.Equal(t, domain.MirrorClassInternal, me.Class)
406+ require.NotContains(t, me.Detail, "boom")
407+ require.Equal(t, domain.MirrorFailing, v.Status.State)
408+ })
409+ }
410+}
411+
412+func TestMirrorsMissingSecretsKeyFailsBeforeReadingRefs(t *testing.T) {
413+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil })
414+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
415+ var me *domain.MirrorError
416+ require.ErrorAs(t, err, &me)
417+ require.Equal(t, domain.MirrorClassInternal, me.Class)
418+ require.Contains(t, me.Detail, "ERMITE_SECRETS_KEY")
419+ require.Equal(t, domain.MirrorFailing, v.Status.State)
420+ require.Zero(t, f.refReads())
421+}
422+
423+func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) {
424+ f := newMirrorFixture(t)
425+ var logs bytes.Buffer
426+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
427+ credentialURL := "ssh://alice:secret-password@example.com/acme/tools"
428+ _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL})
429+ require.Error(t, err)
430+ require.NotContains(t, err.Error(), "secret-password")
431+ require.NotContains(t, err.Error(), "alice")
432+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once()
433+ _, err = f.m.Sync(ctx, domain.Root(), f.repo, true)
434+ require.Error(t, err)
435+ require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER")
436+ require.NotContains(t, err.Error(), "secret-password")
437+ require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER")
438+ require.NotContains(t, logs.String(), "secret-password")
439+}
440+
441+func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) {
442+ f := newKeyFixture(t)
443+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
444+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, true).Return(nil).Once()
445+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once()
446+ first, err := f.m.ShowKey(context.Background(), domain.Root())
447+ require.NoError(t, err)
448+ cached, err := f.m.ShowKey(context.Background(), domain.Root())
449+ require.NoError(t, err)
450+ require.Equal(t, first, cached)
451+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
452+ require.NoError(t, err)
453+ require.Equal(t, first.Fingerprint, rotated.Previous.Fingerprint)
454+ require.NotEqual(t, first.Fingerprint, rotated.Key.Fingerprint)
455+ require.Contains(t, rotated.Key.AuthorizedKey, "ermite-mirror@forge.example.com")
456+ require.Len(t, f.seeds(), 2)
457+ require.NotEqual(t, f.seeds()[0], f.seeds()[1])
458+}
459+
460+func TestMirrorsRotateWithoutExistingKeyGeneratesOnce(t *testing.T) {
461+ f := newKeyFixture(t)
462+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
463+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() // never ifAbsent=true
464+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
465+ require.NoError(t, err)
466+ require.Empty(t, rotated.Previous.Fingerprint)
467+ require.NotEmpty(t, rotated.Key.Fingerprint)
468+ require.Len(t, f.seeds(), 1, "no throwaway key")
469+}
470+
471+func TestMirrorsRotateReplacesUnreadableKey(t *testing.T) {
472+ f := newKeyFixture(t)
473+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("garbage"), now, nil).Once()
474+ f.sealerOpenFails = true
475+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once()
476+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
477+ require.NoError(t, err)
478+ require.Empty(t, rotated.Previous.Fingerprint)
479+}
480+
481+func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) {
482+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil })
483+ _, err := f.m.ShowKey(context.Background(), domain.Anonymous())
484+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
485+ _, err = f.m.ShowKey(context.Background(), domain.Root())
486+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
487+ require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY")
488+ _, err = f.m.RotateKey(context.Background(), domain.Root())
489+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
490+ _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))
491+ require.ErrorIs(t, err, domain.ErrForbidden)
492+}
D internal/usecase/mirrors_test.go +0 -596
@@ -1,596 +0,0 @@
1-// SPDX-License-Identifier: AGPL-3.0-or-later
2-
3-package usecase_test
4-
5-import (
6- "bytes"
7- "context"
8- "crypto/ed25519"
9- "errors"
10- "fmt"
11- "github.com/bnema/zerowrap"
12- "sync"
13- "testing"
14- "time"
15-
16- "bnema.dev/ermite/internal/domain"
17- "bnema.dev/ermite/internal/ports"
18- "bnema.dev/ermite/internal/ports/mocks"
19- "bnema.dev/ermite/internal/usecase"
20- "github.com/stretchr/testify/mock"
21- "github.com/stretchr/testify/require"
22-)
23-
24-type mirrorFixture struct {
25- m *usecase.Mirrors
26- store *mocks.MirrorStore
27- push *mocks.MirrorPusher
28- reader *mocks.RepoReader
29- bus *mocks.EventBus
30- keys *mocks.MirrorKeyStore
31- sealer *mocks.SecretSealer
32- mu sync.Mutex
33- at time.Time
34- status domain.MirrorStatus
35- snapshot domain.MirrorSnapshot
36- events []domain.Event
37- repo domain.RepoName
38-}
39-
40-func newMirrorFixture(t *testing.T) *mirrorFixture {
41- t.Helper()
42- f := &mirrorFixture{store: mocks.NewMirrorStore(t), push: mocks.NewMirrorPusher(t), reader: mocks.NewRepoReader(t), bus: mocks.NewEventBus(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t), at: now, repo: name(t, "tools")}
43- meta, git, clock := mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewClock(t)
44- clock.On("Now").Return(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at })
45- meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil).Maybe()
46- meta.On("List", mock.Anything).Return([]domain.Repo{{Name: f.repo}}, nil).Maybe()
47- f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
48- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound)
49- f.store.On("GetStatus", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorStatus {
50- f.mu.Lock()
51- defer f.mu.Unlock()
52- return f.status
53- }, nil)
54- f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { f.mu.Lock(); defer f.mu.Unlock(); f.status = a.Get(1).(domain.MirrorStatus) }).Return(nil).Maybe()
55- f.store.On("GetSnapshot", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorSnapshot {
56- f.mu.Lock()
57- defer f.mu.Unlock()
58- return f.snapshot
59- }, nil).Maybe()
60- f.store.On("PutSnapshot", mock.Anything, f.repo, mock.Anything).Run(func(a mock.Arguments) {
61- f.mu.Lock()
62- defer f.mu.Unlock()
63- f.snapshot = a.Get(2).(domain.MirrorSnapshot)
64- }).Return(nil).Maybe()
65- f.reader.On("ListRefs", mock.Anything, f.repo).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Maybe()
66- git.On("DefaultBranch", mock.Anything, f.repo).Return("main", true, nil).Maybe()
67- f.bus.On("Publish", mock.Anything, mock.Anything).Run(func(a mock.Arguments) {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- f.events = append(f.events, a.Get(1).(domain.Event))
71- }).Maybe()
72- f.keys.On("GetMirrorKey", mock.Anything).Return([]byte("sealed"), now, nil).Maybe()
73- f.sealer.On("Open", []byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe()
74- f.status = domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK}
75- f.m = usecase.NewMirrors(usecase.MirrorsConfig{Store: f.store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: f.reader, Bus: f.bus, Clock: clock, ScheduleTick: time.Hour})
76- t.Cleanup(func() {
77- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
78- defer cancel()
79- f.m.Close(ctx)
80- })
81- return f
82-}
83-func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() }
84-func TestMirrorsNoOpThrottleForce(t *testing.T) {
85- f := newMirrorFixture(t)
86- f.snapshot = domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}}
87- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
88- require.NoError(t, err)
89- require.True(t, v.NoOp)
90- _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false)
91- require.ErrorIs(t, err, domain.ErrResourceExhausted)
92- f.push.On("Push", mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool {
93- return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main"
94- })).Return(ports.MirrorPushResult{}, nil).Once()
95- v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true)
96- require.NoError(t, err)
97- require.False(t, v.NoOp)
98-}
99-func TestMirrorsIncidentAndRecovery(t *testing.T) {
100- f := newMirrorFixture(t)
101- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5)
102- for i := 1; i <= 5; i++ {
103- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
104- require.Error(t, err)
105- require.Equal(t, i, v.Status.ConsecutiveFailures)
106- if i < 5 {
107- require.Equal(t, domain.MirrorFailing, v.Status.State)
108- require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt))
109- } else {
110- require.Equal(t, domain.MirrorPaused, v.Status.State)
111- }
112- }
113- require.Len(t, f.events, 1)
114- require.IsType(t, domain.MirrorFailed{}, f.events[0])
115- f.advance(24 * time.Hour)
116- f.m.ScheduleNow(context.Background())
117- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
118- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
119- require.NoError(t, err)
120- require.Equal(t, domain.MirrorOK, v.Status.State)
121- require.Len(t, f.events, 2)
122- require.IsType(t, domain.MirrorRecovered{}, f.events[1])
123-}
124-func TestMirrorsSingleFlight(t *testing.T) {
125- f := newMirrorFixture(t)
126- entered, release := make(chan struct{}), make(chan struct{})
127- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once()
128- done := make(chan error, 1)
129- go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
130- <-entered
131- var wg sync.WaitGroup
132- for i := 0; i < 20; i++ {
133- wg.Add(1)
134- go func() {
135- defer wg.Done()
136- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
137- require.NoError(t, err)
138- require.True(t, v.Queued)
139- require.Equal(t, f.repo, v.Status.Repo)
140- }()
141- }
142- wg.Wait()
143- rerun := make(chan struct{})
144- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(rerun) }).Return(ports.MirrorPushResult{}, nil).Once()
145- close(release)
146- require.NoError(t, <-done)
147- select {
148- case <-rerun:
149- case <-time.After(time.Second):
150- t.Fatal("coalesced force was lost")
151- }
152-}
153-func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) {
154- m := usecase.NewMirrors(usecase.MirrorsConfig{Clock: func() *mocks.Clock { c := mocks.NewClock(t); c.On("Now").Return(now); return c }()})
155- _, err := m.ShowKey(context.Background(), domain.Anonymous())
156- require.ErrorIs(t, err, domain.ErrUnauthenticated)
157- _, err = m.ShowKey(context.Background(), domain.Root())
158- require.ErrorIs(t, err, domain.ErrPreconditionFailed)
159- require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY")
160- _, err = m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))
161- require.True(t, errors.Is(err, domain.ErrForbidden))
162-}
163-
164-func mirrorOID(t *testing.T) domain.ObjectID {
165- t.Helper()
166- id, err := domain.ParseObjectID(oidA)
167- require.NoError(t, err)
168- return id
169-}
170-
171-func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) {
172- f := newMirrorFixture(t)
173- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once()
174- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
175- require.Error(t, err)
176- require.Equal(t, domain.MirrorPaused, v.Status.State)
177- for i := 0; i < 3; i++ {
178- f.advance(time.Hour)
179- f.m.ScheduleNow(context.Background())
180- }
181- f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once()
182- _, err = f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools", Cron: "*/5 * * * *"})
183- require.NoError(t, err)
184- f.mu.Lock()
185- s := f.status
186- events := len(f.events)
187- f.mu.Unlock()
188- require.Equal(t, domain.MirrorOK, s.State)
189- require.Zero(t, s.ConsecutiveFailures)
190- require.Equal(t, 1, events)
191-}
192-
193-func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) {
194- keys, sealer, clock := mocks.NewMirrorKeyStore(t), mocks.NewSecretSealer(t), mocks.NewClock(t)
195- clock.On("Now").Return(now)
196- keys.On("GetMirrorKey", mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
197- var seeds [][]byte
198- sealer.On("Seal", mock.Anything, []byte(domain.MirrorKeyPath)).Return(func(seed, aad []byte) []byte {
199- seeds = append(seeds, append([]byte(nil), seed...))
200- return append([]byte("sealed:"), seed...)
201- }).Twice()
202- keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, true).Return(nil).Once()
203- keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, false).Return(nil).Once()
204- m := usecase.NewMirrors(usecase.MirrorsConfig{Keys: keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"})
205- first, err := m.ShowKey(context.Background(), domain.Root())
206- require.NoError(t, err)
207- cached, err := m.ShowKey(context.Background(), domain.Root())
208- require.NoError(t, err)
209- require.Equal(t, first, cached)
210- rotated, err := m.RotateKey(context.Background(), domain.Root())
211- require.NoError(t, err)
212- require.NotEqual(t, first.Fingerprint, rotated.Fingerprint)
213- require.Contains(t, rotated.AuthorizedKey, "ermite-mirror@forge.example.com")
214- require.Len(t, seeds, 2)
215- require.NotEqual(t, seeds[0], seeds[1])
216- require.Len(t, seeds[0], ed25519.SeedSize)
217-}
218-
219-func TestMirrorsPushRecordNotification(t *testing.T) {
220- f := newPushFixture(t)
221- n := name(t, "tools")
222- trigger := mocks.NewMirrorTrigger(t)
223- updates := []domain.RefUpdate{}
224- f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil)
225- f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil)
226- f.bus.On("Publish", mock.Anything, mock.Anything).Return()
227- f.sched.On("Schedule", mock.Anything, n).Return()
228- notified := make(chan struct{})
229- trigger.On("OnPush", mock.Anything, n, updates).Run(func(mock.Arguments) { close(notified) }).Return().Once()
230- require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates))
231- <-notified
232-}
233-
234-func TestMirrorsCronDueAndDirtySkip(t *testing.T) {
235- f := newMirrorFixture(t)
236- // Establish a successful snapshot; subsequent clean cron ticks need no refs.
237- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
238- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
239- require.NoError(t, err)
240- f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
241- f.mu.Lock()
242- f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute)
243- f.mu.Unlock()
244- f.m.ScheduleNow(context.Background())
245- f.advance(4 * time.Minute)
246- f.m.ScheduleNow(context.Background())
247- f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
248- f.m.OnPush(context.Background(), f.repo, nil)
249- read := make(chan struct{})
250- f.reader.ExpectedCalls = nil
251- f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once()
252- // Same minute does not run, even though dirty.
253- f.m.ScheduleNow(context.Background())
254- select {
255- case <-read:
256- t.Fatal("same-minute cron reran")
257- default:
258- }
259- f.advance(5 * time.Minute)
260- f.m.ScheduleNow(context.Background())
261- select {
262- case <-read:
263- case <-time.After(time.Second):
264- t.Fatal("due dirty repository not examined")
265- }
266-}
267-
268-func TestMirrorsAuthorizationMatrix(t *testing.T) {
269- f := newMirrorFixture(t)
270- ctx := context.Background()
271- reader := tokenPrincipal(domain.ScopeRepoRead)
272- admin := tokenPrincipal(domain.ScopeRepoAdmin)
273- _, err := f.m.GetGlobalConfig(ctx, reader)
274- require.ErrorIs(t, err, domain.ErrForbidden)
275- _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{})
276- require.ErrorIs(t, err, domain.ErrForbidden)
277- _, err = f.m.ListStatus(ctx, reader)
278- require.ErrorIs(t, err, domain.ErrForbidden)
279- _, err = f.m.RotateKey(ctx, admin)
280- require.ErrorIs(t, err, domain.ErrForbidden)
281- // Key show deliberately requires authentication, not owner rights.
282- _, err = f.m.ShowKey(ctx, reader)
283- require.NoError(t, err)
284- _, err = f.m.ShowKey(ctx, domain.Anonymous())
285- require.ErrorIs(t, err, domain.ErrUnauthenticated)
286- _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo})
287- require.ErrorIs(t, err, domain.ErrForbidden)
288- err = f.m.RemoveRepoMirror(ctx, reader, f.repo)
289- require.ErrorIs(t, err, domain.ErrForbidden)
290- _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true)
291- require.ErrorIs(t, err, domain.ErrForbidden)
292- _, err = f.m.Sync(ctx, reader, f.repo, false)
293- require.ErrorIs(t, err, domain.ErrForbidden)
294- f.mu.Lock()
295- f.status.Detail, f.status.Reason = "admin detail", "admin reason"
296- f.mu.Unlock()
297- view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo)
298- require.NoError(t, err)
299- require.Empty(t, view.Status.Detail)
300- require.Empty(t, view.Status.Reason)
301- require.Empty(t, view.Target.URL)
302- view, err = f.m.GetStatus(ctx, admin, f.repo)
303- require.NoError(t, err)
304- require.Equal(t, "admin detail", view.Status.Detail)
305- f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once()
306- _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo})
307- require.NoError(t, err)
308-}
309-
310-func TestMirrorsPrivateStatusAuthorization(t *testing.T) {
311- f := newMirrorFixture(t)
312- meta := mocks.NewRepoMetaStore(t)
313- meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPrivate}, nil)
314- clock := mocks.NewClock(t)
315- clock.On("Now").Return(now)
316- m := usecase.NewMirrors(usecase.MirrorsConfig{Meta: meta, Store: f.store, Clock: clock})
317- _, err := m.GetStatus(context.Background(), domain.Anonymous(), f.repo)
318- require.ErrorIs(t, err, domain.ErrNotFound)
319- _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo)
320- require.ErrorIs(t, err, domain.ErrNotFound)
321- _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo)
322- require.NoError(t, err)
323-}
324-
325-func TestMirrorsCloseCancelsWorker(t *testing.T) {
326- f := newMirrorFixture(t)
327- entered, exited := make(chan struct{}), make(chan struct{})
328- f.push.On("Push", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { close(entered); <-a.Get(0).(context.Context).Done(); close(exited) }).Return(ports.MirrorPushResult{}, context.Canceled).Once()
329- done := make(chan error, 1)
330- go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
331- <-entered
332- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
333- defer cancel()
334- f.m.Close(ctx)
335- require.NoError(t, ctx.Err())
336- select {
337- case <-exited:
338- default:
339- t.Fatal("Close returned before worker exited")
340- }
341- require.ErrorIs(t, <-done, context.Canceled)
342- f.m.Close(ctx) // idempotent; Start cannot resurrect workers.
343- f.m.Start()
344- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
345- require.ErrorIs(t, err, context.Canceled)
346-}
347-
348-func TestMirrorsCronBackoff(t *testing.T) {
349- f := newMirrorFixture(t)
350- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
351- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
352- require.Error(t, err)
353- f.advance(time.Minute)
354- f.m.ScheduleNow(context.Background())
355- f.push.AssertNumberOfCalls(t, "Push", 1)
356- attempted := make(chan struct{})
357- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(attempted) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
358- f.advance(4 * time.Minute)
359- f.m.ScheduleNow(context.Background())
360- select {
361- case <-attempted:
362- case <-time.After(time.Second):
363- t.Fatal("retry did not fire at backoff deadline")
364- }
365- // Cleanup joins the worker before mock verification.
366-}
367-
368-func TestMirrorsRecordDoesNotWaitForTrigger(t *testing.T) {
369- f := newPushFixture(t)
370- n := name(t, "tools")
371- trigger := mocks.NewMirrorTrigger(t)
372- entered, release, exited := make(chan struct{}), make(chan struct{}), make(chan struct{})
373- trigger.On("OnPush", mock.Anything, n, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release; close(exited) }).Return().Once()
374- f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil)
375- f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil)
376- f.bus.On("Publish", mock.Anything, mock.Anything).Return()
377- f.sched.On("Schedule", mock.Anything, n).Return()
378- returned := make(chan error, 1)
379- go func() { returned <- f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, nil) }()
380- <-entered
381- select {
382- case err := <-returned:
383- require.NoError(t, err)
384- case <-time.After(time.Second):
385- close(release)
386- <-exited
387- t.Fatal("Record blocked on mirror trigger")
388- }
389- close(release)
390- <-exited
391-}
392-
393-func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) {
394- f := newMirrorFixture(t)
395- var logs bytes.Buffer
396- ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
397- credentialURL := "ssh://alice:secret-password@example.com/acme/tools"
398- _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL})
399- require.Error(t, err)
400- require.NotContains(t, err.Error(), "secret-password")
401- require.NotContains(t, err.Error(), "alice")
402- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once()
403- _, err = f.m.Sync(ctx, domain.Root(), f.repo, true)
404- require.Error(t, err)
405- require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER")
406- require.NotContains(t, err.Error(), "secret-password")
407- require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER")
408- require.NotContains(t, logs.String(), "secret-password")
409-}
410-
411-func TestMirrorsFileURLTestOption(t *testing.T) {
412- for _, allowed := range []bool{false, true} {
413- t.Run(fmt.Sprint(allowed), func(t *testing.T) {
414- store, meta, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewClock(t)
415- repo := name(t, "tools")
416- clock.On("Now").Return(now)
417- meta.On("Get", mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
418- global := domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "file:///tmp/remotes/{repo}", Cron: "*/5 * * * *"}
419- store.On("GetGlobal", mock.Anything).Return(global, nil).Maybe()
420- store.On("GetRepoConfig", mock.Anything, repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe()
421- store.On("GetStatus", mock.Anything, repo).Return(domain.MirrorStatus{Repo: repo}, nil).Maybe()
422- store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Maybe()
423- store.On("PutStatus", mock.Anything, mock.Anything).Return(nil).Maybe()
424- m := usecase.NewMirrors(usecase.MirrorsConfig{Store: store, Meta: meta, Clock: clock, AllowAnyURL: allowed})
425- target, err := m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"})
426- if allowed {
427- require.NoError(t, err)
428- require.Equal(t, "file:///tmp/remotes/tools", target.URL)
429- } else {
430- require.ErrorIs(t, err, domain.ErrInvalid)
431- }
432- })
433- }
434-}
435-
436-func TestMirrorsMergeNotifiesMirror(t *testing.T) {
437- r := newMRRig(t)
438- r.seed()
439- trigger := mocks.NewMirrorTrigger(t)
440- notified := make(chan []domain.RefUpdate, 1)
441- trigger.On("OnPush", mock.Anything, r.repo, mock.Anything).Run(func(a mock.Arguments) { notified <- a.Get(2).([]domain.RefUpdate) }).Return().Once()
442- r.mrs.WithMirror(trigger)
443- head := r.feature("f0")
444- mr := r.open("f0")
445- _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head})
446- require.NoError(t, err)
447- r.mrs.Wait(r.ctx)
448- select {
449- case u := <-notified:
450- require.Equal(t, "refs/heads/main", u[0].Ref)
451- case <-time.After(time.Second):
452- t.Fatal("merge did not notify mirrors")
453- }
454-}
455-
456-func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) {
457- f := newFixture(t)
458- ctx := context.Background()
459- n := name(t, "tools")
460- repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
461- trigger := mocks.NewMirrorTrigger(t)
462- trigger.On("OnPush", mock.Anything, n, mock.Anything).Return().Once()
463- f.repos.WithMirror(trigger)
464- f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
465- f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
466- f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
467- f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
468- _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
469- require.NoError(t, err)
470-}
471-
472-func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) {
473- f := newMirrorFixture(t)
474- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
475- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
476- require.NoError(t, err)
477- f.mu.Lock()
478- f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute)
479- f.mu.Unlock()
480- f.m.ScheduleNow(context.Background())
481- f.advance(4 * time.Minute)
482- f.m.ScheduleNow(context.Background()) // due and clean: skipped
483- f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
484- // A merge and a default-branch change both call OnPush (idempotent dirty mark).
485- f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
486- f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}})
487- read := make(chan struct{})
488- f.reader.ExpectedCalls = nil
489- f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once()
490- f.advance(5 * time.Minute)
491- f.m.ScheduleNow(context.Background())
492- select {
493- case <-read:
494- case <-time.After(time.Second):
495- t.Fatal("dirty repository not synced on the next due tick")
496- }
497-}
498-
499-func setupRepoOverrideFixture(t *testing.T, global domain.MirrorGlobalConfig) *mirrorFixture {
500- f := newMirrorFixture(t)
501- f.store.ExpectedCalls = nil
502- f.store.On("GetGlobal", mock.Anything).Return(global, nil)
503- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe()
504- f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo}, nil).Maybe()
505- return f
506-}
507-
508-func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) {
509- f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{})
510- _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
511- var fe *domain.InvalidFieldsError
512- require.ErrorAs(t, err, &fe)
513- require.Equal(t, "cron", fe.Fields[0].Field)
514- // PutRepoConfig/PutStatus unexpected: strict mock fails if called.
515-}
516-
517-func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) {
518- f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"})
519- f.store.ExpectedCalls = nil
520- f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
521- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
522- // Disabling the global with an empty cron leaves the inheriting override with no cron.
523- _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")})
524- var fe *domain.InvalidFieldsError
525- require.ErrorAs(t, err, &fe)
526-}
527-
528-func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) {
529- f := newMirrorFixture(t)
530- f.store.ExpectedCalls = nil
531- f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
532- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
533- f.store.On("PutGlobal", mock.Anything, mock.Anything).Return(nil).Once()
534- f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}, nil)
535- reset := make(chan domain.MirrorStatus, 1)
536- f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { reset <- a.Get(1).(domain.MirrorStatus) }).Return(nil)
537- _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
538- require.NoError(t, err)
539- require.Equal(t, domain.MirrorOK, (<-reset).State)
540-}
541-
542-func TestMirrorsSetRepoExcludedConcurrent(t *testing.T) {
543- f := newMirrorFixture(t)
544- var mu sync.Mutex
545- active, maxActive := 0, 0
546- f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Run(func(mock.Arguments) {
547- mu.Lock()
548- active++
549- if active > maxActive {
550- maxActive = active
551- }
552- mu.Unlock()
553- time.Sleep(2 * time.Millisecond)
554- mu.Lock()
555- active--
556- mu.Unlock()
557- }).Return(nil)
558- var wg sync.WaitGroup
559- for i := 0; i < 8; i++ {
560- wg.Add(1)
561- go func(ex bool) {
562- defer wg.Done()
563- _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, ex)
564- require.NoError(t, err)
565- }(i%2 == 0)
566- }
567- wg.Wait()
568- require.Equal(t, 1, maxActive, "read-modify-write must be serialized")
569-}
570-
571-func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) {
572- f := newMirrorFixture(t)
573- entered, release := make(chan struct{}), make(chan struct{})
574- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once()
575- done := make(chan error, 1)
576- go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
577- <-entered
578- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
579- require.NoError(t, err)
580- require.True(t, v.Queued)
581- close(release)
582- require.NoError(t, <-done)
583- // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push.
584- time.Sleep(200 * time.Millisecond)
585- f.push.AssertNumberOfCalls(t, "Push", 1)
586-}
587-
588-func TestMirrorsSlowPushTimestamps(t *testing.T) {
589- f := newMirrorFixture(t)
590- start := f.at
591- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { f.advance(10 * time.Minute) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
592- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
593- require.Error(t, err)
594- require.True(t, v.Status.LastAttemptAt.Equal(start))
595- require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1))))
596-}
@@ -1,596 +0,0 @@
1-// SPDX-License-Identifier: AGPL-3.0-or-later
2-
3-package usecase_test
4-
5-import (
6- "bytes"
7- "context"
8- "crypto/ed25519"
9- "errors"
10- "fmt"
11- "github.com/bnema/zerowrap"
12- "sync"
13- "testing"
14- "time"
15-
16- "bnema.dev/ermite/internal/domain"
17- "bnema.dev/ermite/internal/ports"
18- "bnema.dev/ermite/internal/ports/mocks"
19- "bnema.dev/ermite/internal/usecase"
20- "github.com/stretchr/testify/mock"
21- "github.com/stretchr/testify/require"
22-)
23-
24-type mirrorFixture struct {
25- m *usecase.Mirrors
26- store *mocks.MirrorStore
27- push *mocks.MirrorPusher
28- reader *mocks.RepoReader
29- bus *mocks.EventBus
30- keys *mocks.MirrorKeyStore
31- sealer *mocks.SecretSealer
32- mu sync.Mutex
33- at time.Time
34- status domain.MirrorStatus
35- snapshot domain.MirrorSnapshot
36- events []domain.Event
37- repo domain.RepoName
38-}
39-
40-func newMirrorFixture(t *testing.T) *mirrorFixture {
41- t.Helper()
42- f := &mirrorFixture{store: mocks.NewMirrorStore(t), push: mocks.NewMirrorPusher(t), reader: mocks.NewRepoReader(t), bus: mocks.NewEventBus(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t), at: now, repo: name(t, "tools")}
43- meta, git, clock := mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewClock(t)
44- clock.On("Now").Return(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at })
45- meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil).Maybe()
46- meta.On("List", mock.Anything).Return([]domain.Repo{{Name: f.repo}}, nil).Maybe()
47- f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
48- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound)
49- f.store.On("GetStatus", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorStatus {
50- f.mu.Lock()
51- defer f.mu.Unlock()
52- return f.status
53- }, nil)
54- f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { f.mu.Lock(); defer f.mu.Unlock(); f.status = a.Get(1).(domain.MirrorStatus) }).Return(nil).Maybe()
55- f.store.On("GetSnapshot", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorSnapshot {
56- f.mu.Lock()
57- defer f.mu.Unlock()
58- return f.snapshot
59- }, nil).Maybe()
60- f.store.On("PutSnapshot", mock.Anything, f.repo, mock.Anything).Run(func(a mock.Arguments) {
61- f.mu.Lock()
62- defer f.mu.Unlock()
63- f.snapshot = a.Get(2).(domain.MirrorSnapshot)
64- }).Return(nil).Maybe()
65- f.reader.On("ListRefs", mock.Anything, f.repo).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Maybe()
66- git.On("DefaultBranch", mock.Anything, f.repo).Return("main", true, nil).Maybe()
67- f.bus.On("Publish", mock.Anything, mock.Anything).Run(func(a mock.Arguments) {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- f.events = append(f.events, a.Get(1).(domain.Event))
71- }).Maybe()
72- f.keys.On("GetMirrorKey", mock.Anything).Return([]byte("sealed"), now, nil).Maybe()
73- f.sealer.On("Open", []byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe()
74- f.status = domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK}
75- f.m = usecase.NewMirrors(usecase.MirrorsConfig{Store: f.store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: f.reader, Bus: f.bus, Clock: clock, ScheduleTick: time.Hour})
76- t.Cleanup(func() {
77- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
78- defer cancel()
79- f.m.Close(ctx)
80- })
81- return f
82-}
83-func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() }
84-func TestMirrorsNoOpThrottleForce(t *testing.T) {
85- f := newMirrorFixture(t)
86- f.snapshot = domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}}
87- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
88- require.NoError(t, err)
89- require.True(t, v.NoOp)
90- _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false)
91- require.ErrorIs(t, err, domain.ErrResourceExhausted)
92- f.push.On("Push", mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool {
93- return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main"
94- })).Return(ports.MirrorPushResult{}, nil).Once()
95- v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true)
96- require.NoError(t, err)
97- require.False(t, v.NoOp)
98-}
99-func TestMirrorsIncidentAndRecovery(t *testing.T) {
100- f := newMirrorFixture(t)
101- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5)
102- for i := 1; i <= 5; i++ {
103- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
104- require.Error(t, err)
105- require.Equal(t, i, v.Status.ConsecutiveFailures)
106- if i < 5 {
107- require.Equal(t, domain.MirrorFailing, v.Status.State)
108- require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt))
109- } else {
110- require.Equal(t, domain.MirrorPaused, v.Status.State)
111- }
112- }
113- require.Len(t, f.events, 1)
114- require.IsType(t, domain.MirrorFailed{}, f.events[0])
115- f.advance(24 * time.Hour)
116- f.m.ScheduleNow(context.Background())
117- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
118- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
119- require.NoError(t, err)
120- require.Equal(t, domain.MirrorOK, v.Status.State)
121- require.Len(t, f.events, 2)
122- require.IsType(t, domain.MirrorRecovered{}, f.events[1])
123-}
124-func TestMirrorsSingleFlight(t *testing.T) {
125- f := newMirrorFixture(t)
126- entered, release := make(chan struct{}), make(chan struct{})
127- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once()
128- done := make(chan error, 1)
129- go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
130- <-entered
131- var wg sync.WaitGroup
132- for i := 0; i < 20; i++ {
133- wg.Add(1)
134- go func() {
135- defer wg.Done()
136- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
137- require.NoError(t, err)
138- require.True(t, v.Queued)
139- require.Equal(t, f.repo, v.Status.Repo)
140- }()
141- }
142- wg.Wait()
143- rerun := make(chan struct{})
144- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(rerun) }).Return(ports.MirrorPushResult{}, nil).Once()
145- close(release)
146- require.NoError(t, <-done)
147- select {
148- case <-rerun:
149- case <-time.After(time.Second):
150- t.Fatal("coalesced force was lost")
151- }
152-}
153-func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) {
154- m := usecase.NewMirrors(usecase.MirrorsConfig{Clock: func() *mocks.Clock { c := mocks.NewClock(t); c.On("Now").Return(now); return c }()})
155- _, err := m.ShowKey(context.Background(), domain.Anonymous())
156- require.ErrorIs(t, err, domain.ErrUnauthenticated)
157- _, err = m.ShowKey(context.Background(), domain.Root())
158- require.ErrorIs(t, err, domain.ErrPreconditionFailed)
159- require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY")
160- _, err = m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))
161- require.True(t, errors.Is(err, domain.ErrForbidden))
162-}
163-
164-func mirrorOID(t *testing.T) domain.ObjectID {
165- t.Helper()
166- id, err := domain.ParseObjectID(oidA)
167- require.NoError(t, err)
168- return id
169-}
170-
171-func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) {
172- f := newMirrorFixture(t)
173- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once()
174- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
175- require.Error(t, err)
176- require.Equal(t, domain.MirrorPaused, v.Status.State)
177- for i := 0; i < 3; i++ {
178- f.advance(time.Hour)
179- f.m.ScheduleNow(context.Background())
180- }
181- f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once()
182- _, err = f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools", Cron: "*/5 * * * *"})
183- require.NoError(t, err)
184- f.mu.Lock()
185- s := f.status
186- events := len(f.events)
187- f.mu.Unlock()
188- require.Equal(t, domain.MirrorOK, s.State)
189- require.Zero(t, s.ConsecutiveFailures)
190- require.Equal(t, 1, events)
191-}
192-
193-func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) {
194- keys, sealer, clock := mocks.NewMirrorKeyStore(t), mocks.NewSecretSealer(t), mocks.NewClock(t)
195- clock.On("Now").Return(now)
196- keys.On("GetMirrorKey", mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
197- var seeds [][]byte
198- sealer.On("Seal", mock.Anything, []byte(domain.MirrorKeyPath)).Return(func(seed, aad []byte) []byte {
199- seeds = append(seeds, append([]byte(nil), seed...))
200- return append([]byte("sealed:"), seed...)
201- }).Twice()
202- keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, true).Return(nil).Once()
203- keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, false).Return(nil).Once()
204- m := usecase.NewMirrors(usecase.MirrorsConfig{Keys: keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"})
205- first, err := m.ShowKey(context.Background(), domain.Root())
206- require.NoError(t, err)
207- cached, err := m.ShowKey(context.Background(), domain.Root())
208- require.NoError(t, err)
209- require.Equal(t, first, cached)
210- rotated, err := m.RotateKey(context.Background(), domain.Root())
211- require.NoError(t, err)
212- require.NotEqual(t, first.Fingerprint, rotated.Fingerprint)
213- require.Contains(t, rotated.AuthorizedKey, "ermite-mirror@forge.example.com")
214- require.Len(t, seeds, 2)
215- require.NotEqual(t, seeds[0], seeds[1])
216- require.Len(t, seeds[0], ed25519.SeedSize)
217-}
218-
219-func TestMirrorsPushRecordNotification(t *testing.T) {
220- f := newPushFixture(t)
221- n := name(t, "tools")
222- trigger := mocks.NewMirrorTrigger(t)
223- updates := []domain.RefUpdate{}
224- f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil)
225- f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil)
226- f.bus.On("Publish", mock.Anything, mock.Anything).Return()
227- f.sched.On("Schedule", mock.Anything, n).Return()
228- notified := make(chan struct{})
229- trigger.On("OnPush", mock.Anything, n, updates).Run(func(mock.Arguments) { close(notified) }).Return().Once()
230- require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates))
231- <-notified
232-}
233-
234-func TestMirrorsCronDueAndDirtySkip(t *testing.T) {
235- f := newMirrorFixture(t)
236- // Establish a successful snapshot; subsequent clean cron ticks need no refs.
237- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
238- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
239- require.NoError(t, err)
240- f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
241- f.mu.Lock()
242- f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute)
243- f.mu.Unlock()
244- f.m.ScheduleNow(context.Background())
245- f.advance(4 * time.Minute)
246- f.m.ScheduleNow(context.Background())
247- f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
248- f.m.OnPush(context.Background(), f.repo, nil)
249- read := make(chan struct{})
250- f.reader.ExpectedCalls = nil
251- f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once()
252- // Same minute does not run, even though dirty.
253- f.m.ScheduleNow(context.Background())
254- select {
255- case <-read:
256- t.Fatal("same-minute cron reran")
257- default:
258- }
259- f.advance(5 * time.Minute)
260- f.m.ScheduleNow(context.Background())
261- select {
262- case <-read:
263- case <-time.After(time.Second):
264- t.Fatal("due dirty repository not examined")
265- }
266-}
267-
268-func TestMirrorsAuthorizationMatrix(t *testing.T) {
269- f := newMirrorFixture(t)
270- ctx := context.Background()
271- reader := tokenPrincipal(domain.ScopeRepoRead)
272- admin := tokenPrincipal(domain.ScopeRepoAdmin)
273- _, err := f.m.GetGlobalConfig(ctx, reader)
274- require.ErrorIs(t, err, domain.ErrForbidden)
275- _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{})
276- require.ErrorIs(t, err, domain.ErrForbidden)
277- _, err = f.m.ListStatus(ctx, reader)
278- require.ErrorIs(t, err, domain.ErrForbidden)
279- _, err = f.m.RotateKey(ctx, admin)
280- require.ErrorIs(t, err, domain.ErrForbidden)
281- // Key show deliberately requires authentication, not owner rights.
282- _, err = f.m.ShowKey(ctx, reader)
283- require.NoError(t, err)
284- _, err = f.m.ShowKey(ctx, domain.Anonymous())
285- require.ErrorIs(t, err, domain.ErrUnauthenticated)
286- _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo})
287- require.ErrorIs(t, err, domain.ErrForbidden)
288- err = f.m.RemoveRepoMirror(ctx, reader, f.repo)
289- require.ErrorIs(t, err, domain.ErrForbidden)
290- _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true)
291- require.ErrorIs(t, err, domain.ErrForbidden)
292- _, err = f.m.Sync(ctx, reader, f.repo, false)
293- require.ErrorIs(t, err, domain.ErrForbidden)
294- f.mu.Lock()
295- f.status.Detail, f.status.Reason = "admin detail", "admin reason"
296- f.mu.Unlock()
297- view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo)
298- require.NoError(t, err)
299- require.Empty(t, view.Status.Detail)
300- require.Empty(t, view.Status.Reason)
301- require.Empty(t, view.Target.URL)
302- view, err = f.m.GetStatus(ctx, admin, f.repo)
303- require.NoError(t, err)
304- require.Equal(t, "admin detail", view.Status.Detail)
305- f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once()
306- _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo})
307- require.NoError(t, err)
308-}
309-
310-func TestMirrorsPrivateStatusAuthorization(t *testing.T) {
311- f := newMirrorFixture(t)
312- meta := mocks.NewRepoMetaStore(t)
313- meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPrivate}, nil)
314- clock := mocks.NewClock(t)
315- clock.On("Now").Return(now)
316- m := usecase.NewMirrors(usecase.MirrorsConfig{Meta: meta, Store: f.store, Clock: clock})
317- _, err := m.GetStatus(context.Background(), domain.Anonymous(), f.repo)
318- require.ErrorIs(t, err, domain.ErrNotFound)
319- _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo)
320- require.ErrorIs(t, err, domain.ErrNotFound)
321- _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo)
322- require.NoError(t, err)
323-}
324-
325-func TestMirrorsCloseCancelsWorker(t *testing.T) {
326- f := newMirrorFixture(t)
327- entered, exited := make(chan struct{}), make(chan struct{})
328- f.push.On("Push", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { close(entered); <-a.Get(0).(context.Context).Done(); close(exited) }).Return(ports.MirrorPushResult{}, context.Canceled).Once()
329- done := make(chan error, 1)
330- go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
331- <-entered
332- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
333- defer cancel()
334- f.m.Close(ctx)
335- require.NoError(t, ctx.Err())
336- select {
337- case <-exited:
338- default:
339- t.Fatal("Close returned before worker exited")
340- }
341- require.ErrorIs(t, <-done, context.Canceled)
342- f.m.Close(ctx) // idempotent; Start cannot resurrect workers.
343- f.m.Start()
344- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
345- require.ErrorIs(t, err, context.Canceled)
346-}
347-
348-func TestMirrorsCronBackoff(t *testing.T) {
349- f := newMirrorFixture(t)
350- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
351- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
352- require.Error(t, err)
353- f.advance(time.Minute)
354- f.m.ScheduleNow(context.Background())
355- f.push.AssertNumberOfCalls(t, "Push", 1)
356- attempted := make(chan struct{})
357- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(attempted) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
358- f.advance(4 * time.Minute)
359- f.m.ScheduleNow(context.Background())
360- select {
361- case <-attempted:
362- case <-time.After(time.Second):
363- t.Fatal("retry did not fire at backoff deadline")
364- }
365- // Cleanup joins the worker before mock verification.
366-}
367-
368-func TestMirrorsRecordDoesNotWaitForTrigger(t *testing.T) {
369- f := newPushFixture(t)
370- n := name(t, "tools")
371- trigger := mocks.NewMirrorTrigger(t)
372- entered, release, exited := make(chan struct{}), make(chan struct{}), make(chan struct{})
373- trigger.On("OnPush", mock.Anything, n, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release; close(exited) }).Return().Once()
374- f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil)
375- f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil)
376- f.bus.On("Publish", mock.Anything, mock.Anything).Return()
377- f.sched.On("Schedule", mock.Anything, n).Return()
378- returned := make(chan error, 1)
379- go func() { returned <- f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, nil) }()
380- <-entered
381- select {
382- case err := <-returned:
383- require.NoError(t, err)
384- case <-time.After(time.Second):
385- close(release)
386- <-exited
387- t.Fatal("Record blocked on mirror trigger")
388- }
389- close(release)
390- <-exited
391-}
392-
393-func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) {
394- f := newMirrorFixture(t)
395- var logs bytes.Buffer
396- ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
397- credentialURL := "ssh://alice:secret-password@example.com/acme/tools"
398- _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL})
399- require.Error(t, err)
400- require.NotContains(t, err.Error(), "secret-password")
401- require.NotContains(t, err.Error(), "alice")
402- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once()
403- _, err = f.m.Sync(ctx, domain.Root(), f.repo, true)
404- require.Error(t, err)
405- require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER")
406- require.NotContains(t, err.Error(), "secret-password")
407- require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER")
408- require.NotContains(t, logs.String(), "secret-password")
409-}
410-
411-func TestMirrorsFileURLTestOption(t *testing.T) {
412- for _, allowed := range []bool{false, true} {
413- t.Run(fmt.Sprint(allowed), func(t *testing.T) {
414- store, meta, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewClock(t)
415- repo := name(t, "tools")
416- clock.On("Now").Return(now)
417- meta.On("Get", mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
418- global := domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "file:///tmp/remotes/{repo}", Cron: "*/5 * * * *"}
419- store.On("GetGlobal", mock.Anything).Return(global, nil).Maybe()
420- store.On("GetRepoConfig", mock.Anything, repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe()
421- store.On("GetStatus", mock.Anything, repo).Return(domain.MirrorStatus{Repo: repo}, nil).Maybe()
422- store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Maybe()
423- store.On("PutStatus", mock.Anything, mock.Anything).Return(nil).Maybe()
424- m := usecase.NewMirrors(usecase.MirrorsConfig{Store: store, Meta: meta, Clock: clock, AllowAnyURL: allowed})
425- target, err := m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"})
426- if allowed {
427- require.NoError(t, err)
428- require.Equal(t, "file:///tmp/remotes/tools", target.URL)
429- } else {
430- require.ErrorIs(t, err, domain.ErrInvalid)
431- }
432- })
433- }
434-}
435-
436-func TestMirrorsMergeNotifiesMirror(t *testing.T) {
437- r := newMRRig(t)
438- r.seed()
439- trigger := mocks.NewMirrorTrigger(t)
440- notified := make(chan []domain.RefUpdate, 1)
441- trigger.On("OnPush", mock.Anything, r.repo, mock.Anything).Run(func(a mock.Arguments) { notified <- a.Get(2).([]domain.RefUpdate) }).Return().Once()
442- r.mrs.WithMirror(trigger)
443- head := r.feature("f0")
444- mr := r.open("f0")
445- _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head})
446- require.NoError(t, err)
447- r.mrs.Wait(r.ctx)
448- select {
449- case u := <-notified:
450- require.Equal(t, "refs/heads/main", u[0].Ref)
451- case <-time.After(time.Second):
452- t.Fatal("merge did not notify mirrors")
453- }
454-}
455-
456-func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) {
457- f := newFixture(t)
458- ctx := context.Background()
459- n := name(t, "tools")
460- repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
461- trigger := mocks.NewMirrorTrigger(t)
462- trigger.On("OnPush", mock.Anything, n, mock.Anything).Return().Once()
463- f.repos.WithMirror(trigger)
464- f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
465- f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
466- f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
467- f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
468- _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
469- require.NoError(t, err)
470-}
471-
472-func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) {
473- f := newMirrorFixture(t)
474- f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
475- _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
476- require.NoError(t, err)
477- f.mu.Lock()
478- f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute)
479- f.mu.Unlock()
480- f.m.ScheduleNow(context.Background())
481- f.advance(4 * time.Minute)
482- f.m.ScheduleNow(context.Background()) // due and clean: skipped
483- f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
484- // A merge and a default-branch change both call OnPush (idempotent dirty mark).
485- f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
486- f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}})
487- read := make(chan struct{})
488- f.reader.ExpectedCalls = nil
489- f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once()
490- f.advance(5 * time.Minute)
491- f.m.ScheduleNow(context.Background())
492- select {
493- case <-read:
494- case <-time.After(time.Second):
495- t.Fatal("dirty repository not synced on the next due tick")
496- }
497-}
498-
499-func setupRepoOverrideFixture(t *testing.T, global domain.MirrorGlobalConfig) *mirrorFixture {
500- f := newMirrorFixture(t)
501- f.store.ExpectedCalls = nil
502- f.store.On("GetGlobal", mock.Anything).Return(global, nil)
503- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe()
504- f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo}, nil).Maybe()
505- return f
506-}
507-
508-func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) {
509- f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{})
510- _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
511- var fe *domain.InvalidFieldsError
512- require.ErrorAs(t, err, &fe)
513- require.Equal(t, "cron", fe.Fields[0].Field)
514- // PutRepoConfig/PutStatus unexpected: strict mock fails if called.
515-}
516-
517-func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) {
518- f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"})
519- f.store.ExpectedCalls = nil
520- f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
521- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
522- // Disabling the global with an empty cron leaves the inheriting override with no cron.
523- _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")})
524- var fe *domain.InvalidFieldsError
525- require.ErrorAs(t, err, &fe)
526-}
527-
528-func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) {
529- f := newMirrorFixture(t)
530- f.store.ExpectedCalls = nil
531- f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
532- f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
533- f.store.On("PutGlobal", mock.Anything, mock.Anything).Return(nil).Once()
534- f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}, nil)
535- reset := make(chan domain.MirrorStatus, 1)
536- f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { reset <- a.Get(1).(domain.MirrorStatus) }).Return(nil)
537- _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
538- require.NoError(t, err)
539- require.Equal(t, domain.MirrorOK, (<-reset).State)
540-}
541-
542-func TestMirrorsSetRepoExcludedConcurrent(t *testing.T) {
543- f := newMirrorFixture(t)
544- var mu sync.Mutex
545- active, maxActive := 0, 0
546- f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Run(func(mock.Arguments) {
547- mu.Lock()
548- active++
549- if active > maxActive {
550- maxActive = active
551- }
552- mu.Unlock()
553- time.Sleep(2 * time.Millisecond)
554- mu.Lock()
555- active--
556- mu.Unlock()
557- }).Return(nil)
558- var wg sync.WaitGroup
559- for i := 0; i < 8; i++ {
560- wg.Add(1)
561- go func(ex bool) {
562- defer wg.Done()
563- _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, ex)
564- require.NoError(t, err)
565- }(i%2 == 0)
566- }
567- wg.Wait()
568- require.Equal(t, 1, maxActive, "read-modify-write must be serialized")
569-}
570-
571-func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) {
572- f := newMirrorFixture(t)
573- entered, release := make(chan struct{}), make(chan struct{})
574- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once()
575- done := make(chan error, 1)
576- go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
577- <-entered
578- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
579- require.NoError(t, err)
580- require.True(t, v.Queued)
581- close(release)
582- require.NoError(t, <-done)
583- // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push.
584- time.Sleep(200 * time.Millisecond)
585- f.push.AssertNumberOfCalls(t, "Push", 1)
586-}
587-
588-func TestMirrorsSlowPushTimestamps(t *testing.T) {
589- f := newMirrorFixture(t)
590- start := f.at
591- f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { f.advance(10 * time.Minute) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
592- v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
593- require.Error(t, err)
594- require.True(t, v.Status.LastAttemptAt.Equal(start))
595- require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1))))
596-}
M internal/usecase/pushes.go +1 -2
@@ -270,8 +270,7 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
270270 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
271271 })
272272 if u.mirror != nil {
273- updates := append(make([]domain.RefUpdate, 0, len(updates)), updates...)
274- go u.mirror.OnPush(ctx, name, updates)
273+ u.mirror.OnPush(ctx, name, updates) // marks only: never blocks
275274 }
276275 u.maint.Schedule(ctx, name)
277276 log.Info().
@@ -270,8 +270,7 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
270 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,270 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
271 })271 })
272 if u.mirror != nil {272 if u.mirror != nil {
273- updates := append(make([]domain.RefUpdate, 0, len(updates)), updates...)273+ u.mirror.OnPush(ctx, name, updates) // marks only: never blocks
274- go u.mirror.OnPush(ctx, name, updates)
275 }274 }
276 u.maint.Schedule(ctx, name)275 u.maint.Schedule(ctx, name)
277 log.Info().276 log.Info().

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close