1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21func ptr[T any](v T) *T { return &v }
22
23func TestUpdate(t *testing.T) {
24 t.Parallel()
25 ctx := context.Background()
26 n := name(t, "tools")
27 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
28 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
29
30 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
31 t.Parallel()
32 f := newFixture(t)
33 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
34 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
35 f.meta.EXPECT().Update(ctx, want).Return(nil)
36 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
37 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
38 }).Return().Once()
39
40 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{
41 Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
42 })
43 require.NoError(t, err)
44 assert.Equal(t, want, got)
45 })
46
47 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
48 t.Parallel()
49 f := newFixture(t)
50 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
51 want := priv
52 want.Topics = []string{"cli", "go"}
53 want.Settings = domain.DefaultRepoSettings()
54 f.meta.EXPECT().Update(ctx, want).Return(nil)
55 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
56 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
57 }).Return().Once()
58
59 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{"Go", "cli", "go"}})
60 require.NoError(t, err)
61 assert.Equal(t, want, got)
62 })
63
64 t.Run("an empty list clears the topics", func(t *testing.T) {
65 t.Parallel()
66 f := newFixture(t)
67 withTopics := priv
68 withTopics.Topics = []string{"go"}
69 f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()
70 want := priv
71 want.Settings = domain.DefaultRepoSettings()
72 f.meta.EXPECT().Update(ctx, want).Return(nil)
73 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
74
75 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
76 require.NoError(t, err)
77 assert.Nil(t, got.Topics)
78 })
79
80 t.Run("a lost race on the metadata is retried", func(t *testing.T) {
81 t.Parallel()
82 f := newFixture(t)
83 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
84 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
85 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
86 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
87 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})
88 require.NoError(t, err)
89 })
90
91 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
92 t.Parallel()
93 f := newFixture(t)
94 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
95 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
96 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
97 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
98 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
99 require.NoError(t, err)
100 assert.Equal(t, priv, got)
101 })
102
103 t.Run("metadata is written before HEAD moves, and the event follows both", func(t *testing.T) {
104 t.Parallel()
105 f := newFixture(t)
106 var order []string
107 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
108 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
109 f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {
110 order = append(order, "meta")
111 return nil
112 })
113 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
114 order = append(order, "head")
115 return nil
116 })
117 f.bus.EXPECT().Publish(ctx, mock.Anything).Run(func(context.Context, domain.Event) { order = append(order, "event") }).Return().Once()
118 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x"), DefaultBranch: ptr("dev")})
119 require.NoError(t, err)
120 assert.Equal(t, []string{"meta", "head", "event"}, order)
121 })
122
123 t.Run("a HEAD that cannot move after the metadata was stored still publishes, then fails", func(t *testing.T) {
124 t.Parallel()
125 f := newFixture(t)
126 boom := errors.New("boom")
127 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
128 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
129 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
130 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
131 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
132 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
133 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
134 }).Return().Once()
135 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Visibility: ptr(domain.VisibilityPrivate), DefaultBranch: ptr("dev")})
136 assert.ErrorIs(t, err, boom)
137 })
138
139 t.Run("a HEAD that cannot move with no metadata change publishes nothing", func(t *testing.T) {
140 t.Parallel()
141 f := newFixture(t)
142 boom := errors.New("boom")
143 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
144 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
145 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
146 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
147 assert.ErrorIs(t, err, boom)
148 })
149
150 t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) {
151 t.Parallel()
152 f := newFixture(t)
153 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
154 f.git.EXPECT().HasBranch(ctx, n, "nope").Return(false, nil)
155 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x"), DefaultBranch: ptr("nope")})
156 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
157 })
158
159 t.Run("invalid input changes nothing", func(t *testing.T) {
160 tooMany := make([]string, 0, domain.MaxTopics+1)
161 for i := range domain.MaxTopics + 1 {
162 tooMany = append(tooMany, fmt.Sprintf("t%d", i))
163 }
164 for name, params := range map[string]ports.UpdateRepoParams{
165 "empty": {},
166 "empty branch": {DefaultBranch: ptr("")},
167 "multi-line": {Description: ptr("a\nb")},
168 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
169 "bad utf-8": {Description: ptr("\xff")},
170 "bad topic": {Topics: &[]string{"Bad_Topic"}},
171 "empty topic": {Topics: &[]string{""}},
172 "too many topics": {Topics: &tooMany},
173 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
174 } {
175 f := newFixture(t)
176 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
177 _, err := f.repos.Update(ctx, admin, n, params)
178 require.Error(t, err, name)
179 assert.True(t, errors.Is(err, domain.ErrInvalid) || errors.Is(err, domain.ErrInvalidVisibility), "%s: %v", name, err)
180 }
181 })
182
183 access := []struct {
184 name string
185 p domain.Principal
186 repo domain.Repo
187 want error
188 }{
189 {"anonymous on a private repository sees nothing", domain.Anonymous(), priv, domain.ErrNotFound},
190 {"anonymous on a public repository must log in", domain.Anonymous(), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrUnauthenticated},
191 {"reader of a private repository is forbidden", tokenPrincipal(domain.ScopeRepoRead), priv, domain.ErrForbidden},
192 {"writer of a private repository is hidden", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
193 {"admin of another repository is hidden", tokenPrincipal(domain.ScopeRepoAdmin, "other"), priv, domain.ErrNotFound},
194 {"writer of a public repository is forbidden", tokenPrincipal(domain.ScopeRepoWrite), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrForbidden},
195 }
196 for _, tt := range access {
197 t.Run(tt.name, func(t *testing.T) {
198 t.Parallel()
199 f := newFixture(t)
200 f.meta.EXPECT().Get(ctx, n).Return(tt.repo, nil)
201 _, err := f.repos.Update(ctx, tt.p, n, ports.UpdateRepoParams{Description: ptr("x")})
202 assert.ErrorIs(t, err, tt.want)
203 err = f.repos.Delete(ctx, tt.p, n)
204 assert.ErrorIs(t, err, tt.want)
205 })
206 }
207
208 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
209 t.Parallel()
210 modes := []domain.MergeMode{domain.MergeModeSquash}
211 block := true
212 for name, params := range map[string]ports.UpdateRepoParams{
213 "default branch": {DefaultBranch: ptr("dev")},
214 "visibility": {Visibility: ptr(domain.VisibilityPublic)},
215 "description": {Description: ptr("x")},
216 "merge modes": {AllowedMergeModes: &modes},
217 "checks rule": {FailingChecksBlock: &block},
218 } {
219 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
220 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
221 _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
222 assert.ErrorIs(t, err, domain.ErrForbidden, name)
223 }
224 })
225
226 t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
227 t.Parallel()
228 f := newFixture(t) // no Trash, no Delete
229 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
230 err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
231 assert.ErrorIs(t, err, domain.ErrForbidden)
232 })
233
234 t.Run("a missing repository is not found", func(t *testing.T) {
235 t.Parallel()
236 f := newFixture(t)
237 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
238 _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Description: ptr("x")})
239 assert.ErrorIs(t, err, domain.ErrNotFound)
240 })
241}
242
243func TestDelete(t *testing.T) {
244 t.Parallel()
245 ctx := context.Background()
246 n := name(t, "tools")
247 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
248
249 t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
250 t.Parallel()
251 f := newFixture(t)
252 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
253 f.git.EXPECT().Trash(ctx, n).Return(nil)
254 f.meta.EXPECT().Delete(ctx, n).Return(nil)
255 f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
256 require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
257 })
258
259 t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
260 t.Parallel()
261 f := newFixture(t)
262 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
263 f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
264 f.meta.EXPECT().Delete(ctx, n).Return(nil)
265 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
266 require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
267 })
268
269 t.Run("a failed move keeps the metadata", func(t *testing.T) {
270 t.Parallel()
271 f := newFixture(t)
272 boom := errors.New("boom")
273 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
274 f.git.EXPECT().Trash(ctx, n).Return(boom)
275 assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
276 })
277}
278
279func TestView(t *testing.T) {
280 t.Parallel()
281 ctx := context.Background()
282 n := name(t, "tools")
283 repo := domain.Repo{Name: n}
284
285 f := newFixture(t)
286 f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
287 v, err := f.repos.View(ctx, repo)
288 require.NoError(t, err)
289 assert.Equal(t, "main", v.DefaultBranch)
290
291 f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
292 v, err = f.repos.View(ctx, repo)
293 require.NoError(t, err, "a missing directory has no default branch")
294 assert.Empty(t, v.DefaultBranch)
295}
296
297func TestListPageSizes(t *testing.T) {
298 t.Parallel()
299 ctx := context.Background()
300 var all []domain.Repo
301 for i := range usecase.MaxRepoPageSize + 10 {
302 all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic})
303 }
304 for _, tt := range []struct {
305 name string
306 size int
307 want int
308 more bool
309 }{
310 {"default", 0, usecase.DefaultPageSize, true},
311 {"within the maximum", 500, 500, true},
312 {"the maximum", usecase.MaxRepoPageSize, usecase.MaxRepoPageSize, true},
313 {"lowered to the maximum", usecase.MaxRepoPageSize * 10, usecase.MaxRepoPageSize, true},
314 } {
315 t.Run(tt.name, func(t *testing.T) {
316 t.Parallel()
317 f := newFixture(t)
318 f.meta.EXPECT().List(ctx).Return(all, nil)
319 page, err := f.repos.ListPage(ctx, domain.Anonymous(), tt.size, "")
320 require.NoError(t, err)
321 assert.Len(t, page.Repos, tt.want)
322 assert.Equal(t, tt.more, page.NextPageToken != "")
323 })
324 }
325}
326
327func TestListPage(t *testing.T) {
328 t.Parallel()
329 ctx := context.Background()
330 var all []domain.Repo
331 for _, s := range []string{"a", "b", "c", "d", "e"} {
332 all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
333 }
334 priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
335 all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
336
337 f := newFixture(t)
338 f.meta.EXPECT().List(ctx).Return(all, nil)
339 var got []string
340 token := ""
341 for pages := 0; ; pages++ {
342 require.Less(t, pages, 10)
343 page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
344 require.NoError(t, err)
345 for _, r := range page.Repos {
346 got = append(got, r.Name.String())
347 }
348 if token = page.NextPageToken; token == "" {
349 break
350 }
351 }
352 assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
353
354 _, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
355 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
356 _, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
357 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
358}