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