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}