v0.6.0 d5cc8fe

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	"io"
10	"net/http"
11	"net/http/httptest"
12	"strings"
13	"sync/atomic"
14	"testing"
15
16	"connectrpc.com/connect"
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/require"
19
20	pb "bnema.dev/ermite-api/gen/ermite"
21	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22)
23
24// pathsGit is a Git client that resolves any revision to itself and counts
25// the lists it builds.
26type pathsGit struct {
27	ermiteconnect.GitServiceClient
28	lists atomic.Int32
29	paths []string // the list; a.go and b/c.go when nil
30}
31
32func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
33	return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: req.Msg.GetRevision()}), nil
34}
35
36func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) {
37	g.lists.Add(1)
38	paths := g.paths
39	if paths == nil {
40		paths = []string{"a.go", "b/c.go"}
41	}
42	return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: paths}), nil
43}
44
45// A revalidation is answered after the access check and before the list is
46// loaded: it never builds. Each variant has its own validator.
47func TestPathsRevalidationDoesNotBuild(t *testing.T) {
48	git := &pathsGit{}
49	srv, err := New(Config{
50		Repos: failing{}, Git: git,
51		PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
52	})
53	require.NoError(t, err)
54	sha := strings.Repeat("a", 40)
55	get := func(hdr map[string]string) *httptest.ResponseRecorder {
56		req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil)
57		for k, v := range hdr {
58			req.Header.Set(k, v)
59		}
60		rec := httptest.NewRecorder()
61		srv.ServeHTTP(rec, req)
62		return rec
63	}
64	gzTag, plainTag := `"paths-`+sha+`-gz"`, `"paths-`+sha+`"`
65
66	for _, tt := range []struct{ name, ae, inm string }{
67		{"gzip", "gzip", gzTag},
68		{"identity", "", plainTag},
69		{"weak", "gzip", "W/" + gzTag},
70		{"any", "", "*"},
71	} {
72		rec := get(map[string]string{"Accept-Encoding": tt.ae, "If-None-Match": tt.inm})
73		assert.Equal(t, http.StatusNotModified, rec.Code, tt.name)
74		assert.Empty(t, rec.Body.String(), tt.name)
75		assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), tt.name)
76		assert.Equal(t, "Accept-Encoding", rec.Header().Get("Vary"), tt.name)
77		assert.NotEmpty(t, rec.Header().Get("ETag"), tt.name)
78	}
79	assert.Zero(t, git.lists.Load(), "a 304 never builds")
80
81	// The validator of the other variant does not match.
82	assert.Equal(t, http.StatusOK, get(map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code)
83	assert.Equal(t, http.StatusOK, get(map[string]string{"If-None-Match": gzTag}).Code)
84	assert.EqualValues(t, 1, git.lists.Load(), "built once, then cached")
85
86	rec := get(map[string]string{"Accept-Encoding": "gzip"})
87	assert.Equal(t, gzTag, rec.Header().Get("ETag"))
88	assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
89	zr, err := gzip.NewReader(rec.Body)
90	require.NoError(t, err)
91	out, err := io.ReadAll(zr)
92	require.NoError(t, err)
93	assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out))
94
95	rec = get(nil)
96	assert.Equal(t, plainTag, rec.Header().Get("ETag"))
97	assert.Empty(t, rec.Header().Get("Content-Encoding"))
98	assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String())
99}
100
101// The cached list holds no spare capacity.
102func TestBuildPathsCachesATightBody(t *testing.T) {
103	srv, err := New(Config{Repos: failing{}, Git: &pathsGit{}, PublicURL: "https://forge.example"})
104	require.NoError(t, err)
105	body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40))
106	require.NoError(t, err)
107	assert.Equal(t, len(body), cap(body))
108}
109
110func TestAcceptsGzip(t *testing.T) {
111	for _, tt := range []struct {
112		header string
113		want   bool
114	}{
115		{"", false},
116		{"gzip", true},
117		{"GZip", true},
118		{"deflate, gzip", true},
119		{"deflate, br", false},
120		{"gzip;q=0", false},
121		{"gzip; q=0", false},
122		{"gzip;q=0.0", false},
123		{"gzip ; q = 0", false},
124		{"gzip;q=0.5", true},
125		{"gzip;q=1", true},
126		{"identity, gzip;q=0", false},
127		{"gzip;q=0, deflate", false},
128		{"gzip;q=bad", true},
129		{"x-gzip", false},
130		// A wildcard is not read: the finder asks for gzip by name, and the
131		// plain body is always right.
132		{"*", false},
133	} {
134		assert.Equal(t, tt.want, acceptsGzip(tt.header), "%q", tt.header)
135	}
136}
137
138func TestEtagMatches(t *testing.T) {
139	const tag = `"paths-x"`
140	for _, tt := range []struct {
141		header string
142		want   bool
143	}{
144		{"", false},
145		{tag, true},
146		{"W/" + tag, true},
147		{`"other"`, false},
148		{`"a", ` + tag, true},
149		{`"a",` + tag + `,"b"`, true},
150		{`"a", W/` + tag, true},
151		{`"a", "b"`, false},
152		{"*", true},
153		{`"a", *`, true},
154		{`"paths-x-gz"`, false},
155		{`w/` + tag, false},
156	} {
157		assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header)
158	}
159}
160
161// A path is not HTML: "<", ">" and "&" are not escaped in the JSON.
162func TestBuildPathsDoesNotEscapeHTML(t *testing.T) {
163	git := &pathsGit{paths: []string{"a&b/<c>.go"}}
164	srv, err := New(Config{Repos: failing{}, Git: git, PublicURL: "https://forge.example"})
165	require.NoError(t, err)
166	body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40))
167	require.NoError(t, err)
168	zr, err := gzip.NewReader(bytes.NewReader(body))
169	require.NoError(t, err)
170	out, err := io.ReadAll(zr)
171	require.NoError(t, err)
172	assert.Contains(t, string(out), `"a&b/<c>.go"`)
173}

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