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}