v0.34.0 7c1886f

mirrors_config_test.go RawHistoryFolder

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

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