detached 638a507

feat(app): add read use cases with access checks

Browse enforces the read access rule on every method (private repositories
answer not found), caps pages at 100 and uses opaque page tokens.
commit
638a507881010bd8f709345a5efe4c9d86d62a7b
author
brice · · 2026-10-10 08:25 UTC
committer
brice · · 2026-10-10 08:25 UTC
parent
0d9c2a3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +671 -0
A internal/app/browse.go +312 -0
@@ -0,0 +1,312 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "encoding/hex"
10+ "fmt"
11+ "io"
12+ "slices"
13+ "strconv"
14+ "strings"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// Page sizes of the listings.
21+const (
22+ // DefaultPageSize is the page size when the caller gives none.
23+ DefaultPageSize = 50
24+ // MaxPageSize is the largest page; a larger request is lowered to it.
25+ MaxPageSize = 100
26+)
27+
28+// Browse are the read use cases over the Git data of a repository: refs,
29+// trees, blobs, history and commits. Every method applies the access rule
30+// first: a repository the principal may not read, or that does not exist,
31+// answers domain.ErrNotFound before the Git reader is touched.
32+type Browse struct {
33+ meta ports.RepoMetaStore
34+ reader ports.RepoReader
35+}
36+
37+// NewBrowse builds the read use cases over their ports.
38+func NewBrowse(meta ports.RepoMetaStore, reader ports.RepoReader) *Browse {
39+ return &Browse{meta: meta, reader: reader}
40+}
41+
42+// authorize returns nil if p may read the repository.
43+func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) error {
44+ if name.IsZero() {
45+ return fmt.Errorf("%w: empty name", domain.ErrInvalidName)
46+ }
47+ repo, err := b.meta.Get(ctx, name)
48+ if err != nil {
49+ return err
50+ }
51+ if !canRead(p, repo) {
52+ return domain.ErrNotFound
53+ }
54+ return nil
55+}
56+
57+// pageSize applies the default and the maximum.
58+func pageSize(n int) (int, error) {
59+ switch {
60+ case n < 0:
61+ return 0, fmt.Errorf("%w: %d", domain.ErrInvalidPageSize, n)
62+ case n == 0:
63+ return DefaultPageSize, nil
64+ }
65+ return min(n, MaxPageSize), nil
66+}
67+
68+// ListRefsParams selects the refs to list.
69+type ListRefsParams struct {
70+ // Kinds restricts the result; empty means every kind.
71+ Kinds []domain.RefKind
72+ // PageSize is 0 for the default, and lowered to MaxPageSize.
73+ PageSize int
74+ // PageToken is the NextPageToken of the previous page, or empty.
75+ PageToken string
76+}
77+
78+// RefPage is a page of refs.
79+type RefPage struct {
80+ Refs []domain.Ref
81+ // NextPageToken is empty on the last page.
82+ NextPageToken string
83+}
84+
85+func kindRank(k domain.RefKind) int {
86+ switch k {
87+ case domain.RefKindHead:
88+ return 0
89+ case domain.RefKindBranch:
90+ return 1
91+ }
92+ return 2
93+}
94+
95+// ListRefs lists HEAD, the branches and the tags, in that order, then by
96+// name. The page token names the last ref of the previous page, so refs that
97+// appear or disappear between two pages never shift the others.
98+func (b *Browse) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error) {
99+ size, err := pageSize(params.PageSize)
100+ if err != nil {
101+ return RefPage{}, err
102+ }
103+ for _, k := range params.Kinds {
104+ if k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag {
105+ return RefPage{}, fmt.Errorf("%w: %q", domain.ErrInvalidRefKind, k)
106+ }
107+ }
108+ after, err := decodeRefToken(params.PageToken)
109+ if err != nil {
110+ return RefPage{}, err
111+ }
112+ if err := b.authorize(ctx, p, repo); err != nil {
113+ return RefPage{}, err
114+ }
115+ all, err := b.reader.ListRefs(ctx, repo)
116+ if err != nil {
117+ return RefPage{}, err
118+ }
119+
120+ var page RefPage
121+ for _, r := range all {
122+ if len(params.Kinds) > 0 && !slices.Contains(params.Kinds, r.Kind) {
123+ continue
124+ }
125+ if after != nil && !refAfter(r, *after) {
126+ continue
127+ }
128+ if len(page.Refs) == size {
129+ last := page.Refs[len(page.Refs)-1]
130+ page.NextPageToken = encodeRefToken(last)
131+ break
132+ }
133+ page.Refs = append(page.Refs, r)
134+ }
135+ return page, nil
136+}
137+
138+// refAfter reports whether r sorts after the ref the token names.
139+func refAfter(r domain.Ref, last refCursor) bool {
140+ if kr, lr := kindRank(r.Kind), kindRank(last.kind); kr != lr {
141+ return kr > lr
142+ }
143+ return r.Name > last.name
144+}
145+
146+// refCursor is the decoded page token of ListRefs.
147+type refCursor struct {
148+ kind domain.RefKind
149+ name string
150+}
151+
152+const (
153+ refTokenPrefix = "r1:"
154+ commitTokenPrefix = "c1:"
155+ maxTokenLen = 1024
156+)
157+
158+func encodeRefToken(last domain.Ref) string {
159+ return base64.RawURLEncoding.EncodeToString([]byte(refTokenPrefix + string(last.Kind) + ":" + last.Name))
160+}
161+
162+// decodeRefToken returns nil for the empty token.
163+func decodeRefToken(tok string) (*refCursor, error) {
164+ if tok == "" {
165+ return nil, nil
166+ }
167+ raw, ok := decodeToken(tok, refTokenPrefix)
168+ if !ok {
169+ return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
170+ }
171+ kind, name, ok := strings.Cut(raw, ":")
172+ k := domain.RefKind(kind)
173+ if !ok || name == "" || (k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag) {
174+ return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
175+ }
176+ return &refCursor{kind: k, name: name}, nil
177+}
178+
179+func decodeToken(tok, prefix string) (string, bool) {
180+ if len(tok) > maxTokenLen {
181+ return "", false
182+ }
183+ raw, err := base64.RawURLEncoding.DecodeString(tok)
184+ if err != nil {
185+ return "", false
186+ }
187+ return strings.CutPrefix(string(raw), prefix)
188+}
189+
190+// ResolveRevision turns a revision into the commit it designates.
191+func (b *Browse) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
192+ if err := b.authorize(ctx, p, repo); err != nil {
193+ return domain.ResolvedRevision{}, err
194+ }
195+ return b.reader.ResolveRevision(ctx, repo, rev)
196+}
197+
198+// ResolveRefPath splits a path such as "feature/x/dir/file" into the longest
199+// leading ref and the path inside it.
200+func (b *Browse) ResolveRefPath(ctx context.Context, p domain.Principal, repo domain.RepoName, path string) (domain.RefPath, error) {
201+ if err := b.authorize(ctx, p, repo); err != nil {
202+ return domain.RefPath{}, err
203+ }
204+ return b.reader.ResolveRefPath(ctx, repo, path)
205+}
206+
207+// GetTree lists a directory at a revision. With includeLastCommit, entries get
208+// their last commit, for at most domain.MaxLastCommitEntries of them.
209+func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
210+ if err := b.authorize(ctx, p, repo); err != nil {
211+ return domain.Tree{}, err
212+ }
213+ return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit)
214+}
215+
216+// OpenBlob returns the description of a file and a reader of its content. The
217+// caller closes the reader.
218+func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
219+ if err := b.authorize(ctx, p, repo); err != nil {
220+ return domain.BlobInfo{}, nil, err
221+ }
222+ return b.reader.OpenBlob(ctx, repo, rev, path)
223+}
224+
225+// ListCommitsParams selects a page of history.
226+type ListCommitsParams struct {
227+ // Revision is where the history starts; the zero value is HEAD. Ignored
228+ // when PageToken is set: the token pins the commit the first page started
229+ // from, so every page walks the same history even if a branch moves.
230+ Revision domain.Revision
231+ // Path limits the history to the commits that changed it. It must be the
232+ // same on every page of a listing.
233+ Path domain.TreePath
234+ // PageSize is 0 for the default, and lowered to MaxPageSize.
235+ PageSize int
236+ // PageToken is the NextPageToken of the previous page, or empty.
237+ PageToken string
238+}
239+
240+// CommitPage is a page of history, newest first.
241+type CommitPage struct {
242+ Commits []domain.Commit
243+ // NextPageToken is empty on the last page.
244+ NextPageToken string
245+}
246+
247+// ListCommits returns a page of history. The page token holds the commit the
248+// listing started from, how many commits were already returned and a digest
249+// of the path; a token used with another path is invalid.
250+func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (CommitPage, error) {
251+ size, err := pageSize(params.PageSize)
252+ if err != nil {
253+ return CommitPage{}, err
254+ }
255+ q := ports.CommitQuery{Revision: params.Revision, Path: params.Path, Limit: size}
256+ if params.PageToken != "" {
257+ head, skip, err := decodeCommitToken(params.PageToken, params.Path)
258+ if err != nil {
259+ return CommitPage{}, err
260+ }
261+ q.Revision, q.Skip = domain.RevisionOf(head), skip
262+ }
263+ if err := b.authorize(ctx, p, repo); err != nil {
264+ return CommitPage{}, err
265+ }
266+ res, err := b.reader.ListCommits(ctx, repo, q)
267+ if err != nil {
268+ return CommitPage{}, err
269+ }
270+ out := CommitPage{Commits: res.Commits}
271+ if res.HasMore {
272+ out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
273+ }
274+ return out, nil
275+}
276+
277+func pathDigest(path domain.TreePath) string {
278+ sum := sha256.Sum256([]byte(path.String()))
279+ return hex.EncodeToString(sum[:6])
280+}
281+
282+func encodeCommitToken(head domain.ObjectID, skip int, path domain.TreePath) string {
283+ raw := commitTokenPrefix + head.String() + ":" + strconv.Itoa(skip) + ":" + pathDigest(path)
284+ return base64.RawURLEncoding.EncodeToString([]byte(raw))
285+}
286+
287+func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, error) {
288+ bad := fmt.Errorf("%w: commits", domain.ErrInvalidPageToken)
289+ raw, ok := decodeToken(tok, commitTokenPrefix)
290+ if !ok {
291+ return domain.ObjectID{}, 0, bad
292+ }
293+ parts := strings.Split(raw, ":")
294+ if len(parts) != 3 {
295+ return domain.ObjectID{}, 0, bad
296+ }
297+ head, err := domain.ParseObjectID(parts[0])
298+ skip, err2 := strconv.Atoi(parts[1])
299+ if err != nil || err2 != nil || skip < 0 || parts[2] != pathDigest(path) {
300+ return domain.ObjectID{}, 0, bad
301+ }
302+ return head, skip, nil
303+}
304+
305+// GetCommit returns a commit with its statistics and its diff against the
306+// first parent, within the default diff limits.
307+func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
308+ if err := b.authorize(ctx, p, repo); err != nil {
309+ return domain.CommitDetail{}, err
310+ }
311+ return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
312+}
@@ -0,0 +1,312 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "encoding/hex"
10+ "fmt"
11+ "io"
12+ "slices"
13+ "strconv"
14+ "strings"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// Page sizes of the listings.
21+const (
22+ // DefaultPageSize is the page size when the caller gives none.
23+ DefaultPageSize = 50
24+ // MaxPageSize is the largest page; a larger request is lowered to it.
25+ MaxPageSize = 100
26+)
27+
28+// Browse are the read use cases over the Git data of a repository: refs,
29+// trees, blobs, history and commits. Every method applies the access rule
30+// first: a repository the principal may not read, or that does not exist,
31+// answers domain.ErrNotFound before the Git reader is touched.
32+type Browse struct {
33+ meta ports.RepoMetaStore
34+ reader ports.RepoReader
35+}
36+
37+// NewBrowse builds the read use cases over their ports.
38+func NewBrowse(meta ports.RepoMetaStore, reader ports.RepoReader) *Browse {
39+ return &Browse{meta: meta, reader: reader}
40+}
41+
42+// authorize returns nil if p may read the repository.
43+func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) error {
44+ if name.IsZero() {
45+ return fmt.Errorf("%w: empty name", domain.ErrInvalidName)
46+ }
47+ repo, err := b.meta.Get(ctx, name)
48+ if err != nil {
49+ return err
50+ }
51+ if !canRead(p, repo) {
52+ return domain.ErrNotFound
53+ }
54+ return nil
55+}
56+
57+// pageSize applies the default and the maximum.
58+func pageSize(n int) (int, error) {
59+ switch {
60+ case n < 0:
61+ return 0, fmt.Errorf("%w: %d", domain.ErrInvalidPageSize, n)
62+ case n == 0:
63+ return DefaultPageSize, nil
64+ }
65+ return min(n, MaxPageSize), nil
66+}
67+
68+// ListRefsParams selects the refs to list.
69+type ListRefsParams struct {
70+ // Kinds restricts the result; empty means every kind.
71+ Kinds []domain.RefKind
72+ // PageSize is 0 for the default, and lowered to MaxPageSize.
73+ PageSize int
74+ // PageToken is the NextPageToken of the previous page, or empty.
75+ PageToken string
76+}
77+
78+// RefPage is a page of refs.
79+type RefPage struct {
80+ Refs []domain.Ref
81+ // NextPageToken is empty on the last page.
82+ NextPageToken string
83+}
84+
85+func kindRank(k domain.RefKind) int {
86+ switch k {
87+ case domain.RefKindHead:
88+ return 0
89+ case domain.RefKindBranch:
90+ return 1
91+ }
92+ return 2
93+}
94+
95+// ListRefs lists HEAD, the branches and the tags, in that order, then by
96+// name. The page token names the last ref of the previous page, so refs that
97+// appear or disappear between two pages never shift the others.
98+func (b *Browse) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error) {
99+ size, err := pageSize(params.PageSize)
100+ if err != nil {
101+ return RefPage{}, err
102+ }
103+ for _, k := range params.Kinds {
104+ if k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag {
105+ return RefPage{}, fmt.Errorf("%w: %q", domain.ErrInvalidRefKind, k)
106+ }
107+ }
108+ after, err := decodeRefToken(params.PageToken)
109+ if err != nil {
110+ return RefPage{}, err
111+ }
112+ if err := b.authorize(ctx, p, repo); err != nil {
113+ return RefPage{}, err
114+ }
115+ all, err := b.reader.ListRefs(ctx, repo)
116+ if err != nil {
117+ return RefPage{}, err
118+ }
119+
120+ var page RefPage
121+ for _, r := range all {
122+ if len(params.Kinds) > 0 && !slices.Contains(params.Kinds, r.Kind) {
123+ continue
124+ }
125+ if after != nil && !refAfter(r, *after) {
126+ continue
127+ }
128+ if len(page.Refs) == size {
129+ last := page.Refs[len(page.Refs)-1]
130+ page.NextPageToken = encodeRefToken(last)
131+ break
132+ }
133+ page.Refs = append(page.Refs, r)
134+ }
135+ return page, nil
136+}
137+
138+// refAfter reports whether r sorts after the ref the token names.
139+func refAfter(r domain.Ref, last refCursor) bool {
140+ if kr, lr := kindRank(r.Kind), kindRank(last.kind); kr != lr {
141+ return kr > lr
142+ }
143+ return r.Name > last.name
144+}
145+
146+// refCursor is the decoded page token of ListRefs.
147+type refCursor struct {
148+ kind domain.RefKind
149+ name string
150+}
151+
152+const (
153+ refTokenPrefix = "r1:"
154+ commitTokenPrefix = "c1:"
155+ maxTokenLen = 1024
156+)
157+
158+func encodeRefToken(last domain.Ref) string {
159+ return base64.RawURLEncoding.EncodeToString([]byte(refTokenPrefix + string(last.Kind) + ":" + last.Name))
160+}
161+
162+// decodeRefToken returns nil for the empty token.
163+func decodeRefToken(tok string) (*refCursor, error) {
164+ if tok == "" {
165+ return nil, nil
166+ }
167+ raw, ok := decodeToken(tok, refTokenPrefix)
168+ if !ok {
169+ return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
170+ }
171+ kind, name, ok := strings.Cut(raw, ":")
172+ k := domain.RefKind(kind)
173+ if !ok || name == "" || (k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag) {
174+ return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
175+ }
176+ return &refCursor{kind: k, name: name}, nil
177+}
178+
179+func decodeToken(tok, prefix string) (string, bool) {
180+ if len(tok) > maxTokenLen {
181+ return "", false
182+ }
183+ raw, err := base64.RawURLEncoding.DecodeString(tok)
184+ if err != nil {
185+ return "", false
186+ }
187+ return strings.CutPrefix(string(raw), prefix)
188+}
189+
190+// ResolveRevision turns a revision into the commit it designates.
191+func (b *Browse) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
192+ if err := b.authorize(ctx, p, repo); err != nil {
193+ return domain.ResolvedRevision{}, err
194+ }
195+ return b.reader.ResolveRevision(ctx, repo, rev)
196+}
197+
198+// ResolveRefPath splits a path such as "feature/x/dir/file" into the longest
199+// leading ref and the path inside it.
200+func (b *Browse) ResolveRefPath(ctx context.Context, p domain.Principal, repo domain.RepoName, path string) (domain.RefPath, error) {
201+ if err := b.authorize(ctx, p, repo); err != nil {
202+ return domain.RefPath{}, err
203+ }
204+ return b.reader.ResolveRefPath(ctx, repo, path)
205+}
206+
207+// GetTree lists a directory at a revision. With includeLastCommit, entries get
208+// their last commit, for at most domain.MaxLastCommitEntries of them.
209+func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
210+ if err := b.authorize(ctx, p, repo); err != nil {
211+ return domain.Tree{}, err
212+ }
213+ return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit)
214+}
215+
216+// OpenBlob returns the description of a file and a reader of its content. The
217+// caller closes the reader.
218+func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
219+ if err := b.authorize(ctx, p, repo); err != nil {
220+ return domain.BlobInfo{}, nil, err
221+ }
222+ return b.reader.OpenBlob(ctx, repo, rev, path)
223+}
224+
225+// ListCommitsParams selects a page of history.
226+type ListCommitsParams struct {
227+ // Revision is where the history starts; the zero value is HEAD. Ignored
228+ // when PageToken is set: the token pins the commit the first page started
229+ // from, so every page walks the same history even if a branch moves.
230+ Revision domain.Revision
231+ // Path limits the history to the commits that changed it. It must be the
232+ // same on every page of a listing.
233+ Path domain.TreePath
234+ // PageSize is 0 for the default, and lowered to MaxPageSize.
235+ PageSize int
236+ // PageToken is the NextPageToken of the previous page, or empty.
237+ PageToken string
238+}
239+
240+// CommitPage is a page of history, newest first.
241+type CommitPage struct {
242+ Commits []domain.Commit
243+ // NextPageToken is empty on the last page.
244+ NextPageToken string
245+}
246+
247+// ListCommits returns a page of history. The page token holds the commit the
248+// listing started from, how many commits were already returned and a digest
249+// of the path; a token used with another path is invalid.
250+func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (CommitPage, error) {
251+ size, err := pageSize(params.PageSize)
252+ if err != nil {
253+ return CommitPage{}, err
254+ }
255+ q := ports.CommitQuery{Revision: params.Revision, Path: params.Path, Limit: size}
256+ if params.PageToken != "" {
257+ head, skip, err := decodeCommitToken(params.PageToken, params.Path)
258+ if err != nil {
259+ return CommitPage{}, err
260+ }
261+ q.Revision, q.Skip = domain.RevisionOf(head), skip
262+ }
263+ if err := b.authorize(ctx, p, repo); err != nil {
264+ return CommitPage{}, err
265+ }
266+ res, err := b.reader.ListCommits(ctx, repo, q)
267+ if err != nil {
268+ return CommitPage{}, err
269+ }
270+ out := CommitPage{Commits: res.Commits}
271+ if res.HasMore {
272+ out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
273+ }
274+ return out, nil
275+}
276+
277+func pathDigest(path domain.TreePath) string {
278+ sum := sha256.Sum256([]byte(path.String()))
279+ return hex.EncodeToString(sum[:6])
280+}
281+
282+func encodeCommitToken(head domain.ObjectID, skip int, path domain.TreePath) string {
283+ raw := commitTokenPrefix + head.String() + ":" + strconv.Itoa(skip) + ":" + pathDigest(path)
284+ return base64.RawURLEncoding.EncodeToString([]byte(raw))
285+}
286+
287+func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, error) {
288+ bad := fmt.Errorf("%w: commits", domain.ErrInvalidPageToken)
289+ raw, ok := decodeToken(tok, commitTokenPrefix)
290+ if !ok {
291+ return domain.ObjectID{}, 0, bad
292+ }
293+ parts := strings.Split(raw, ":")
294+ if len(parts) != 3 {
295+ return domain.ObjectID{}, 0, bad
296+ }
297+ head, err := domain.ParseObjectID(parts[0])
298+ skip, err2 := strconv.Atoi(parts[1])
299+ if err != nil || err2 != nil || skip < 0 || parts[2] != pathDigest(path) {
300+ return domain.ObjectID{}, 0, bad
301+ }
302+ return head, skip, nil
303+}
304+
305+// GetCommit returns a commit with its statistics and its diff against the
306+// first parent, within the default diff limits.
307+func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
308+ if err := b.authorize(ctx, p, repo); err != nil {
309+ return domain.CommitDetail{}, err
310+ }
311+ return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
312+}
A internal/app/browse_test.go +359 -0
@@ -0,0 +1,359 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "io"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/app"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+type browseFixture struct {
23+ meta *mocks.RepoMetaStore
24+ reader *mocks.RepoReader
25+ browse *app.Browse
26+}
27+
28+func newBrowseFixture(t *testing.T) *browseFixture {
29+ t.Helper()
30+ f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)}
31+ f.browse = app.NewBrowse(f.meta, f.reader)
32+ return f
33+}
34+
35+func oid(c string) domain.ObjectID {
36+ id, err := domain.ParseObjectID(strings.Repeat(c, 40))
37+ if err != nil {
38+ panic(err)
39+ }
40+ return id
41+}
42+
43+// browseOps are the read use cases, each as a call that returns its error.
44+// The reader expectation of each is what must (not) run.
45+type browseOp struct {
46+ name string
47+ call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
48+ // expect registers the reader call a permitted caller makes.
49+ expect func(r *mocks.RepoReader)
50+}
51+
52+func browseOps() []browseOp {
53+ root := domain.TreePath{}
54+ return []browseOp{
55+ {"ListRefs", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
56+ _, err := b.ListRefs(ctx, p, repo, app.ListRefsParams{})
57+ return err
58+ }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }},
59+ {"ResolveRevision", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
60+ _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
61+ return err
62+ }, func(r *mocks.RepoReader) {
63+ r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
64+ }},
65+ {"ResolveRefPath", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
66+ _, err := b.ResolveRefPath(ctx, p, repo, "main/x")
67+ return err
68+ }, func(r *mocks.RepoReader) {
69+ r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
70+ }},
71+ {"GetTree", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
72+ _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
73+ return err
74+ }, func(r *mocks.RepoReader) {
75+ r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
76+ }},
77+ {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
78+ _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
79+ if rc != nil {
80+ _ = rc.Close()
81+ }
82+ return err
83+ }, func(r *mocks.RepoReader) {
84+ r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
85+ Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
86+ }},
87+ {"ListCommits", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
88+ _, err := b.ListCommits(ctx, p, repo, app.ListCommitsParams{})
89+ return err
90+ }, func(r *mocks.RepoReader) {
91+ r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
92+ }},
93+ {"GetCommit", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
94+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
95+ return err
96+ }, func(r *mocks.RepoReader) {
97+ r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
98+ }},
99+ }
100+}
101+
102+// TestBrowseAccess is the access rule over every read use case and every
103+// principal: anonymous sees public repositories only, a private one answers
104+// not found; stored tokens need repo:read on the repository; and the Git
105+// reader is never reached when access is refused.
106+func TestBrowseAccess(t *testing.T) {
107+ ctx := context.Background()
108+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
109+ priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
110+
111+ principals := []struct {
112+ name string
113+ p domain.Principal
114+ // who may read which repository
115+ pub, priv bool
116+ }{
117+ {"anonymous", domain.Anonymous(), true, false},
118+ {"root", domain.Root(), true, true},
119+ {"token repo:read", tokenPrincipal(domain.ScopeRepoRead), true, true},
120+ {"token repo:read on priv", tokenPrincipal(domain.ScopeRepoRead, "priv"), true, true},
121+ {"token repo:read on another repo", tokenPrincipal(domain.ScopeRepoRead, "other"), true, false},
122+ {"token repo:write only", tokenPrincipal(domain.ScopeRepoWrite), true, false},
123+ {"token repo:admin only", tokenPrincipal(domain.ScopeRepoAdmin, "priv"), true, false},
124+ {"token token:admin only", tokenPrincipal(domain.ScopeTokenAdmin), true, false},
125+ }
126+ for _, op := range browseOps() {
127+ for _, pr := range principals {
128+ for _, repo := range []domain.Repo{pub, priv} {
129+ t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) {
130+ f := newBrowseFixture(t)
131+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
132+ allowed := pr.pub
133+ if repo.Visibility == domain.VisibilityPrivate {
134+ allowed = pr.priv
135+ }
136+ if allowed {
137+ op.expect(f.reader)
138+ }
139+ err := op.call(f.browse, ctx, pr.p, repo.Name)
140+ if allowed {
141+ assert.NoError(t, err)
142+ } else {
143+ assert.ErrorIs(t, err, domain.ErrNotFound)
144+ assert.NotErrorIs(t, err, domain.ErrForbidden)
145+ }
146+ })
147+ }
148+ }
149+ t.Run(op.name+"/missing repository", func(t *testing.T) {
150+ f := newBrowseFixture(t)
151+ f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound)
152+ err := op.call(f.browse, ctx, domain.Root(), name(t, "nope"))
153+ assert.ErrorIs(t, err, domain.ErrNotFound)
154+ })
155+ t.Run(op.name+"/zero name", func(t *testing.T) {
156+ f := newBrowseFixture(t)
157+ err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{})
158+ assert.ErrorIs(t, err, domain.ErrInvalidName)
159+ })
160+ }
161+}
162+
163+func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) {
164+ // A reader error is passed through for a caller who may read.
165+ ctx := context.Background()
166+ f := newBrowseFixture(t)
167+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
168+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
169+ f.reader.EXPECT().GetTree(ctx, pub.Name, domain.Revision{}, domain.TreePath{}, false).Return(domain.Tree{}, domain.ErrNotDirectory)
170+ _, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, domain.TreePath{}, false)
171+ assert.ErrorIs(t, err, domain.ErrNotDirectory)
172+}
173+
174+func ref(kind domain.RefKind, name string) domain.Ref {
175+ return domain.Ref{Name: name, Kind: kind, ShortName: name}
176+}
177+
178+func TestListRefsPagingAndKinds(t *testing.T) {
179+ ctx := context.Background()
180+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
181+ all := []domain.Ref{
182+ ref(domain.RefKindHead, "HEAD"),
183+ ref(domain.RefKindBranch, "refs/heads/a"),
184+ ref(domain.RefKindBranch, "refs/heads/b"),
185+ ref(domain.RefKindBranch, "refs/heads/c"),
186+ ref(domain.RefKindTag, "refs/tags/v1"),
187+ ref(domain.RefKindTag, "refs/tags/v2"),
188+ }
189+ names := func(refs []domain.Ref) []string {
190+ var out []string
191+ for _, r := range refs {
192+ out = append(out, r.Name)
193+ }
194+ return out
195+ }
196+ setup := func(t *testing.T, refs []domain.Ref) *app.Browse {
197+ f := newBrowseFixture(t)
198+ f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
199+ f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
200+ return f.browse
201+ }
202+
203+ t.Run("pages follow each other without gap or repeat", func(t *testing.T) {
204+ b := setup(t, all)
205+ var got []string
206+ tok := ""
207+ for pages := 0; ; pages++ {
208+ require.Less(t, pages, 10)
209+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: tok})
210+ require.NoError(t, err)
211+ got = append(got, names(page.Refs)...)
212+ if tok = page.NextPageToken; tok == "" {
213+ break
214+ }
215+ }
216+ assert.Equal(t, names(all), got)
217+ })
218+
219+ t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) {
220+ b := setup(t, all[:4])
221+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2})
222+ require.NoError(t, err)
223+ require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs))
224+
225+ b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...))
226+ next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken})
227+ require.NoError(t, err)
228+ assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs))
229+ })
230+
231+ t.Run("kinds filter", func(t *testing.T) {
232+ b := setup(t, all)
233+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}})
234+ require.NoError(t, err)
235+ assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs))
236+ assert.Empty(t, page.NextPageToken)
237+ })
238+
239+ t.Run("default and maximum page size", func(t *testing.T) {
240+ var many []domain.Ref
241+ many = append(many, ref(domain.RefKindHead, "HEAD"))
242+ for i := range 250 {
243+ many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%03d", i)))
244+ }
245+ b := setup(t, many)
246+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{})
247+ require.NoError(t, err)
248+ assert.Len(t, page.Refs, app.DefaultPageSize)
249+ assert.NotEmpty(t, page.NextPageToken)
250+
251+ b = setup(t, many)
252+ page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 100000})
253+ require.NoError(t, err)
254+ assert.Len(t, page.Refs, app.MaxPageSize)
255+ })
256+
257+ t.Run("bad input never reaches the reader", func(t *testing.T) {
258+ f := newBrowseFixture(t)
259+ for _, params := range []app.ListRefsParams{
260+ {PageSize: -1},
261+ {Kinds: []domain.RefKind{"nope"}},
262+ {PageToken: "!!!"},
263+ {PageToken: "Y2E6eA"}, // a token of another kind of listing
264+ } {
265+ _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params)
266+ assert.Error(t, err, "%+v", params)
267+ }
268+ _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: -1})
269+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
270+ _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{"x"}})
271+ assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
272+ _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageToken: "!!!"})
273+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
274+ })
275+}
276+
277+func TestListCommitsPaging(t *testing.T) {
278+ ctx := context.Background()
279+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
280+ p := mustPath(t, "d/f")
281+
282+ t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) {
283+ f := newBrowseFixture(t)
284+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
285+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}).
286+ Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
287+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageSize: 10})
288+ require.NoError(t, err)
289+ require.Len(t, page.Commits, 1)
290+ require.NotEmpty(t, page.NextPageToken)
291+
292+ // The next page starts from the pinned head, after the commits read.
293+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{
294+ Revision: domain.RevisionOf(oid("a")), Path: p, Skip: 1, Limit: 10,
295+ }).Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}}, nil)
296+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
297+ // The revision of the request is ignored once there is a token.
298+ rev, _ := domain.ParseRevision("moved")
299+ next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{
300+ Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken,
301+ })
302+ require.NoError(t, err)
303+ assert.Equal(t, oid("c"), next.Commits[0].ID)
304+ assert.Empty(t, next.NextPageToken)
305+ })
306+
307+ t.Run("a token is bound to its path and must be well formed", func(t *testing.T) {
308+ f := newBrowseFixture(t)
309+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
310+ f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
311+ Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
312+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p})
313+ require.NoError(t, err)
314+
315+ for _, params := range []app.ListCommitsParams{
316+ {PageToken: page.NextPageToken}, // no path
317+ {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path
318+ {Path: p, PageToken: "garbage"},
319+ {Path: p, PageToken: "cjE6eDp5OnQ"}, // c1:x:y:t is not a token
320+ {Path: p, PageToken: strings.Repeat("A", 2000)},
321+ } {
322+ _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
323+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken, "%+v", params)
324+ }
325+ })
326+
327+ t.Run("page size", func(t *testing.T) {
328+ f := newBrowseFixture(t)
329+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)
330+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: app.DefaultPageSize}).Return(ports.CommitPage{}, nil)
331+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
332+ _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{})
333+ require.NoError(t, err)
334+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: 5000})
335+ require.NoError(t, err)
336+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: -3})
337+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
338+ })
339+
340+ t.Run("the token of a private repository does not open it", func(t *testing.T) {
341+ priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
342+ f := newBrowseFixture(t)
343+ f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
344+ f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything).
345+ Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
346+ page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, app.ListCommitsParams{})
347+ require.NoError(t, err)
348+ f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
349+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, app.ListCommitsParams{PageToken: page.NextPageToken})
350+ assert.ErrorIs(t, err, domain.ErrNotFound)
351+ })
352+}
353+
354+func mustPath(t *testing.T, s string) domain.TreePath {
355+ t.Helper()
356+ p, err := domain.ParseTreePath(s)
357+ require.NoError(t, err)
358+ return p
359+}
@@ -0,0 +1,359 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "io"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/app"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+type browseFixture struct {
23+ meta *mocks.RepoMetaStore
24+ reader *mocks.RepoReader
25+ browse *app.Browse
26+}
27+
28+func newBrowseFixture(t *testing.T) *browseFixture {
29+ t.Helper()
30+ f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)}
31+ f.browse = app.NewBrowse(f.meta, f.reader)
32+ return f
33+}
34+
35+func oid(c string) domain.ObjectID {
36+ id, err := domain.ParseObjectID(strings.Repeat(c, 40))
37+ if err != nil {
38+ panic(err)
39+ }
40+ return id
41+}
42+
43+// browseOps are the read use cases, each as a call that returns its error.
44+// The reader expectation of each is what must (not) run.
45+type browseOp struct {
46+ name string
47+ call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
48+ // expect registers the reader call a permitted caller makes.
49+ expect func(r *mocks.RepoReader)
50+}
51+
52+func browseOps() []browseOp {
53+ root := domain.TreePath{}
54+ return []browseOp{
55+ {"ListRefs", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
56+ _, err := b.ListRefs(ctx, p, repo, app.ListRefsParams{})
57+ return err
58+ }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }},
59+ {"ResolveRevision", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
60+ _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
61+ return err
62+ }, func(r *mocks.RepoReader) {
63+ r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
64+ }},
65+ {"ResolveRefPath", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
66+ _, err := b.ResolveRefPath(ctx, p, repo, "main/x")
67+ return err
68+ }, func(r *mocks.RepoReader) {
69+ r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
70+ }},
71+ {"GetTree", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
72+ _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
73+ return err
74+ }, func(r *mocks.RepoReader) {
75+ r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
76+ }},
77+ {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
78+ _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
79+ if rc != nil {
80+ _ = rc.Close()
81+ }
82+ return err
83+ }, func(r *mocks.RepoReader) {
84+ r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
85+ Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
86+ }},
87+ {"ListCommits", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
88+ _, err := b.ListCommits(ctx, p, repo, app.ListCommitsParams{})
89+ return err
90+ }, func(r *mocks.RepoReader) {
91+ r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
92+ }},
93+ {"GetCommit", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
94+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
95+ return err
96+ }, func(r *mocks.RepoReader) {
97+ r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
98+ }},
99+ }
100+}
101+
102+// TestBrowseAccess is the access rule over every read use case and every
103+// principal: anonymous sees public repositories only, a private one answers
104+// not found; stored tokens need repo:read on the repository; and the Git
105+// reader is never reached when access is refused.
106+func TestBrowseAccess(t *testing.T) {
107+ ctx := context.Background()
108+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
109+ priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
110+
111+ principals := []struct {
112+ name string
113+ p domain.Principal
114+ // who may read which repository
115+ pub, priv bool
116+ }{
117+ {"anonymous", domain.Anonymous(), true, false},
118+ {"root", domain.Root(), true, true},
119+ {"token repo:read", tokenPrincipal(domain.ScopeRepoRead), true, true},
120+ {"token repo:read on priv", tokenPrincipal(domain.ScopeRepoRead, "priv"), true, true},
121+ {"token repo:read on another repo", tokenPrincipal(domain.ScopeRepoRead, "other"), true, false},
122+ {"token repo:write only", tokenPrincipal(domain.ScopeRepoWrite), true, false},
123+ {"token repo:admin only", tokenPrincipal(domain.ScopeRepoAdmin, "priv"), true, false},
124+ {"token token:admin only", tokenPrincipal(domain.ScopeTokenAdmin), true, false},
125+ }
126+ for _, op := range browseOps() {
127+ for _, pr := range principals {
128+ for _, repo := range []domain.Repo{pub, priv} {
129+ t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) {
130+ f := newBrowseFixture(t)
131+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
132+ allowed := pr.pub
133+ if repo.Visibility == domain.VisibilityPrivate {
134+ allowed = pr.priv
135+ }
136+ if allowed {
137+ op.expect(f.reader)
138+ }
139+ err := op.call(f.browse, ctx, pr.p, repo.Name)
140+ if allowed {
141+ assert.NoError(t, err)
142+ } else {
143+ assert.ErrorIs(t, err, domain.ErrNotFound)
144+ assert.NotErrorIs(t, err, domain.ErrForbidden)
145+ }
146+ })
147+ }
148+ }
149+ t.Run(op.name+"/missing repository", func(t *testing.T) {
150+ f := newBrowseFixture(t)
151+ f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound)
152+ err := op.call(f.browse, ctx, domain.Root(), name(t, "nope"))
153+ assert.ErrorIs(t, err, domain.ErrNotFound)
154+ })
155+ t.Run(op.name+"/zero name", func(t *testing.T) {
156+ f := newBrowseFixture(t)
157+ err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{})
158+ assert.ErrorIs(t, err, domain.ErrInvalidName)
159+ })
160+ }
161+}
162+
163+func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) {
164+ // A reader error is passed through for a caller who may read.
165+ ctx := context.Background()
166+ f := newBrowseFixture(t)
167+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
168+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
169+ f.reader.EXPECT().GetTree(ctx, pub.Name, domain.Revision{}, domain.TreePath{}, false).Return(domain.Tree{}, domain.ErrNotDirectory)
170+ _, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, domain.TreePath{}, false)
171+ assert.ErrorIs(t, err, domain.ErrNotDirectory)
172+}
173+
174+func ref(kind domain.RefKind, name string) domain.Ref {
175+ return domain.Ref{Name: name, Kind: kind, ShortName: name}
176+}
177+
178+func TestListRefsPagingAndKinds(t *testing.T) {
179+ ctx := context.Background()
180+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
181+ all := []domain.Ref{
182+ ref(domain.RefKindHead, "HEAD"),
183+ ref(domain.RefKindBranch, "refs/heads/a"),
184+ ref(domain.RefKindBranch, "refs/heads/b"),
185+ ref(domain.RefKindBranch, "refs/heads/c"),
186+ ref(domain.RefKindTag, "refs/tags/v1"),
187+ ref(domain.RefKindTag, "refs/tags/v2"),
188+ }
189+ names := func(refs []domain.Ref) []string {
190+ var out []string
191+ for _, r := range refs {
192+ out = append(out, r.Name)
193+ }
194+ return out
195+ }
196+ setup := func(t *testing.T, refs []domain.Ref) *app.Browse {
197+ f := newBrowseFixture(t)
198+ f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
199+ f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
200+ return f.browse
201+ }
202+
203+ t.Run("pages follow each other without gap or repeat", func(t *testing.T) {
204+ b := setup(t, all)
205+ var got []string
206+ tok := ""
207+ for pages := 0; ; pages++ {
208+ require.Less(t, pages, 10)
209+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: tok})
210+ require.NoError(t, err)
211+ got = append(got, names(page.Refs)...)
212+ if tok = page.NextPageToken; tok == "" {
213+ break
214+ }
215+ }
216+ assert.Equal(t, names(all), got)
217+ })
218+
219+ t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) {
220+ b := setup(t, all[:4])
221+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2})
222+ require.NoError(t, err)
223+ require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs))
224+
225+ b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...))
226+ next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken})
227+ require.NoError(t, err)
228+ assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs))
229+ })
230+
231+ t.Run("kinds filter", func(t *testing.T) {
232+ b := setup(t, all)
233+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}})
234+ require.NoError(t, err)
235+ assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs))
236+ assert.Empty(t, page.NextPageToken)
237+ })
238+
239+ t.Run("default and maximum page size", func(t *testing.T) {
240+ var many []domain.Ref
241+ many = append(many, ref(domain.RefKindHead, "HEAD"))
242+ for i := range 250 {
243+ many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%03d", i)))
244+ }
245+ b := setup(t, many)
246+ page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{})
247+ require.NoError(t, err)
248+ assert.Len(t, page.Refs, app.DefaultPageSize)
249+ assert.NotEmpty(t, page.NextPageToken)
250+
251+ b = setup(t, many)
252+ page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 100000})
253+ require.NoError(t, err)
254+ assert.Len(t, page.Refs, app.MaxPageSize)
255+ })
256+
257+ t.Run("bad input never reaches the reader", func(t *testing.T) {
258+ f := newBrowseFixture(t)
259+ for _, params := range []app.ListRefsParams{
260+ {PageSize: -1},
261+ {Kinds: []domain.RefKind{"nope"}},
262+ {PageToken: "!!!"},
263+ {PageToken: "Y2E6eA"}, // a token of another kind of listing
264+ } {
265+ _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params)
266+ assert.Error(t, err, "%+v", params)
267+ }
268+ _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: -1})
269+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
270+ _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{"x"}})
271+ assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
272+ _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageToken: "!!!"})
273+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
274+ })
275+}
276+
277+func TestListCommitsPaging(t *testing.T) {
278+ ctx := context.Background()
279+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
280+ p := mustPath(t, "d/f")
281+
282+ t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) {
283+ f := newBrowseFixture(t)
284+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
285+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}).
286+ Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
287+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageSize: 10})
288+ require.NoError(t, err)
289+ require.Len(t, page.Commits, 1)
290+ require.NotEmpty(t, page.NextPageToken)
291+
292+ // The next page starts from the pinned head, after the commits read.
293+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{
294+ Revision: domain.RevisionOf(oid("a")), Path: p, Skip: 1, Limit: 10,
295+ }).Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}}, nil)
296+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
297+ // The revision of the request is ignored once there is a token.
298+ rev, _ := domain.ParseRevision("moved")
299+ next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{
300+ Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken,
301+ })
302+ require.NoError(t, err)
303+ assert.Equal(t, oid("c"), next.Commits[0].ID)
304+ assert.Empty(t, next.NextPageToken)
305+ })
306+
307+ t.Run("a token is bound to its path and must be well formed", func(t *testing.T) {
308+ f := newBrowseFixture(t)
309+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
310+ f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
311+ Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
312+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p})
313+ require.NoError(t, err)
314+
315+ for _, params := range []app.ListCommitsParams{
316+ {PageToken: page.NextPageToken}, // no path
317+ {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path
318+ {Path: p, PageToken: "garbage"},
319+ {Path: p, PageToken: "cjE6eDp5OnQ"}, // c1:x:y:t is not a token
320+ {Path: p, PageToken: strings.Repeat("A", 2000)},
321+ } {
322+ _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
323+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken, "%+v", params)
324+ }
325+ })
326+
327+ t.Run("page size", func(t *testing.T) {
328+ f := newBrowseFixture(t)
329+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)
330+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: app.DefaultPageSize}).Return(ports.CommitPage{}, nil)
331+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
332+ _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{})
333+ require.NoError(t, err)
334+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: 5000})
335+ require.NoError(t, err)
336+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: -3})
337+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
338+ })
339+
340+ t.Run("the token of a private repository does not open it", func(t *testing.T) {
341+ priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
342+ f := newBrowseFixture(t)
343+ f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
344+ f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything).
345+ Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
346+ page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, app.ListCommitsParams{})
347+ require.NoError(t, err)
348+ f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
349+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, app.ListCommitsParams{PageToken: page.NextPageToken})
350+ assert.ErrorIs(t, err, domain.ErrNotFound)
351+ })
352+}
353+
354+func mustPath(t *testing.T, s string) domain.TreePath {
355+ t.Helper()
356+ p, err := domain.ParseTreePath(s)
357+ require.NoError(t, err)
358+ return p
359+}

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 3

Tags 45

↑↓ moveenter openesc close