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