v0.34.0 7c1886f

mirror_test.go RawHistoryFolder

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}

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 3

Tags 43

↑↓ moveenter openesc close