1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_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/app"
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"
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 *app.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 = app.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 ctx := context.Background()
53 errBoom := errors.New("boom")
54
55 t.Run("success is private by default", func(t *testing.T) {
56 f := newFixture(t)
57 n := name(t, "tools")
58 want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
59 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
60 f.meta.EXPECT().Create(ctx, want).Return(nil)
61 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
62 f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
63
64 got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"})
65 require.NoError(t, err)
66 assert.Equal(t, want, got)
67 })
68
69 t.Run("anonymous is refused before anything runs", func(t *testing.T) {
70 f := newFixture(t)
71 _, err := f.repos.Create(ctx, domain.Anonymous(), app.CreateParams{Name: name(t, "tools")})
72 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
73 })
74
75 t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) {
76 for _, p := range []domain.Principal{
77 tokenPrincipal(domain.ScopeRepoRead),
78 tokenPrincipal(domain.ScopeRepoWrite, "other"),
79 } {
80 f := newFixture(t)
81 _, err := f.repos.Create(ctx, p, app.CreateParams{Name: name(t, "tools")})
82 assert.ErrorIs(t, err, domain.ErrForbidden)
83 }
84 })
85
86 t.Run("token with repo:write creates", func(t *testing.T) {
87 f := newFixture(t)
88 n := name(t, "tools")
89 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
90 f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
91 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
92 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
93 _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n})
94 require.NoError(t, err)
95 })
96
97 t.Run("zero name is invalid", func(t *testing.T) {
98 f := newFixture(t)
99 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{})
100 assert.ErrorIs(t, err, domain.ErrInvalidName)
101 })
102
103 t.Run("unknown visibility is invalid", func(t *testing.T) {
104 f := newFixture(t)
105 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools"), Visibility: "secret"})
106 assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
107 })
108
109 t.Run("stage failure stores nothing", func(t *testing.T) {
110 f := newFixture(t)
111 f.git.EXPECT().Stage(ctx).Return("", errBoom)
112 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
113 assert.ErrorIs(t, err, errBoom)
114 })
115
116 t.Run("existing name discards the staged repository", func(t *testing.T) {
117 f := newFixture(t)
118 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
119 f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
120 f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
121 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
122 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
123 })
124
125 t.Run("publish conflict removes metadata and staging", func(t *testing.T) {
126 f := newFixture(t)
127 n := name(t, "tools")
128 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
129 f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
130 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(domain.ErrAlreadyExists)
131 f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
132 f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
133 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
134 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
135 })
136
137 t.Run("failed publish removes metadata and staging", func(t *testing.T) {
138 f := newFixture(t)
139 n := name(t, "tools")
140 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
141 f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
142 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom)
143 f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
144 f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
145 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
146 assert.ErrorIs(t, err, errBoom)
147 })
148
149 t.Run("cleanup survives a canceled context", func(t *testing.T) {
150 f := newFixture(t)
151 n := name(t, "tools")
152 cctx, cancel := context.WithCancel(ctx)
153 f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil)
154 f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil)
155 f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n).
156 RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
157 cancel()
158 return errBoom
159 })
160 f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error {
161 return c.Err()
162 })
163 f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error {
164 return c.Err()
165 })
166 _, err := f.repos.Create(cctx, domain.Root(), app.CreateParams{Name: n})
167 assert.ErrorIs(t, err, errBoom)
168 })
169}
170
171func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
172 tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}}
173 for _, r := range repos {
174 n, err := domain.ParseRepoName(r)
175 if err != nil {
176 panic(err)
177 }
178 tok.Repos = append(tok.Repos, n)
179 }
180 return domain.TokenPrincipal(tok)
181}
182
183func TestGetAndListAccess(t *testing.T) {
184 ctx := context.Background()
185 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
186 priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
187
188 tests := []struct {
189 name string
190 principal domain.Principal
191 get domain.Repo
192 wantErr error
193 }{
194 {"anonymous reads public", domain.Anonymous(), pub, nil},
195 {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
196 {"root reads public", domain.Root(), pub, nil},
197 {"root reads private", domain.Root(), priv, nil},
198 {"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil},
199 {"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil},
200 {"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound},
201 {"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
202 {"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil},
203 {"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil},
204 }
205 for _, tt := range tests {
206 t.Run("get/"+tt.name, func(t *testing.T) {
207 f := newFixture(t)
208 f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil)
209 got, err := f.repos.Get(ctx, tt.principal, tt.get.Name)
210 if tt.wantErr != nil {
211 assert.ErrorIs(t, err, tt.wantErr)
212 assert.Zero(t, got)
213 return
214 }
215 require.NoError(t, err)
216 assert.Equal(t, tt.get, got)
217 })
218 }
219
220 t.Run("get missing is not found", func(t *testing.T) {
221 f := newFixture(t)
222 f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound)
223 _, err := f.repos.Get(ctx, domain.Root(), pub.Name)
224 assert.ErrorIs(t, err, domain.ErrNotFound)
225 })
226
227 t.Run("private missing and hidden look the same", func(t *testing.T) {
228 f := newFixture(t)
229 f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
230 _, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name)
231 f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
232 _, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone"))
233 assert.Equal(t, errMissing, errHidden)
234 })
235
236 listTests := []struct {
237 name string
238 principal domain.Principal
239 want []domain.Repo
240 }{
241 {"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}},
242 {"root lists all", domain.Root(), []domain.Repo{priv, pub}},
243 }
244 for _, tt := range listTests {
245 t.Run("list/"+tt.name, func(t *testing.T) {
246 f := newFixture(t)
247 f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil)
248 got, err := f.repos.List(ctx, tt.principal)
249 require.NoError(t, err)
250 assert.Equal(t, tt.want, got)
251 })
252 }
253
254 t.Run("list error", func(t *testing.T) {
255 f := newFixture(t)
256 boom := errors.New("boom")
257 f.meta.EXPECT().List(ctx).Return(nil, boom)
258 _, err := f.repos.List(ctx, domain.Root())
259 assert.ErrorIs(t, err, boom)
260 })
261}
262
263func TestReconcile(t *testing.T) {
264 ctx := context.Background()
265
266 t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) {
267 f := newFixture(t)
268 orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost")
269 f.git.EXPECT().PurgeStaging(ctx).Return(nil)
270 f.git.EXPECT().List(ctx).Return(ports.RepoDirs{
271 Names: []domain.RepoName{orphan, kept},
272 Invalid: []string{"Bad Name.git"},
273 }, nil)
274 f.meta.EXPECT().List(ctx).Return([]domain.Repo{
275 {Name: ghost, Visibility: domain.VisibilityPublic},
276 {Name: kept, Visibility: domain.VisibilityPublic},
277 }, nil)
278 f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil)
279
280 rep, err := f.repos.Reconcile(ctx)
281 require.NoError(t, err)
282 assert.Equal(t, []domain.RepoName{orphan}, rep.Registered)
283 assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs)
284 assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs)
285 })
286
287 t.Run("purge failure stops", func(t *testing.T) {
288 f := newFixture(t)
289 boom := errors.New("boom")
290 f.git.EXPECT().PurgeStaging(ctx).Return(boom)
291 _, err := f.repos.Reconcile(ctx)
292 assert.ErrorIs(t, err, boom)
293 })
294
295 t.Run("register failure stops", func(t *testing.T) {
296 f := newFixture(t)
297 boom := errors.New("boom")
298 orphan := name(t, "orphan")
299 f.git.EXPECT().PurgeStaging(ctx).Return(nil)
300 f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil)
301 f.meta.EXPECT().List(ctx).Return(nil, nil)
302 f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom)
303 _, err := f.repos.Reconcile(ctx)
304 assert.ErrorIs(t, err, boom)
305 })
306}