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