v0.8.0 eaf4295

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

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 2

Tags 45

↑↓ moveenter openesc close