1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "fmt"
10 "io"
11 "runtime"
12 "strconv"
13 "strings"
14 "sync"
15 "sync/atomic"
16 "testing"
17 "time"
18
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"
22
23 "bnema.dev/ermite/internal/domain"
24 "bnema.dev/ermite/internal/ports"
25 "bnema.dev/ermite/internal/ports/mocks"
26 "bnema.dev/ermite/internal/usecase"
27)
28
29type browseFixture struct {
30 meta *mocks.RepoMetaStore
31 reader *mocks.RepoReader
32 browse *usecase.Browse
33}
34
35func newBrowseFixture(t *testing.T) *browseFixture {
36 t.Helper()
37 f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)}
38 f.browse = usecase.NewBrowse(f.meta, f.reader)
39 return f
40}
41
42func oid(c string) domain.ObjectID {
43 id, err := domain.ParseObjectID(strings.Repeat(c, 40))
44 if err != nil {
45 panic(err)
46 }
47 return id
48}
49
50// browseOps are the read use cases, each as a call that returns its error.
51// The reader expectation of each is what must (not) run.
52type browseOp struct {
53 name string
54 call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
55 // expect registers the reader call a permitted caller makes.
56 expect func(r *mocks.RepoReader)
57}
58
59func browseOps() []browseOp {
60 root := domain.TreePath{}
61 return []browseOp{
62 {"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
63 _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{})
64 return err
65 }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }},
66 {"ResolveRevision", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
67 _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
68 return err
69 }, func(r *mocks.RepoReader) {
70 r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
71 }},
72 {"ResolveRefPath", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
73 _, err := b.ResolveRefPath(ctx, p, repo, "main/x")
74 return err
75 }, func(r *mocks.RepoReader) {
76 r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
77 }},
78 {"GetTree", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
79 _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
80 return err
81 }, func(r *mocks.RepoReader) {
82 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
83 }},
84 {"ListPaths", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
85 _, err := b.ListPaths(ctx, p, repo, domain.Revision{})
86 return err
87 }, func(r *mocks.RepoReader) {
88 r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil)
89 }},
90 {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
91 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
92 if rc != nil {
93 _ = rc.Close()
94 }
95 return err
96 }, func(r *mocks.RepoReader) {
97 r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
98 Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
99 }},
100 {"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
101 _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{})
102 return err
103 }, func(r *mocks.RepoReader) {
104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105 }},
106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107 _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
108 return err
109 }, func(r *mocks.RepoReader) {
110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
111 }},
112 {"RepoStats", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
113 _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114 return err
115 }, func(r *mocks.RepoReader) {
116 r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117 r.EXPECT().CommitTimes(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, false, nil)
118 r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
119 }},
120 }
121}
122
123// RepoStats reads Git once per state of the repository (head and last push)
124// and per 10 minutes; concurrent callers share the read, and a failure is
125// not kept.
126func TestRepoStatsCache(t *testing.T) {
127 t.Parallel()
128 ctx := context.Background()
129 f := newBrowseFixture(t)
130 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic, CreatedAt: time.Unix(100, 0), PushedAt: time.Unix(200, 0)}
131 var mu sync.Mutex
132 f.meta.EXPECT().Get(mock.Anything, repo.Name).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
133 mu.Lock()
134 defer mu.Unlock()
135 return repo, nil
136 })
137 now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
138 var reads atomic.Int32
139 gate := make(chan struct{})
140 f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything).RunAndReturn(
141 func(context.Context, domain.RepoName, domain.ObjectID) (int, error) {
142 reads.Add(1)
143 <-gate
144 return 900, nil
145 })
146 recent := make([]time.Time, 12)
147 for i := range recent {
148 recent[i] = now.Add(-time.Duration(i) * time.Hour)
149 }
150 f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, mock.Anything, mock.Anything).Return(recent, false, nil)
151 tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
152 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
153
154 stats := func(head string, at time.Time) ports.RepoStats {
155 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid(head), at)
156 require.NoError(t, err)
157 return st
158 }
159
160 // Eight concurrent requests: one read.
161 var wg sync.WaitGroup
162 for range 8 {
163 wg.Go(func() {
164 st := stats("a", now)
165 assert.Equal(t, 900, st.Commits)
166 assert.Equal(t, 12, st.Recent)
167 assert.Equal(t, tag, *st.LatestTag)
168 require.Len(t, st.Activity, ports.ActivityDays)
169 assert.Equal(t, 12, st.Activity[ports.ActivityDays-1], "all in the last day")
170 })
171 }
172 for reads.Load() == 0 {
173 runtime.Gosched()
174 }
175 close(gate)
176 wg.Wait()
177 assert.EqualValues(t, 1, reads.Load(), "concurrent callers share the read")
178
179 stats("a", now.Add(9*time.Minute))
180 assert.EqualValues(t, 1, reads.Load(), "served from the cache")
181 stats("a", now.Add(10*time.Minute))
182 assert.EqualValues(t, 2, reads.Load(), "expired")
183 stats("b", now.Add(10*time.Minute))
184 assert.EqualValues(t, 3, reads.Load(), "another head")
185 mu.Lock()
186 repo.PushedAt = time.Unix(300, 0) // a push, a tag-only one included
187 mu.Unlock()
188 stats("b", now.Add(10*time.Minute))
189 assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
190}
191
192// The activity spreads the commits over the last days, oldest first, and
193// the recent count covers 30 days.
194func TestRepoStatsActivity(t *testing.T) {
195 t.Parallel()
196 ctx := context.Background()
197 f := newBrowseFixture(t)
198 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
199 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
200 now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
201 day := 24 * time.Hour
202 times := []time.Time{
203 now.Add(time.Hour), // in the future: the last day
204 now.Add(-time.Minute), // the last day
205 now.Add(-day - 1), // the day before
206 now.Add(-29 * day), // recent, day 29 ago
207 now.Add(-31 * day), // not recent
208 now.Add(-89*day - 1), // the first day of the span
209 }
210 f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(100, nil)
211 f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), now.Add(-ports.ActivityDays*day)).Return(times, false, nil)
212 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
213 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
214 require.NoError(t, err)
215 assert.Equal(t, 4, st.Recent)
216 last := ports.ActivityDays - 1
217 assert.Equal(t, 2, st.Activity[last])
218 assert.Equal(t, 1, st.Activity[last-1])
219 assert.Equal(t, 1, st.Activity[last-29])
220 assert.Equal(t, 1, st.Activity[last-31])
221 assert.Equal(t, 1, st.Activity[0])
222 sum := 0
223 for _, n := range st.Activity {
224 sum += n
225 }
226 assert.Equal(t, 6, sum)
227 assert.False(t, st.Partial)
228}
229
230// The days are 24 hours whatever the zone of now: across a change of
231// daylight saving time, the walk and the bins agree and no hour is lost.
232func TestRepoStatsActivityAcrossDST(t *testing.T) {
233 t.Parallel()
234 paris, err := time.LoadLocation("Europe/Paris")
235 require.NoError(t, err)
236 ctx := context.Background()
237 f := newBrowseFixture(t)
238 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
239 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
240 // 90 days of 24 hours before this noon cross the spring change (March 29).
241 now := time.Date(2026, 4, 1, 12, 0, 0, 0, paris)
242 day := 24 * time.Hour
243 since := now.Add(-ports.ActivityDays * day)
244 require.NotEqual(t, now.AddDate(0, 0, -ports.ActivityDays), since, "the calendar would differ by an hour")
245 times := []time.Time{since.Add(time.Minute), now.Add(-30*day + time.Minute)}
246 f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), since).Return(times, true, nil)
247 f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(200_000, nil)
248 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
249 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
250 require.NoError(t, err)
251 assert.Equal(t, 1, st.Activity[0], "the start of the span is in the first day")
252 assert.Equal(t, 1, st.Recent, "30 days of 24 hours")
253 assert.True(t, st.Partial, "the walk was truncated")
254}
255
256func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
257 t.Parallel()
258 ctx := context.Background()
259 f := newBrowseFixture(t)
260 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
261 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
262 now := time.Now()
263 f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(0, errors.New("boom")).Once()
264 _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
265 require.Error(t, err)
266 f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(3, nil).Once()
267 f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), mock.Anything).Return([]time.Time{now}, false, nil).Once()
268 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
269 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
270 require.NoError(t, err)
271 assert.Equal(t, 3, st.Commits)
272 assert.Nil(t, st.LatestTag)
273}
274
275// TestBrowseAccess is the access rule over every read use case and every
276// principal: anonymous sees public repositories only, a private one answers
277// not found; stored tokens need repo:read on the repository; and the Git
278// reader is never reached when access is refused.
279func TestBrowseAccess(t *testing.T) {
280 t.Parallel()
281 ctx := context.Background()
282 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
283 priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
284
285 principals := []struct {
286 name string
287 p domain.Principal
288 // who may read which repository
289 pub, priv bool
290 }{
291 {"anonymous", domain.Anonymous(), true, false},
292 {"root", domain.Root(), true, true},
293 {"token repo:read", tokenPrincipal(domain.ScopeRepoRead), true, true},
294 {"token repo:read on priv", tokenPrincipal(domain.ScopeRepoRead, "priv"), true, true},
295 {"token repo:read on another repo", tokenPrincipal(domain.ScopeRepoRead, "other"), true, false},
296 {"token repo:write only", tokenPrincipal(domain.ScopeRepoWrite), true, false},
297 {"token repo:admin only", tokenPrincipal(domain.ScopeRepoAdmin, "priv"), true, false},
298 {"token token:admin only", tokenPrincipal(domain.ScopeTokenAdmin), true, false},
299 }
300 for _, op := range browseOps() {
301 for _, pr := range principals {
302 for _, repo := range []domain.Repo{pub, priv} {
303 t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) {
304 t.Parallel()
305 f := newBrowseFixture(t)
306 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
307 allowed := pr.pub
308 if repo.Visibility == domain.VisibilityPrivate {
309 allowed = pr.priv
310 }
311 if allowed {
312 op.expect(f.reader)
313 }
314 err := op.call(f.browse, ctx, pr.p, repo.Name)
315 if allowed {
316 assert.NoError(t, err)
317 } else {
318 assert.ErrorIs(t, err, domain.ErrNotFound)
319 assert.NotErrorIs(t, err, domain.ErrForbidden)
320 }
321 })
322 }
323 }
324 t.Run(op.name+"/missing repository", func(t *testing.T) {
325 t.Parallel()
326 f := newBrowseFixture(t)
327 f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound)
328 err := op.call(f.browse, ctx, domain.Root(), name(t, "nope"))
329 assert.ErrorIs(t, err, domain.ErrNotFound)
330 })
331 t.Run(op.name+"/zero name", func(t *testing.T) {
332 t.Parallel()
333 f := newBrowseFixture(t)
334 err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{})
335 assert.ErrorIs(t, err, domain.ErrInvalidName)
336 })
337 }
338}
339
340func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) {
341 t.Parallel()
342 // A reader error is passed through for a caller who may read.
343 ctx := context.Background()
344 f := newBrowseFixture(t)
345 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
346 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
347 f.reader.EXPECT().GetTree(ctx, pub.Name, domain.Revision{}, domain.TreePath{}, false).Return(domain.Tree{}, domain.ErrNotDirectory)
348 _, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, domain.TreePath{}, false)
349 assert.ErrorIs(t, err, domain.ErrNotDirectory)
350}
351
352func ref(kind domain.RefKind, name string) domain.Ref {
353 return domain.Ref{Name: name, Kind: kind, ShortName: name}
354}
355
356func TestListRefsPagingAndKinds(t *testing.T) {
357 t.Parallel()
358 ctx := context.Background()
359 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
360 all := []domain.Ref{
361 ref(domain.RefKindHead, "HEAD"),
362 ref(domain.RefKindBranch, "refs/heads/a"),
363 ref(domain.RefKindBranch, "refs/heads/b"),
364 ref(domain.RefKindBranch, "refs/heads/c"),
365 ref(domain.RefKindTag, "refs/tags/v1"),
366 ref(domain.RefKindTag, "refs/tags/v2"),
367 }
368 names := func(refs []domain.Ref) []string {
369 var out []string
370 for _, r := range refs {
371 out = append(out, r.Name)
372 }
373 return out
374 }
375 setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse {
376 f := newBrowseFixture(t)
377 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
378 f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
379 return f.browse
380 }
381
382 t.Run("pages follow each other without gap or repeat", func(t *testing.T) {
383 b := setup(t, all)
384 var got []string
385 tok := ""
386 for pages := 0; ; pages++ {
387 require.Less(t, pages, 10)
388 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: tok})
389 require.NoError(t, err)
390 got = append(got, names(page.Refs)...)
391 if tok = page.NextPageToken; tok == "" {
392 break
393 }
394 }
395 assert.Equal(t, names(all), got)
396 })
397
398 t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) {
399 b := setup(t, all[:4])
400 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2})
401 require.NoError(t, err)
402 require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs))
403
404 b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...))
405 next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken})
406 require.NoError(t, err)
407 assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs))
408 })
409
410 t.Run("kinds filter", func(t *testing.T) {
411 b := setup(t, all)
412 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}})
413 require.NoError(t, err)
414 assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs))
415 assert.Empty(t, page.NextPageToken)
416 })
417
418 t.Run("default and maximum page size", func(t *testing.T) {
419 var many []domain.Ref
420 many = append(many, ref(domain.RefKindHead, "HEAD"))
421 for i := range usecase.MaxRefPageSize + 100 {
422 many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))
423 }
424 b := setup(t, many)
425 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{})
426 require.NoError(t, err)
427 assert.Len(t, page.Refs, usecase.DefaultPageSize)
428 assert.NotEmpty(t, page.NextPageToken)
429
430 b = setup(t, many)
431 page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000})
432 require.NoError(t, err)
433 assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs")
434 assert.NotEmpty(t, page.NextPageToken)
435 })
436
437 t.Run("bad input never reaches the reader", func(t *testing.T) {
438 t.Parallel()
439 f := newBrowseFixture(t)
440 for _, params := range []ports.ListRefsParams{
441 {PageSize: -1},
442 {Kinds: []domain.RefKind{"nope"}},
443 {PageToken: "!!!"},
444 {PageToken: "Y2E6eA"}, // a token of another kind of listing
445 } {
446 _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params)
447 assert.Error(t, err, "%+v", params)
448 }
449 _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: -1})
450 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
451 _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{"x"}})
452 assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
453 _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageToken: "!!!"})
454 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
455 })
456}
457
458func TestListCommitsPaging(t *testing.T) {
459 t.Parallel()
460 ctx := context.Background()
461 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
462 p := mustPath(t, "d/f")
463
464 t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) {
465 t.Parallel()
466 f := newBrowseFixture(t)
467 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
468 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}).
469 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
470 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageSize: 10})
471 require.NoError(t, err)
472 require.Len(t, page.Commits, 1)
473 require.NotEmpty(t, page.NextPageToken)
474
475 // The next page starts from the pinned head, after the commits read.
476 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{
477 Revision: domain.RevisionOf(oid("a")), Path: p, Skip: 1, Limit: 10,
478 }).Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}}, nil)
479 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
480 // The revision of the request is ignored once there is a token.
481 rev, _ := domain.ParseRevision("moved")
482 next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{
483 Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken,
484 })
485 require.NoError(t, err)
486 assert.Equal(t, oid("c"), next.Commits[0].ID)
487 assert.Empty(t, next.NextPageToken)
488 })
489
490 t.Run("a token is bound to its path and must be well formed", func(t *testing.T) {
491 t.Parallel()
492 f := newBrowseFixture(t)
493 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
494 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
495 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
496 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
497 require.NoError(t, err)
498
499 for _, params := range []ports.ListCommitsParams{
500 {PageToken: page.NextPageToken}, // no path
501 {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path
502 {Path: p, PageToken: "garbage"},
503 {Path: p, PageToken: "cjE6eDp5OnQ"}, // c1:x:y:t is not a token
504 {Path: p, PageToken: strings.Repeat("A", 2000)},
505 } {
506 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
507 assert.ErrorIs(t, err, domain.ErrInvalidPageToken, "%+v", params)
508 }
509 })
510
511 t.Run("a token cannot skip deeper than the cap", func(t *testing.T) {
512 t.Parallel()
513 f := newBrowseFixture(t)
514 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
515 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
516 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once()
517 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
518 require.NoError(t, err)
519 raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken)
520 require.NoError(t, err)
521 parts := strings.Split(string(raw), ":") // c1:<head>:<skip>:<path digest>
522 require.Len(t, parts, 4)
523 withSkip := func(n int) string {
524 parts[2] = strconv.Itoa(n)
525 return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))
526 }
527
528 f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })).
529 Return(ports.CommitPage{Head: oid("a")}, nil).Once()
530 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)})
531 require.NoError(t, err, "the cap itself is allowed")
532 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)})
533 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
534 })
535
536 t.Run("page size", func(t *testing.T) {
537 t.Parallel()
538 f := newBrowseFixture(t)
539 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)
540 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil)
541 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
542 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
543 require.NoError(t, err)
544 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: 5000})
545 require.NoError(t, err)
546 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: -3})
547 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
548 })
549
550 t.Run("the token of a private repository does not open it", func(t *testing.T) {
551 priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
552 f := newBrowseFixture(t)
553 f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
554 f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything).
555 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
556 page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, ports.ListCommitsParams{})
557 require.NoError(t, err)
558 f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
559 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, ports.ListCommitsParams{PageToken: page.NextPageToken})
560 assert.ErrorIs(t, err, domain.ErrNotFound)
561 })
562}
563
564func mustPath(t *testing.T, s string) domain.TreePath {
565 t.Helper()
566 p, err := domain.ParseTreePath(s)
567 require.NoError(t, err)
568 return p
569}