v0.35.1 2aca1df

browse_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close