v0.12.1 7328df5

repos_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"errors"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/assert"
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"
17	"bnema.dev/ermite/internal/ports/mocks"
18	"bnema.dev/ermite/internal/usecase"
19)
20
21var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
22
23type fixture struct {
24	meta  *mocks.RepoMetaStore
25	git   *mocks.GitRepos
26	clock *mocks.Clock
27	bus   *mocks.EventBus
28	repos *usecase.Repos
29}
30
31func newFixture(t *testing.T) *fixture {
32	t.Helper()
33	f := &fixture{
34		meta:  mocks.NewRepoMetaStore(t),
35		git:   mocks.NewGitRepos(t),
36		clock: mocks.NewClock(t),
37		bus:   mocks.NewEventBus(t),
38	}
39	f.clock.EXPECT().Now().Return(now).Maybe()
40	f.repos = usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
41	return f
42}
43
44func name(t *testing.T, s string) domain.RepoName {
45	t.Helper()
46	n, err := domain.ParseRepoName(s)
47	require.NoError(t, err)
48	return n
49}
50
51func TestCreate(t *testing.T) {
52	t.Parallel()
53	ctx := context.Background()
54	errBoom := errors.New("boom")
55
56	t.Run("success is private by default", func(t *testing.T) {
57		t.Parallel()
58		f := newFixture(t)
59		n := name(t, "tools")
60		want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
61		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
62		f.meta.EXPECT().Create(ctx, want).Return(nil)
63		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
64		f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
65
66		got, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n, Description: "d"})
67		require.NoError(t, err)
68		assert.Equal(t, want, got)
69	})
70
71	t.Run("anonymous is refused before anything runs", func(t *testing.T) {
72		t.Parallel()
73		f := newFixture(t)
74		_, err := f.repos.Create(ctx, domain.Anonymous(), ports.CreateRepoParams{Name: name(t, "tools")})
75		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
76	})
77
78	t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) {
79		for _, p := range []domain.Principal{
80			tokenPrincipal(domain.ScopeRepoRead),
81			tokenPrincipal(domain.ScopeRepoWrite, "other"),
82		} {
83			f := newFixture(t)
84			_, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
85			assert.ErrorIs(t, err, domain.ErrForbidden)
86		}
87	})
88
89	t.Run("a token limited to branches may not create a repository", func(t *testing.T) {
90		t.Parallel()
91		f := newFixture(t) // the mocks expect no call: nothing is staged
92		for _, p := range []domain.Principal{
93			branchLimited(domain.ScopeRepoWrite),
94			branchLimited(domain.ScopeRepoWrite, "tools"),
95		} {
96			_, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
97			assert.ErrorIs(t, err, domain.ErrForbidden)
98		}
99	})
100
101	t.Run("token with repo:write creates", func(t *testing.T) {
102		t.Parallel()
103		f := newFixture(t)
104		n := name(t, "tools")
105		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
106		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
107		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
108		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
109		_, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), ports.CreateRepoParams{Name: n})
110		require.NoError(t, err)
111	})
112
113	t.Run("zero name is invalid", func(t *testing.T) {
114		t.Parallel()
115		f := newFixture(t)
116		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{})
117		assert.ErrorIs(t, err, domain.ErrInvalidName)
118	})
119
120	t.Run("unknown visibility is invalid", func(t *testing.T) {
121		t.Parallel()
122		f := newFixture(t)
123		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools"), Visibility: "secret"})
124		assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
125	})
126
127	t.Run("stage failure stores nothing", func(t *testing.T) {
128		t.Parallel()
129		f := newFixture(t)
130		f.git.EXPECT().Stage(ctx).Return("", errBoom)
131		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools")})
132		assert.ErrorIs(t, err, errBoom)
133	})
134
135	t.Run("existing name discards the staged repository", func(t *testing.T) {
136		t.Parallel()
137		f := newFixture(t)
138		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
139		f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
140		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
141		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools")})
142		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
143	})
144
145	t.Run("publish conflict removes metadata and staging", func(t *testing.T) {
146		t.Parallel()
147		f := newFixture(t)
148		n := name(t, "tools")
149		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
150		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
151		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(domain.ErrAlreadyExists)
152		f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
153		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
154		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n})
155		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
156	})
157
158	t.Run("failed publish removes metadata and staging", func(t *testing.T) {
159		t.Parallel()
160		f := newFixture(t)
161		n := name(t, "tools")
162		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
163		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
164		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom)
165		f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
166		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
167		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n})
168		assert.ErrorIs(t, err, errBoom)
169	})
170
171	t.Run("cleanup survives a canceled context", func(t *testing.T) {
172		t.Parallel()
173		f := newFixture(t)
174		n := name(t, "tools")
175		cctx, cancel := context.WithCancel(ctx)
176		f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil)
177		f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil)
178		f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n).
179			RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
180				cancel()
181				return errBoom
182			})
183		f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error {
184			return c.Err()
185		})
186		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error {
187			return c.Err()
188		})
189		_, err := f.repos.Create(cctx, domain.Root(), ports.CreateRepoParams{Name: n})
190		assert.ErrorIs(t, err, errBoom)
191	})
192}
193
194// scopedToken is the principal of a token with one scope, limited to the
195// repositories and to the branch prefixes given (none means no limit).
196func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal {
197	tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes}
198	for _, r := range repos {
199		n, err := domain.ParseRepoName(r)
200		if err != nil {
201			panic(err)
202		}
203		tok.Repos = append(tok.Repos, n)
204	}
205	return domain.TokenPrincipal(tok)
206}
207
208func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
209	return scopedToken(s, nil, repos...)
210}
211
212// branchLimited is a token with one scope, optionally limited to repositories,
213// and limited to the branches under "agent/".
214func branchLimited(s domain.Scope, repos ...string) domain.Principal {
215	return scopedToken(s, []string{"agent/"}, repos...)
216}
217
218func TestGetAndListAccess(t *testing.T) {
219	t.Parallel()
220	ctx := context.Background()
221	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
222	priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
223
224	tests := []struct {
225		name      string
226		principal domain.Principal
227		get       domain.Repo
228		wantErr   error
229	}{
230		{"anonymous reads public", domain.Anonymous(), pub, nil},
231		{"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
232		{"root reads public", domain.Root(), pub, nil},
233		{"root reads private", domain.Root(), priv, nil},
234		{"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil},
235		{"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil},
236		{"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound},
237		{"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
238		{"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil},
239		{"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil},
240	}
241	for _, tt := range tests {
242		t.Run("get/"+tt.name, func(t *testing.T) {
243			t.Parallel()
244			f := newFixture(t)
245			f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil)
246			got, err := f.repos.Get(ctx, tt.principal, tt.get.Name)
247			if tt.wantErr != nil {
248				assert.ErrorIs(t, err, tt.wantErr)
249				assert.Zero(t, got)
250				return
251			}
252			require.NoError(t, err)
253			assert.Equal(t, tt.get, got)
254		})
255	}
256
257	t.Run("get missing is not found", func(t *testing.T) {
258		t.Parallel()
259		f := newFixture(t)
260		f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound)
261		_, err := f.repos.Get(ctx, domain.Root(), pub.Name)
262		assert.ErrorIs(t, err, domain.ErrNotFound)
263	})
264
265	t.Run("private missing and hidden look the same", func(t *testing.T) {
266		t.Parallel()
267		f := newFixture(t)
268		f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
269		_, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name)
270		f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
271		_, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone"))
272		assert.Equal(t, errMissing, errHidden)
273	})
274
275	listTests := []struct {
276		name      string
277		principal domain.Principal
278		want      []domain.Repo
279	}{
280		{"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}},
281		{"root lists all", domain.Root(), []domain.Repo{priv, pub}},
282	}
283	for _, tt := range listTests {
284		t.Run("list/"+tt.name, func(t *testing.T) {
285			t.Parallel()
286			f := newFixture(t)
287			f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil)
288			got, err := f.repos.List(ctx, tt.principal)
289			require.NoError(t, err)
290			assert.Equal(t, tt.want, got)
291		})
292	}
293
294	t.Run("list error", func(t *testing.T) {
295		t.Parallel()
296		f := newFixture(t)
297		boom := errors.New("boom")
298		f.meta.EXPECT().List(ctx).Return(nil, boom)
299		_, err := f.repos.List(ctx, domain.Root())
300		assert.ErrorIs(t, err, boom)
301	})
302}
303
304func TestReconcile(t *testing.T) {
305	t.Parallel()
306	ctx := context.Background()
307
308	t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) {
309		t.Parallel()
310		f := newFixture(t)
311		orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost")
312		f.git.EXPECT().PurgeStaging(ctx).Return(nil)
313		f.git.EXPECT().List(ctx).Return(ports.RepoDirs{
314			Names:   []domain.RepoName{orphan, kept},
315			Invalid: []string{"Bad Name.git"},
316		}, nil)
317		f.meta.EXPECT().List(ctx).Return([]domain.Repo{
318			{Name: ghost, Visibility: domain.VisibilityPublic},
319			{Name: kept, Visibility: domain.VisibilityPublic},
320		}, nil)
321		f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil)
322
323		rep, err := f.repos.Reconcile(ctx)
324		require.NoError(t, err)
325		assert.Equal(t, []domain.RepoName{orphan}, rep.Registered)
326		assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs)
327		assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs)
328	})
329
330	t.Run("purge failure stops", func(t *testing.T) {
331		t.Parallel()
332		f := newFixture(t)
333		boom := errors.New("boom")
334		f.git.EXPECT().PurgeStaging(ctx).Return(boom)
335		_, err := f.repos.Reconcile(ctx)
336		assert.ErrorIs(t, err, boom)
337	})
338
339	t.Run("register failure stops", func(t *testing.T) {
340		t.Parallel()
341		f := newFixture(t)
342		boom := errors.New("boom")
343		orphan := name(t, "orphan")
344		f.git.EXPECT().PurgeStaging(ctx).Return(nil)
345		f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil)
346		f.meta.EXPECT().List(ctx).Return(nil, nil)
347		f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom)
348		_, err := f.repos.Reconcile(ctx)
349		assert.ErrorIs(t, err, boom)
350	})
351}

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