1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "encoding/base64"
8 "fmt"
9 "io"
10 "strconv"
11 "strings"
12 "testing"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20 "bnema.dev/ermite/internal/ports/mocks"
21 "bnema.dev/ermite/internal/usecase"
22)
23
24type browseFixture struct {
25 meta *mocks.RepoMetaStore
26 reader *mocks.RepoReader
27 browse *usecase.Browse
28}
29
30func newBrowseFixture(t *testing.T) *browseFixture {
31 t.Helper()
32 f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)}
33 f.browse = usecase.NewBrowse(f.meta, f.reader)
34 return f
35}
36
37func oid(c string) domain.ObjectID {
38 id, err := domain.ParseObjectID(strings.Repeat(c, 40))
39 if err != nil {
40 panic(err)
41 }
42 return id
43}
44
45// browseOps are the read use cases, each as a call that returns its error.
46// The reader expectation of each is what must (not) run.
47type browseOp struct {
48 name string
49 call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
50 // expect registers the reader call a permitted caller makes.
51 expect func(r *mocks.RepoReader)
52}
53
54func browseOps() []browseOp {
55 root := domain.TreePath{}
56 return []browseOp{
57 {"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
58 _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{})
59 return err
60 }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }},
61 {"ResolveRevision", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
62 _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
63 return err
64 }, func(r *mocks.RepoReader) {
65 r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
66 }},
67 {"ResolveRefPath", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
68 _, err := b.ResolveRefPath(ctx, p, repo, "main/x")
69 return err
70 }, func(r *mocks.RepoReader) {
71 r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
72 }},
73 {"GetTree", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
74 _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
75 return err
76 }, func(r *mocks.RepoReader) {
77 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
78 }},
79 {"ListPaths", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
80 _, err := b.ListPaths(ctx, p, repo, domain.Revision{})
81 return err
82 }, func(r *mocks.RepoReader) {
83 r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil)
84 }},
85 {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
86 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
87 if rc != nil {
88 _ = rc.Close()
89 }
90 return err
91 }, func(r *mocks.RepoReader) {
92 r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
93 Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
94 }},
95 {"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
96 _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{})
97 return err
98 }, func(r *mocks.RepoReader) {
99 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
100 }},
101 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
102 _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
103 return err
104 }, func(r *mocks.RepoReader) {
105 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
106 }},
107 }
108}
109
110// TestBrowseAccess is the access rule over every read use case and every
111// principal: anonymous sees public repositories only, a private one answers
112// not found; stored tokens need repo:read on the repository; and the Git
113// reader is never reached when access is refused.
114func TestBrowseAccess(t *testing.T) {
115 t.Parallel()
116 ctx := context.Background()
117 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
118 priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
119
120 principals := []struct {
121 name string
122 p domain.Principal
123 // who may read which repository
124 pub, priv bool
125 }{
126 {"anonymous", domain.Anonymous(), true, false},
127 {"root", domain.Root(), true, true},
128 {"token repo:read", tokenPrincipal(domain.ScopeRepoRead), true, true},
129 {"token repo:read on priv", tokenPrincipal(domain.ScopeRepoRead, "priv"), true, true},
130 {"token repo:read on another repo", tokenPrincipal(domain.ScopeRepoRead, "other"), true, false},
131 {"token repo:write only", tokenPrincipal(domain.ScopeRepoWrite), true, false},
132 {"token repo:admin only", tokenPrincipal(domain.ScopeRepoAdmin, "priv"), true, false},
133 {"token token:admin only", tokenPrincipal(domain.ScopeTokenAdmin), true, false},
134 }
135 for _, op := range browseOps() {
136 for _, pr := range principals {
137 for _, repo := range []domain.Repo{pub, priv} {
138 t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) {
139 t.Parallel()
140 f := newBrowseFixture(t)
141 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
142 allowed := pr.pub
143 if repo.Visibility == domain.VisibilityPrivate {
144 allowed = pr.priv
145 }
146 if allowed {
147 op.expect(f.reader)
148 }
149 err := op.call(f.browse, ctx, pr.p, repo.Name)
150 if allowed {
151 assert.NoError(t, err)
152 } else {
153 assert.ErrorIs(t, err, domain.ErrNotFound)
154 assert.NotErrorIs(t, err, domain.ErrForbidden)
155 }
156 })
157 }
158 }
159 t.Run(op.name+"/missing repository", func(t *testing.T) {
160 t.Parallel()
161 f := newBrowseFixture(t)
162 f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound)
163 err := op.call(f.browse, ctx, domain.Root(), name(t, "nope"))
164 assert.ErrorIs(t, err, domain.ErrNotFound)
165 })
166 t.Run(op.name+"/zero name", func(t *testing.T) {
167 t.Parallel()
168 f := newBrowseFixture(t)
169 err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{})
170 assert.ErrorIs(t, err, domain.ErrInvalidName)
171 })
172 }
173}
174
175func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) {
176 t.Parallel()
177 // A reader error is passed through for a caller who may read.
178 ctx := context.Background()
179 f := newBrowseFixture(t)
180 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
181 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
182 f.reader.EXPECT().GetTree(ctx, pub.Name, domain.Revision{}, domain.TreePath{}, false).Return(domain.Tree{}, domain.ErrNotDirectory)
183 _, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, domain.TreePath{}, false)
184 assert.ErrorIs(t, err, domain.ErrNotDirectory)
185}
186
187func ref(kind domain.RefKind, name string) domain.Ref {
188 return domain.Ref{Name: name, Kind: kind, ShortName: name}
189}
190
191func TestListRefsPagingAndKinds(t *testing.T) {
192 t.Parallel()
193 ctx := context.Background()
194 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
195 all := []domain.Ref{
196 ref(domain.RefKindHead, "HEAD"),
197 ref(domain.RefKindBranch, "refs/heads/a"),
198 ref(domain.RefKindBranch, "refs/heads/b"),
199 ref(domain.RefKindBranch, "refs/heads/c"),
200 ref(domain.RefKindTag, "refs/tags/v1"),
201 ref(domain.RefKindTag, "refs/tags/v2"),
202 }
203 names := func(refs []domain.Ref) []string {
204 var out []string
205 for _, r := range refs {
206 out = append(out, r.Name)
207 }
208 return out
209 }
210 setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse {
211 f := newBrowseFixture(t)
212 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
213 f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
214 return f.browse
215 }
216
217 t.Run("pages follow each other without gap or repeat", func(t *testing.T) {
218 b := setup(t, all)
219 var got []string
220 tok := ""
221 for pages := 0; ; pages++ {
222 require.Less(t, pages, 10)
223 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: tok})
224 require.NoError(t, err)
225 got = append(got, names(page.Refs)...)
226 if tok = page.NextPageToken; tok == "" {
227 break
228 }
229 }
230 assert.Equal(t, names(all), got)
231 })
232
233 t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) {
234 b := setup(t, all[:4])
235 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2})
236 require.NoError(t, err)
237 require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs))
238
239 b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...))
240 next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken})
241 require.NoError(t, err)
242 assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs))
243 })
244
245 t.Run("kinds filter", func(t *testing.T) {
246 b := setup(t, all)
247 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}})
248 require.NoError(t, err)
249 assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs))
250 assert.Empty(t, page.NextPageToken)
251 })
252
253 t.Run("default and maximum page size", func(t *testing.T) {
254 var many []domain.Ref
255 many = append(many, ref(domain.RefKindHead, "HEAD"))
256 for i := range usecase.MaxRefPageSize + 100 {
257 many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))
258 }
259 b := setup(t, many)
260 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{})
261 require.NoError(t, err)
262 assert.Len(t, page.Refs, usecase.DefaultPageSize)
263 assert.NotEmpty(t, page.NextPageToken)
264
265 b = setup(t, many)
266 page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000})
267 require.NoError(t, err)
268 assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs")
269 assert.NotEmpty(t, page.NextPageToken)
270 })
271
272 t.Run("bad input never reaches the reader", func(t *testing.T) {
273 t.Parallel()
274 f := newBrowseFixture(t)
275 for _, params := range []ports.ListRefsParams{
276 {PageSize: -1},
277 {Kinds: []domain.RefKind{"nope"}},
278 {PageToken: "!!!"},
279 {PageToken: "Y2E6eA"}, // a token of another kind of listing
280 } {
281 _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params)
282 assert.Error(t, err, "%+v", params)
283 }
284 _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: -1})
285 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
286 _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{"x"}})
287 assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
288 _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageToken: "!!!"})
289 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
290 })
291}
292
293func TestListCommitsPaging(t *testing.T) {
294 t.Parallel()
295 ctx := context.Background()
296 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
297 p := mustPath(t, "d/f")
298
299 t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) {
300 t.Parallel()
301 f := newBrowseFixture(t)
302 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
303 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}).
304 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
305 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageSize: 10})
306 require.NoError(t, err)
307 require.Len(t, page.Commits, 1)
308 require.NotEmpty(t, page.NextPageToken)
309
310 // The next page starts from the pinned head, after the commits read.
311 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{
312 Revision: domain.RevisionOf(oid("a")), Path: p, Skip: 1, Limit: 10,
313 }).Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}}, nil)
314 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
315 // The revision of the request is ignored once there is a token.
316 rev, _ := domain.ParseRevision("moved")
317 next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{
318 Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken,
319 })
320 require.NoError(t, err)
321 assert.Equal(t, oid("c"), next.Commits[0].ID)
322 assert.Empty(t, next.NextPageToken)
323 })
324
325 t.Run("a token is bound to its path and must be well formed", func(t *testing.T) {
326 t.Parallel()
327 f := newBrowseFixture(t)
328 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
329 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
330 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
331 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
332 require.NoError(t, err)
333
334 for _, params := range []ports.ListCommitsParams{
335 {PageToken: page.NextPageToken}, // no path
336 {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path
337 {Path: p, PageToken: "garbage"},
338 {Path: p, PageToken: "cjE6eDp5OnQ"}, // c1:x:y:t is not a token
339 {Path: p, PageToken: strings.Repeat("A", 2000)},
340 } {
341 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
342 assert.ErrorIs(t, err, domain.ErrInvalidPageToken, "%+v", params)
343 }
344 })
345
346 t.Run("a token cannot skip deeper than the cap", func(t *testing.T) {
347 t.Parallel()
348 f := newBrowseFixture(t)
349 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
350 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
351 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once()
352 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
353 require.NoError(t, err)
354 raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken)
355 require.NoError(t, err)
356 parts := strings.Split(string(raw), ":") // c1:<head>:<skip>:<path digest>
357 require.Len(t, parts, 4)
358 withSkip := func(n int) string {
359 parts[2] = strconv.Itoa(n)
360 return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))
361 }
362
363 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })).
364 Return(ports.CommitPage{Head: oid("a")}, nil).Once()
365 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)})
366 require.NoError(t, err, "the cap itself is allowed")
367 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)})
368 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
369 })
370
371 t.Run("page size", func(t *testing.T) {
372 t.Parallel()
373 f := newBrowseFixture(t)
374 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)
375 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil)
376 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
377 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
378 require.NoError(t, err)
379 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: 5000})
380 require.NoError(t, err)
381 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: -3})
382 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
383 })
384
385 t.Run("the token of a private repository does not open it", func(t *testing.T) {
386 priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
387 f := newBrowseFixture(t)
388 f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
389 f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything).
390 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
391 page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, ports.ListCommitsParams{})
392 require.NoError(t, err)
393 f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
394 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, ports.ListCommitsParams{PageToken: page.NextPageToken})
395 assert.ErrorIs(t, err, domain.ErrNotFound)
396 })
397}
398
399func mustPath(t *testing.T, s string) domain.TreePath {
400 t.Helper()
401 p, err := domain.ParseTreePath(s)
402 require.NoError(t, err)
403 return p
404}