1// SPDX-License-Identifier: AGPL-3.0-or-later
2package domain
3
4import (
5 "crypto/ed25519"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/binary"
9 "errors"
10 "strings"
11 "testing"
12 "time"
13)
14
15func TestMirrorURL(t *testing.T) {
16 for _, tt := range []struct {
17 s string
18 ok bool
19 }{
20 {"git@host:acme/repo", true}, {"host:repo", true}, {"ssh://git@host:2222/acme/repo", true}, {"ssh://[::1]/repo", true},
21 {"https://host/repo", false}, {"file:///repo", false}, {"git@host:-path", false}, {"ssh://host/-path", false}, {":repo", false}, {"ssh:///repo", false}, {"ssh://git:secret@host/repo", false}, {"user:secret@host:repo", false}, {"host:repo\n", false}, {"ssh://host:0/repo", false},
22 {"-oProxyCommand=x@h:p", false}, {"-oProxyCommand=x@h:p/q", false}, {"ssh://-ofoo@h/p", false}, {"ssh://-oHost/p", false}, {"-h:repo", false}, {"git@-h:repo", false}, {"ssh://git@-h/repo", false},
23 } {
24 t.Run(tt.s, func(t *testing.T) {
25 e := ValidateMirrorURL(tt.s)
26 if (e == nil) != tt.ok {
27 t.Fatalf("error %v", e)
28 }
29 if e != nil && !errors.Is(e, ErrInvalid) {
30 t.Fatal(e)
31 }
32 })
33 }
34 if got := RedactMirrorURL("ssh://git@host:2222/acme/repo"); got != "host:2222:acme/repo" {
35 t.Fatal(got)
36 }
37 if strings.Contains(RedactMirrorURL("ssh://git:secret@host/repo"), "secret") {
38 t.Fatal("credential leak")
39 }
40}
41func TestMirrorResolution(t *testing.T) {
42 repo, _ := ParseRepoName("demo")
43 off := false
44 for _, enabled := range []bool{false, true} {
45 for _, kind := range []string{"none", "exclude", "override"} {
46 t.Run(kind+map[bool]string{true: "on", false: "off"}[enabled], func(t *testing.T) {
47 g := MirrorGlobalConfig{Enabled: enabled, URLTemplate: "git@host:org/{repo}", Cron: "*/5 * * * *", AllBranches: true}
48 var r *MirrorRepoConfig
49 want := MirrorSourceNone
50 if enabled {
51 want = MirrorSourceGlobal
52 }
53 if kind == "exclude" {
54 r = &MirrorRepoConfig{Excluded: true}
55 want = MirrorSourceExcluded
56 }
57 if kind == "override" {
58 r = &MirrorRepoConfig{URL: "git@other:demo", Cron: "0 3 * * *", AllBranches: &off, Excluded: true}
59 want = MirrorSourceOverride
60 }
61 got, e := ResolveMirror(g, r, repo)
62 if e != nil || got.Source != want || got.Enabled != (want == MirrorSourceGlobal || want == MirrorSourceOverride) {
63 t.Fatalf("%+v %v", got, e)
64 }
65 if want == MirrorSourceOverride && (got.AllBranches || got.CronSpec != "0 3 * * *") {
66 t.Fatal(got)
67 }
68 if want == MirrorSourceGlobal && got.URL != "git@host:org/demo" {
69 t.Fatal(got)
70 }
71 })
72 }
73 }
74 if _, e := ExpandMirrorURL("host:path", repo); !errors.Is(e, ErrInvalid) {
75 t.Fatal(e)
76 }
77}
78func TestMirrorCronValidation(t *testing.T) {
79 for _, tt := range []struct {
80 spec string
81 gap time.Duration
82 valid bool
83 }{{"*/5 * * * *", 5 * time.Minute, true}, {"*/4 * * * *", 4 * time.Minute, false}, {"0 3 * * *", 24 * time.Hour, true}, {"0 0 29 2 *", 0, true}, {"bad", 0, false},
84 {"0,59 0,23 1,31 1,12 *", time.Minute, false}, // 31 Dec 23:59 and 1 Jan 00:00
85 {"0,59 0,23 1,28 2,3 *", time.Minute, false}, // 28 Feb 23:59 and 1 Mar 00:00 of a non-leap year
86 {"0,59 0,23 29 2 *", 59 * time.Minute, true},
87 } {
88 t.Run(tt.spec, func(t *testing.T) {
89 c, e := ParseCron(tt.spec)
90 if e == nil && CronMinGap(c) != tt.gap {
91 t.Fatal(CronMinGap(c))
92 }
93 g := MirrorGlobalConfig{Enabled: true, URLTemplate: "git@host:{repo}", Cron: tt.spec}
94 if (ValidateMirrorGlobalConfig(g) == nil) != tt.valid {
95 t.Fatal("global validation")
96 }
97 if (ValidateMirrorRepoConfig(MirrorRepoConfig{Cron: tt.spec}) == nil) != tt.valid {
98 t.Fatal("repo validation")
99 }
100 })
101 }
102 for _, g := range []MirrorGlobalConfig{{}, {URLTemplate: "https://host/{repo}"}, {Enabled: true}, {URLTemplate: "host:path"}} {
103 e := ValidateMirrorGlobalConfig(g)
104 if g == (MirrorGlobalConfig{}) {
105 if e != nil {
106 t.Fatal(e)
107 }
108 } else {
109 var fields *InvalidFieldsError
110 if !errors.As(e, &fields) {
111 t.Fatalf("%v", e)
112 }
113 }
114 }
115}
116func TestMirrorNextCron(t *testing.T) {
117 loc, _ := time.LoadLocation("America/New_York")
118 c, _ := ParseCron("30 2 * * *")
119 start := time.Date(2024, 3, 10, 1, 59, 30, 0, loc)
120 got, ok := NextCron(CronSchedule{Cron: c, Loc: loc}, start)
121 want := time.Date(2024, 3, 10, 3, 0, 0, 0, loc)
122 if !ok || !got.Equal(want) {
123 t.Fatal(got, ok)
124 }
125 c, _ = ParseCron("* * * * *")
126 got, ok = NextCron(CronSchedule{Cron: c}, start)
127 if !ok || !got.Equal(start.Truncate(time.Minute).Add(time.Minute)) {
128 t.Fatal(got)
129 }
130 if _, ok := NextCron(CronSchedule{}, start); ok {
131 t.Fatal("empty cron fired")
132 }
133 // A wall clock repeated by a fall-back transition fires once.
134 c, _ = ParseCron("30 1 * * *")
135 got, ok = NextCron(CronSchedule{Cron: c, Loc: loc}, time.Date(2024, 11, 3, 5, 30, 0, 0, time.UTC))
136 if want := time.Date(2024, 11, 4, 1, 30, 0, 0, loc); !ok || !got.Equal(want) {
137 t.Fatal(got, ok)
138 }
139 // A rare entry is found within a year, not beyond.
140 c, _ = ParseCron("0 0 29 2 *")
141 got, ok = NextCron(CronSchedule{Cron: c}, time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC))
142 if ok {
143 t.Fatal("Feb 29 is more than a year away", got)
144 }
145 got, ok = NextCron(CronSchedule{Cron: c}, time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC))
146 if want := time.Date(2028, 2, 29, 0, 0, 0, 0, time.UTC); !ok || !got.Equal(want) {
147 t.Fatal(got, ok)
148 }
149}
150func TestMirrorTransitions(t *testing.T) {
151 now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
152 for _, class := range []MirrorFailureClass{MirrorClassDiverged, MirrorClassAuthDenied, MirrorClassRemoteNotFound, MirrorClassHostKey, MirrorClassTransient, MirrorClassInternal} {
153 t.Run(string(class), func(t *testing.T) {
154 p := MirrorStatus{State: MirrorOK}
155 fatal := mirrorFatal(class)
156 for n := 1; n <= 6; n++ {
157 tr := ApplyMirrorOutcome(p, MirrorOutcome{Err: &MirrorError{Class: class, Detail: "detail"}}, now)
158 want := MirrorFailing
159 if fatal || n >= 5 {
160 want = MirrorPaused
161 }
162 if tr.Status.State != want || tr.Status.ConsecutiveFailures != n || tr.FailedEvent != (n == 1) {
163 t.Fatalf("n=%d %+v", n, tr)
164 }
165 if want == MirrorFailing && !tr.Status.NextRetryAt.Equal(now.Add(MirrorBackoff(n))) {
166 t.Fatal(tr)
167 }
168 if want == MirrorPaused && (!tr.Status.NextRetryAt.IsZero() || tr.Status.PausedAt.IsZero()) {
169 t.Fatal(tr)
170 }
171 p = tr.Status
172 }
173 for _, noop := range []bool{false, true} {
174 tr := ApplyMirrorOutcome(p, MirrorOutcome{NoOp: noop}, now)
175 if !tr.Recovered || tr.FailedEvent || tr.Status.State != MirrorOK || tr.Status.ConsecutiveFailures != 0 || !tr.Status.LastSuccessAt.Equal(now) || tr.Status.Detail != "" {
176 t.Fatal(tr)
177 }
178 if ApplyMirrorOutcome(tr.Status, MirrorOutcome{}, now).Recovered {
179 t.Fatal("repeat recovery")
180 }
181 }
182 for _, state := range []MirrorState{MirrorOK, MirrorDisabled} {
183 tr := ApplyMirrorOutcome(MirrorStatus{State: state}, MirrorOutcome{Err: &MirrorError{Class: class}}, now)
184 if !tr.FailedEvent {
185 t.Fatal(tr)
186 }
187 }
188 if r := MirrorReset(p, now); r.State != MirrorOK || r.Class != "" || !r.PausedAt.IsZero() || r.ConsecutiveFailures != 0 {
189 t.Fatal(r)
190 }
191 if d := MirrorDisable(p, now); d.State != MirrorDisabled || d.Reason != "" {
192 t.Fatal(d)
193 }
194 wantErr := ErrUnavailable
195 if fatal {
196 wantErr = ErrPreconditionFailed
197 }
198 if !errors.Is(&MirrorError{Class: class}, wantErr) {
199 t.Fatal("unwrap")
200 }
201 })
202 }
203 for _, tt := range []struct {
204 n int
205 want time.Duration
206 }{{1, 5 * time.Minute}, {2, 10 * time.Minute}, {3, 20 * time.Minute}, {7, 320 * time.Minute}, {8, 6 * time.Hour}, {1000000, 6 * time.Hour}} {
207 if got := MirrorBackoff(tt.n); got != tt.want {
208 t.Fatal(tt, got)
209 }
210 }
211}
212func TestMirrorClassify(t *testing.T) {
213 for _, tt := range []struct {
214 s string
215 c MirrorFailureClass
216 }{{"non-fast-forward", MirrorClassDiverged}, {"fetch first", MirrorClassDiverged}, {"Permission denied (publickey)", MirrorClassAuthDenied}, {"Repository not found", MirrorClassRemoteNotFound}, {"does not appear to be a git repository", MirrorClassRemoteNotFound}, {"REMOTE HOST IDENTIFICATION HAS CHANGED", MirrorClassHostKey}, {"Host key verification failed", MirrorClassHostKey}, {"timeout", MirrorClassTransient},
217 {"Permission denied (publickey,password).", MirrorClassAuthDenied}, {"Permission denied (password,publickey,keyboard-interactive).", MirrorClassAuthDenied},
218 {"ERROR: Permission to x/y.git denied to deploy key", MirrorClassAuthDenied}, {"remote: Permission to x/y.git denied to someone.", MirrorClassAuthDenied},
219 } {
220 t.Run(tt.s, func(t *testing.T) {
221 if c := ClassifyMirrorOutput(tt.s, errors.New("exit"), false); c != tt.c {
222 t.Fatal(c)
223 }
224 })
225 }
226 if ClassifyMirrorOutput("", nil, false) != MirrorClassNone || ClassifyMirrorOutput("non-fast-forward", nil, true) != MirrorClassTransient {
227 t.Fatal("classification")
228 }
229}
230func TestMirrorRecoveryFromAnyState(t *testing.T) {
231 now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
232 for _, state := range []MirrorState{MirrorDisabled, MirrorOK, MirrorFailing, MirrorPaused} {
233 for _, noop := range []bool{false, true} {
234 tr := ApplyMirrorOutcome(MirrorStatus{State: state, ConsecutiveFailures: 2, Class: MirrorClassTransient, Detail: "old"}, MirrorOutcome{NoOp: noop}, now)
235 if tr.Recovered != (state == MirrorFailing || state == MirrorPaused) || tr.FailedEvent || tr.Status.ConsecutiveFailures != 0 || tr.Status.Class != "" || tr.Status.Reason != "" || !tr.Status.NextRetryAt.IsZero() {
236 t.Fatal(state, tr)
237 }
238 }
239 }
240}
241func TestMirrorRefs(t *testing.T) {
242 id, _ := ParseObjectID(strings.Repeat("a", 40))
243 refs := []Ref{{Name: "HEAD", ID: id}, {Name: "refs/heads/main", ID: id}, {Name: "refs/heads/other", ID: id}, {Name: "refs/tags/v1", ID: id}, {Name: "refs/pull/1", ID: id}, {Name: "refs/heads/unborn"}, {Name: "refs/tags/\xff", ID: id}, {Name: "refs/heads/\xff", ID: id}}
244 for _, all := range []bool{false, true} {
245 got := MirrorRefspecs(refs, "main", all)
246 n := 2
247 if all {
248 n = 3
249 }
250 if len(got) != n {
251 t.Fatal(got)
252 }
253 for i, r := range got {
254 if strings.Contains(r.Name+":"+r.Name, "+") || i > 0 && got[i-1].Name >= r.Name {
255 t.Fatal(got)
256 }
257 }
258 snap := MirrorSnapshot{URL: "host:repo", Refs: got, AllBranches: all}
259 rev := append([]MirrorRef(nil), got...)
260 rev[0], rev[len(rev)-1] = rev[len(rev)-1], rev[0]
261 if !snap.Equal(snap.URL, rev, all) || snap.Equal("other", rev, all) || snap.Equal(snap.URL, rev, !all) || snap.Equal(snap.URL, rev[1:], all) {
262 t.Fatal("snapshot")
263 }
264 rev[0].ID = ObjectID{}
265 if snap.Equal(snap.URL, rev, all) {
266 t.Fatal("changed ID")
267 }
268 }
269}
270func TestMirrorKey(t *testing.T) {
271 for _, tt := range []struct{ url, want string }{{"https://forge.example:443/path", "ermite-mirror@forge.example"}, {"", "ermite-mirror@localhost"}, {":bad", "ermite-mirror@localhost"}} {
272 if got := MirrorKeyComment(tt.url); got != tt.want {
273 t.Fatal(got)
274 }
275 }
276 pub := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)).Public().(ed25519.PublicKey)
277 now := time.Now()
278 key := MirrorPublicKeyOf(pub, "forge.example", now)
279 parts := strings.Fields(key.AuthorizedKey)
280 if len(parts) != 3 || parts[0] != "ssh-ed25519" || parts[2] != "ermite-mirror@forge.example" || !key.CreatedAt.Equal(now) {
281 t.Fatal(key)
282 }
283 wire, e := base64.StdEncoding.DecodeString(parts[1])
284 if e != nil || binary.BigEndian.Uint32(wire[:4]) != 11 || string(wire[4:15]) != "ssh-ed25519" || binary.BigEndian.Uint32(wire[15:19]) != 32 || string(wire[19:]) != string(pub) {
285 t.Fatal("wire encoding")
286 }
287 hash := sha256.Sum256(wire)
288 if key.Fingerprint != "SHA256:"+base64.RawStdEncoding.EncodeToString(hash[:]) {
289 t.Fatal(key)
290 }
291}
292
293func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
294 g := MirrorGlobalConfig{Enabled: true, URLTemplate: "host:{repo}", Cron: "* * * * *"}
295 repo, _ := ParseRepoName("demo")
296 if _, err := ResolveMirror(g, nil, repo); err != nil {
297 t.Fatal(err)
298 }
299 if ValidateMirrorGlobalConfig(g) == nil {
300 t.Fatal("mutation must still reject short gap")
301 }
302}