v0.31.0 d5e0505

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("topics are normalized and stored", func(t *testing.T) {
72		t.Parallel()
73		f := newFixture(t)
74		n := name(t, "tools")
75		want := domain.Repo{Name: n, Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
76		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
77		f.meta.EXPECT().Create(ctx, want).Return(nil)
78		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
79		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
80
81		got, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n, Topics: []string{"Go", "cli", "go"}})
82		require.NoError(t, err)
83		assert.Equal(t, want, got)
84	})
85
86	t.Run("an invalid topic is refused before anything runs", func(t *testing.T) {
87		t.Parallel()
88		f := newFixture(t)
89		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools"), Topics: []string{"bad topic"}})
90		assert.ErrorIs(t, err, domain.ErrInvalid)
91	})
92
93	t.Run("anonymous is refused before anything runs", func(t *testing.T) {
94		t.Parallel()
95		f := newFixture(t)
96		_, err := f.repos.Create(ctx, domain.Anonymous(), ports.CreateRepoParams{Name: name(t, "tools")})
97		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
98	})
99
100	t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) {
101		for _, p := range []domain.Principal{
102			tokenPrincipal(domain.ScopeRepoRead),
103			tokenPrincipal(domain.ScopeRepoWrite, "other"),
104		} {
105			f := newFixture(t)
106			_, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
107			assert.ErrorIs(t, err, domain.ErrForbidden)
108		}
109	})
110
111	t.Run("a token limited to branches may not create a repository", func(t *testing.T) {
112		t.Parallel()
113		f := newFixture(t) // the mocks expect no call: nothing is staged
114		for _, p := range []domain.Principal{
115			branchLimited(domain.ScopeRepoWrite),
116			branchLimited(domain.ScopeRepoWrite, "tools"),
117		} {
118			_, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
119			assert.ErrorIs(t, err, domain.ErrForbidden)
120		}
121	})
122
123	t.Run("token with repo:write creates", func(t *testing.T) {
124		t.Parallel()
125		f := newFixture(t)
126		n := name(t, "tools")
127		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
128		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
129		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
130		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
131		_, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), ports.CreateRepoParams{Name: n})
132		require.NoError(t, err)
133	})
134
135	t.Run("zero name is invalid", func(t *testing.T) {
136		t.Parallel()
137		f := newFixture(t)
138		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{})
139		assert.ErrorIs(t, err, domain.ErrInvalidName)
140	})
141
142	t.Run("unknown visibility is invalid", func(t *testing.T) {
143		t.Parallel()
144		f := newFixture(t)
145		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools"), Visibility: "secret"})
146		assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
147	})
148
149	t.Run("stage failure stores nothing", func(t *testing.T) {
150		t.Parallel()
151		f := newFixture(t)
152		f.git.EXPECT().Stage(ctx).Return("", errBoom)
153		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools")})
154		assert.ErrorIs(t, err, errBoom)
155	})
156
157	t.Run("existing name discards the staged repository", func(t *testing.T) {
158		t.Parallel()
159		f := newFixture(t)
160		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
161		f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
162		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
163		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools")})
164		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
165	})
166
167	t.Run("publish conflict removes metadata and staging", func(t *testing.T) {
168		t.Parallel()
169		f := newFixture(t)
170		n := name(t, "tools")
171		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
172		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
173		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(domain.ErrAlreadyExists)
174		f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
175		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
176		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n})
177		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
178	})
179
180	t.Run("failed publish removes metadata and staging", func(t *testing.T) {
181		t.Parallel()
182		f := newFixture(t)
183		n := name(t, "tools")
184		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
185		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
186		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom)
187		f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
188		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
189		_, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n})
190		assert.ErrorIs(t, err, errBoom)
191	})
192
193	t.Run("cleanup survives a canceled context", func(t *testing.T) {
194		t.Parallel()
195		f := newFixture(t)
196		n := name(t, "tools")
197		cctx, cancel := context.WithCancel(ctx)
198		f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil)
199		f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil)
200		f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n).
201			RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
202				cancel()
203				return errBoom
204			})
205		f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error {
206			return c.Err()
207		})
208		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error {
209			return c.Err()
210		})
211		_, err := f.repos.Create(cctx, domain.Root(), ports.CreateRepoParams{Name: n})
212		assert.ErrorIs(t, err, errBoom)
213	})
214}
215
216// scopedToken is the principal of a token with one scope, limited to the
217// repositories and to the branch prefixes given (none means no limit).
218func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal {
219	tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes}
220	for _, r := range repos {
221		n, err := domain.ParseRepoName(r)
222		if err != nil {
223			panic(err)
224		}
225		tok.Repos = append(tok.Repos, n)
226	}
227	return domain.TokenPrincipal(tok)
228}
229
230func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
231	return scopedToken(s, nil, repos...)
232}
233
234// branchLimited is a token with one scope, optionally limited to repositories,
235// and limited to the branches under "agent/".
236func branchLimited(s domain.Scope, repos ...string) domain.Principal {
237	return scopedToken(s, []string{"agent/"}, repos...)
238}
239
240func TestGetAndListAccess(t *testing.T) {
241	t.Parallel()
242	ctx := context.Background()
243	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
244	priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
245
246	tests := []struct {
247		name      string
248		principal domain.Principal
249		get       domain.Repo
250		wantErr   error
251	}{
252		{"anonymous reads public", domain.Anonymous(), pub, nil},
253		{"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
254		{"root reads public", domain.Root(), pub, nil},
255		{"root reads private", domain.Root(), priv, nil},
256		{"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil},
257		{"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil},
258		{"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound},
259		{"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
260		{"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil},
261		{"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil},
262	}
263	for _, tt := range tests {
264		t.Run("get/"+tt.name, func(t *testing.T) {
265			t.Parallel()
266			f := newFixture(t)
267			f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil)
268			got, err := f.repos.Get(ctx, tt.principal, tt.get.Name)
269			if tt.wantErr != nil {
270				assert.ErrorIs(t, err, tt.wantErr)
271				assert.Zero(t, got)
272				return
273			}
274			require.NoError(t, err)
275			assert.Equal(t, tt.get, got)
276		})
277	}
278
279	t.Run("get missing is not found", func(t *testing.T) {
280		t.Parallel()
281		f := newFixture(t)
282		f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound)
283		_, err := f.repos.Get(ctx, domain.Root(), pub.Name)
284		assert.ErrorIs(t, err, domain.ErrNotFound)
285	})
286
287	t.Run("private missing and hidden look the same", func(t *testing.T) {
288		t.Parallel()
289		f := newFixture(t)
290		f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
291		_, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name)
292		f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
293		_, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone"))
294		assert.Equal(t, errMissing, errHidden)
295	})
296
297	listTests := []struct {
298		name      string
299		principal domain.Principal
300		want      []domain.Repo
301	}{
302		{"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}},
303		{"root lists all", domain.Root(), []domain.Repo{priv, pub}},
304	}
305	for _, tt := range listTests {
306		t.Run("list/"+tt.name, func(t *testing.T) {
307			t.Parallel()
308			f := newFixture(t)
309			f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil)
310			got, err := f.repos.List(ctx, tt.principal)
311			require.NoError(t, err)
312			assert.Equal(t, tt.want, got)
313		})
314	}
315
316	t.Run("list error", func(t *testing.T) {
317		t.Parallel()
318		f := newFixture(t)
319		boom := errors.New("boom")
320		f.meta.EXPECT().List(ctx).Return(nil, boom)
321		_, err := f.repos.List(ctx, domain.Root())
322		assert.ErrorIs(t, err, boom)
323	})
324}
325
326func TestReconcile(t *testing.T) {
327	t.Parallel()
328	ctx := context.Background()
329
330	t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) {
331		t.Parallel()
332		f := newFixture(t)
333		orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost")
334		f.git.EXPECT().PurgeStaging(ctx).Return(nil)
335		f.git.EXPECT().List(ctx).Return(ports.RepoDirs{
336			Names:   []domain.RepoName{orphan, kept},
337			Invalid: []string{"Bad Name.git"},
338		}, nil)
339		f.meta.EXPECT().List(ctx).Return([]domain.Repo{
340			{Name: ghost, Visibility: domain.VisibilityPublic},
341			{Name: kept, Visibility: domain.VisibilityPublic},
342		}, nil)
343		f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil)
344
345		rep, err := f.repos.Reconcile(ctx)
346		require.NoError(t, err)
347		assert.Equal(t, []domain.RepoName{orphan}, rep.Registered)
348		assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs)
349		assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs)
350	})
351
352	t.Run("purge failure stops", func(t *testing.T) {
353		t.Parallel()
354		f := newFixture(t)
355		boom := errors.New("boom")
356		f.git.EXPECT().PurgeStaging(ctx).Return(boom)
357		_, err := f.repos.Reconcile(ctx)
358		assert.ErrorIs(t, err, boom)
359	})
360
361	t.Run("register failure stops", func(t *testing.T) {
362		t.Parallel()
363		f := newFixture(t)
364		boom := errors.New("boom")
365		orphan := name(t, "orphan")
366		f.git.EXPECT().PurgeStaging(ctx).Return(nil)
367		f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil)
368		f.meta.EXPECT().List(ctx).Return(nil, nil)
369		f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom)
370		_, err := f.repos.Reconcile(ctx)
371		assert.ErrorIs(t, err, boom)
372	})
373}

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