v0.27.0 0039612

paths_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"compress/gzip"
8	"context"
9	"errors"
10	"io"
11	"net/http"
12	"net/http/httptest"
13	"strings"
14	"testing"
15
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22	"bnema.dev/ermite/internal/ports/mocks"
23)
24
25// pathsSHA is the commit the path list tests ask for.
26var pathsSHA = strings.Repeat("a", 40)
27
28// pathsBrowse resolves any full id to itself and lists a.go and b/c.go (or
29// paths) for the commit, as the anonymous visitor. lists is how many times
30// the list may be built: 0 means ListPaths must not be called.
31func pathsBrowse(t testing.TB, lists int, paths []string) *mocks.BrowseService {
32	t.Helper()
33	browse := mocks.NewBrowseService(t)
34	browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn(
35		func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
36			return domain.ResolvedRevision{CommitID: objectID(t, rev.String())}, nil
37		}).Maybe()
38	if lists > 0 {
39		if paths == nil {
40			paths = []string{"a.go", "b/c.go"}
41		}
42		browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(
43			domain.PathList{CommitID: objectID(t, pathsSHA), Paths: paths}, nil).Times(lists)
44	}
45	return browse
46}
47
48func getPaths(srv *Server, sha string, hdr map[string]string) *httptest.ResponseRecorder {
49	req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil)
50	for k, v := range hdr {
51		req.Header.Set(k, v)
52	}
53	rec := httptest.NewRecorder()
54	srv.ServeHTTP(rec, req)
55	return rec
56}
57
58// A revalidation is answered after the access check and before the list is
59// loaded: it never builds (the mock has no ListPaths to answer). Each variant
60// has its own validator.
61func TestPathsRevalidationDoesNotBuild(t *testing.T) {
62	t.Parallel()
63	gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"`
64	tests := []struct{ name, ae, inm string }{
65		{"gzip", "gzip", gzTag},
66		{"identity", "", plainTag},
67		{"weak", "gzip", "W/" + gzTag},
68		{"any", "", "*"},
69	}
70	for _, tt := range tests {
71		t.Run(tt.name, func(t *testing.T) {
72			t.Parallel()
73			srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: pathsBrowse(t, 0, nil)})
74			rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": tt.ae, "If-None-Match": tt.inm})
75			assert.Equal(t, http.StatusNotModified, rec.Code)
76			assert.Empty(t, rec.Body.String())
77			assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
78			assert.Equal(t, "Accept-Encoding", rec.Header().Get("Vary"))
79			assert.NotEmpty(t, rec.Header().Get("ETag"))
80		})
81	}
82}
83
84// The list is built once, then served from the cache, in the variant the
85// client accepts; the validator of the other variant does not match.
86func TestPathsServesAndCaches(t *testing.T) {
87	t.Parallel()
88	gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"`
89	srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: pathsBrowse(t, 1, nil)})
90
91	assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code)
92	assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"If-None-Match": gzTag}).Code)
93
94	rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip"})
95	assert.Equal(t, gzTag, rec.Header().Get("ETag"))
96	assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
97	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
98	zr, err := gzip.NewReader(rec.Body)
99	require.NoError(t, err)
100	out, err := io.ReadAll(zr)
101	require.NoError(t, err)
102	assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out))
103
104	rec = getPaths(srv, pathsSHA, nil)
105	assert.Equal(t, plainTag, rec.Header().Get("ETag"))
106	assert.Empty(t, rec.Header().Get("Content-Encoding"))
107	assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String())
108}
109
110// The commit is checked as the caller of the request, and only a full id that
111// resolves to itself is served.
112func TestPathsRefusals(t *testing.T) {
113	t.Parallel()
114	other := strings.Repeat("b", 40)
115	tests := []struct {
116		name    string
117		sha     string
118		resolve func() (domain.ResolvedRevision, error)
119		want    int
120	}{
121		{"not a full id", "main", nil, http.StatusNotFound},
122		{"abbreviated id", "aaaaaaa", nil, http.StatusNotFound},
123		{"commit not found or refused to the caller", pathsSHA, func() (domain.ResolvedRevision, error) { return domain.ResolvedRevision{}, domain.ErrNotFound }, http.StatusNotFound},
124		{"a ref named like a commit", pathsSHA, func() (domain.ResolvedRevision, error) {
125			return domain.ResolvedRevision{CommitID: objectID(t, other)}, nil
126		}, http.StatusNotFound},
127		{"backend failure", pathsSHA, func() (domain.ResolvedRevision, error) {
128			return domain.ResolvedRevision{}, errors.New("boom")
129		}, http.StatusInternalServerError},
130	}
131	for _, tt := range tests {
132		t.Run(tt.name, func(t *testing.T) {
133			t.Parallel()
134			browse := mocks.NewBrowseService(t)
135			if tt.resolve != nil {
136				browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn(
137					func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.ResolvedRevision, error) {
138						return tt.resolve()
139					})
140			}
141			srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
142			assert.Equal(t, tt.want, getPaths(srv, tt.sha, nil).Code)
143		})
144	}
145}
146
147// The list is built as the caller of the request: a failure of the use case
148// is the answer.
149func TestPathsListFailure(t *testing.T) {
150	t.Parallel()
151	tests := []struct {
152		name string
153		err  error
154		want int
155	}{
156		{"not found", domain.ErrNotFound, http.StatusNotFound},
157		{"other", errors.New("boom"), http.StatusInternalServerError},
158	}
159	for _, tt := range tests {
160		t.Run(tt.name, func(t *testing.T) {
161			t.Parallel()
162			browse := pathsBrowse(t, 0, nil)
163			browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(domain.PathList{}, tt.err)
164			srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
165			assert.Equal(t, tt.want, getPaths(srv, pathsSHA, nil).Code)
166		})
167	}
168}
169
170// The cached list holds no spare capacity; the truncation is reported; a path
171// is not HTML, so "<", ">" and "&" are not escaped in the JSON.
172func TestBuildPaths(t *testing.T) {
173	t.Parallel()
174	tests := []struct {
175		name      string
176		paths     []string
177		truncated bool
178		want      string
179	}{
180		{"plain", []string{"a.go", "b/c.go"}, false, `{"paths":["a.go","b/c.go"],"truncated":false}`},
181		{"empty", nil, false, `{"paths":[],"truncated":false}`},
182		{"truncated", []string{"a.go"}, true, `{"paths":["a.go"],"truncated":true}`},
183		{"html characters", []string{"a&b/<c>.go"}, false, `{"paths":["a&b/<c>.go"],"truncated":false}`},
184	}
185	for _, tt := range tests {
186		t.Run(tt.name, func(t *testing.T) {
187			t.Parallel()
188			browse := mocks.NewBrowseService(t)
189			browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(
190				domain.PathList{CommitID: objectID(t, pathsSHA), Paths: tt.paths, Truncated: tt.truncated}, nil)
191			srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
192			ctx := domain.ContextWithPrincipal(context.Background(), domain.Anonymous())
193			body, err := srv.buildPaths(ctx, repoName(t, "r"), pathsSHA)
194			require.NoError(t, err)
195			assert.Equal(t, len(body), cap(body))
196			zr, err := gzip.NewReader(bytes.NewReader(body))
197			require.NoError(t, err)
198			out, err := io.ReadAll(zr)
199			require.NoError(t, err)
200			assert.Equal(t, tt.want, strings.TrimSpace(string(out)))
201		})
202	}
203}
204
205// A list that answers for another commit than the one asked is refused.
206func TestBuildPathsWrongCommit(t *testing.T) {
207	t.Parallel()
208	browse := mocks.NewBrowseService(t)
209	browse.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(
210		domain.PathList{CommitID: objectID(t, strings.Repeat("b", 40))}, nil)
211	srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
212	_, err := srv.buildPaths(context.Background(), repoName(t, "r"), pathsSHA)
213	assert.ErrorIs(t, err, domain.ErrNotFound)
214}
215
216var _ ports.BrowseService = (*mocks.BrowseService)(nil)

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