v0.15.0 8a6cfec

compress_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	"time"
16
17	"github.com/a-h/templ"
18	"github.com/stretchr/testify/assert"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite/internal/ports/mocks"
22)
23
24func TestAcceptsGzip(t *testing.T) {
25	t.Parallel()
26	for _, tc := range []struct {
27		name   string
28		header []string
29		want   bool
30	}{
31		{"absent", nil, false},
32		{"gzip", []string{"gzip"}, true},
33		{"list", []string{"deflate, gzip, br"}, true},
34		{"case", []string{"GZip"}, true},
35		{"x-gzip alias", []string{"x-gzip"}, true},
36		{"q=1", []string{"gzip;q=1.0"}, true},
37		{"q spaces", []string{"gzip ; q=0.5"}, true},
38		{"q=0 refuses", []string{"gzip;q=0"}, false},
39		{"q=0.0 refuses", []string{"br, gzip;q=0.0"}, false},
40		{"bad q refuses", []string{"gzip;q=abc"}, false},
41		{"identity only", []string{"identity"}, false},
42		{"br only", []string{"br"}, false},
43		{"star", []string{"*"}, true},
44		{"star q=0", []string{"*;q=0"}, false},
45		{"gzip refused beats star", []string{"gzip;q=0, *"}, false},
46		{"gzip accepted beats star q=0", []string{"*;q=0, gzip"}, true},
47		{"several lines", []string{"br", "gzip"}, true},
48		{"substring is not a coding", []string{"gzipped"}, false},
49	} {
50		t.Run(tc.name, func(t *testing.T) {
51			t.Parallel()
52			h := http.Header{}
53			for _, v := range tc.header {
54				h.Add("Accept-Encoding", v)
55			}
56			assert.Equal(t, tc.want, acceptsGzip(h))
57		})
58	}
59}
60
61func TestEtagMatches(t *testing.T) {
62	t.Parallel()
63	const tag = `"abc"`
64	for _, tc := range []struct {
65		name, header, etag string
66		want               bool
67	}{
68		{"empty", "", tag, false},
69		{"same", `"abc"`, tag, true},
70		{"other", `"abd"`, tag, false},
71		{"weak client tag", `W/"abc"`, tag, true},
72		{"list", `"x", "abc", "y"`, tag, true},
73		{"list no space", `"x","abc"`, tag, true},
74		{"list without it", `"x", W/"y"`, tag, false},
75		{"star", `*`, tag, true},
76		{"unquoted", `abc`, tag, false},
77		{"unterminated", `"abc`, tag, false},
78		{"prefix is not equal", `"ab"`, tag, false},
79		{"weak server tag", `"abc"`, `W/"abc"`, true},
80		{"comma in a tag", `"a,b", "abc"`, tag, true},
81	} {
82		t.Run(tc.name, func(t *testing.T) {
83			t.Parallel()
84			assert.Equal(t, tc.want, etagMatches(tc.header, tc.etag))
85		})
86	}
87}
88
89func TestPageETag(t *testing.T) {
90	t.Parallel()
91	body := []byte("<html>x</html>")
92	a := pageETag(body, false, false)
93	assert.Equal(t, a, pageETag(body, false, false), "stable")
94	assert.NotEqual(t, a, pageETag(body, true, false), "owner and visitor do not share a tag")
95	assert.NotEqual(t, a, pageETag(body, false, true), "gzip is another representation")
96	assert.NotEqual(t, a, pageETag([]byte("<html>y</html>"), false, false))
97	assert.True(t, strings.HasPrefix(a, `"`) && strings.HasSuffix(a, `"`))
98}
99
100func newBareServer(t *testing.T) *Server {
101	t.Helper()
102	return newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)})
103}
104
105func gunzip(t *testing.T, b []byte) string {
106	t.Helper()
107	zr, err := gzip.NewReader(bytes.NewReader(b))
108	require.NoError(t, err)
109	out, err := io.ReadAll(zr)
110	require.NoError(t, err)
111	return string(out)
112}
113
114// write: gzip by size and Accept-Encoding, validators, 304.
115func TestWritePage(t *testing.T) {
116	t.Parallel()
117	big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>"
118	small := "<p>tiny</p>"
119
120	for _, tc := range []struct {
121		name     string
122		method   string
123		ae       string
124		body     string
125		status   int
126		cc       string // Cache-Control set by the handler beforehand
127		wantGzip bool
128		wantTag  bool
129	}{
130		{"big page, gzip", "GET", "gzip", big, 200, "", true, true},
131		{"big page, no Accept-Encoding", "GET", "", big, 200, "", false, true},
132		{"big page, gzip refused", "GET", "gzip;q=0", big, 200, "", false, true},
133		{"big page, br only", "GET", "br", big, 200, "", false, true},
134		{"small page not compressed", "GET", "gzip", small, 200, "", false, true},
135		{"HEAD keeps headers, no body", "HEAD", "gzip", big, 200, "", true, true},
136		{"404 page compressed, no tag", "GET", "gzip", big, 404, "", true, false},
137		{"no-store handler: no tag", "GET", "gzip", big, 200, "no-store", true, false},
138		{"no-transform: untouched", "GET", "gzip", big, 200, "no-transform", false, true},
139	} {
140		t.Run(tc.name, func(t *testing.T) {
141			t.Parallel()
142			srv := newBareServer(t)
143			rec := httptest.NewRecorder()
144			r := httptest.NewRequestWithContext(context.Background(), tc.method, "/", nil)
145			if tc.ae != "" {
146				r.Header.Set("Accept-Encoding", tc.ae)
147			}
148			if tc.cc != "" {
149				rec.Header().Set("Cache-Control", tc.cc)
150			}
151			srv.write(r.Context(), rec, r, tc.status, templ.Raw(tc.body))
152
153			res := rec.Result()
154			t.Cleanup(func() { _ = res.Body.Close() })
155			raw, _ := io.ReadAll(res.Body)
156			assert.Equal(t, tc.status, res.StatusCode)
157			assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding")
158			assert.Equal(t, tc.wantTag, res.Header.Get("ETag") != "")
159			switch {
160			case tc.method == "HEAD":
161				assert.Empty(t, raw)
162				assert.Equal(t, tc.wantGzip, res.Header.Get("Content-Encoding") == "gzip")
163			case tc.wantGzip:
164				assert.Equal(t, "gzip", res.Header.Get("Content-Encoding"))
165				assert.Equal(t, itoa(len(raw)), res.Header.Get("Content-Length"))
166				assert.Less(t, len(raw), len(tc.body))
167				assert.Equal(t, tc.body, gunzip(t, raw))
168			default:
169				assert.Empty(t, res.Header.Get("Content-Encoding"))
170				assert.Equal(t, tc.body, string(raw))
171			}
172		})
173	}
174}
175
176func TestWritePageConditional(t *testing.T) {
177	t.Parallel()
178	big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>"
179	srv := newBareServer(t)
180	do := func(ae, inm string) *http.Response {
181		rec := httptest.NewRecorder()
182		r := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil)
183		if ae != "" {
184			r.Header.Set("Accept-Encoding", ae)
185		}
186		if inm != "" {
187			r.Header.Set("If-None-Match", inm)
188		}
189		srv.write(r.Context(), rec, r, 200, templ.Raw(big))
190		res := rec.Result()
191		t.Cleanup(func() { _ = res.Body.Close() })
192		return res
193	}
194	plain, gz := do("", "").Header.Get("ETag"), do("gzip", "").Header.Get("ETag")
195	require.NotEmpty(t, plain)
196	require.NotEmpty(t, gz)
197	assert.NotEqual(t, plain, gz, "identity and gzip are different representations")
198
199	for _, tc := range []struct {
200		name, ae, inm string
201		want          int
202	}{
203		{"identity match", "", plain, 304},
204		{"gzip match", "gzip", gz, 304},
205		{"list", "gzip", `"nope", ` + gz, 304},
206		{"weak prefix", "", "W/" + plain, 304},
207		{"star", "", "*", 304},
208		{"identity tag for gzip request", "gzip", plain, 200},
209		{"gzip tag for identity request", "", gz, 200},
210		{"stale", "", `"stale"`, 200},
211	} {
212		t.Run(tc.name, func(t *testing.T) {
213			t.Parallel()
214			res := do(tc.ae, tc.inm)
215			assert.Equal(t, tc.want, res.StatusCode)
216			b, _ := io.ReadAll(res.Body)
217			if tc.want == 304 {
218				assert.Empty(t, b)
219				assert.NotEmpty(t, res.Header.Get("ETag"))
220				assert.Equal(t, "no-cache", res.Header.Get("Cache-Control"))
221				assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding")
222				assert.Empty(t, res.Header.Get("Content-Encoding"))
223			} else {
224				assert.NotEmpty(t, b)
225			}
226		})
227	}
228}
229
230// The owner's page is private and never revalidated, whatever the client sends.
231func TestWritePageOwnerHasNoValidator(t *testing.T) {
232	t.Parallel()
233	srv := newBareServer(t)
234	ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
235	rec := httptest.NewRecorder()
236	r := httptest.NewRequestWithContext(ctx, "GET", "/", nil)
237	r.Header.Set("If-None-Match", "*")
238	srv.write(ctx, rec, r, 200, templ.Raw("<p>mine</p>"))
239	assert.Equal(t, 200, rec.Code)
240	assert.Equal(t, sessionCache, rec.Header().Get("Cache-Control"))
241	assert.Empty(t, rec.Header().Get("ETag"))
242	assert.Contains(t, rec.Header().Values("Vary"), "Cookie")
243	assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding")
244}
245
246// The assets: gzip once at load, chosen by Accept-Encoding, not for fonts.
247func TestAssetsGzip(t *testing.T) {
248	t.Parallel()
249	srv := newBareServer(t)
250	get := func(url, ae string) *httptest.ResponseRecorder {
251		rec := httptest.NewRecorder()
252		r := httptest.NewRequestWithContext(context.Background(), "GET", url, nil)
253		if ae != "" {
254			r.Header.Set("Accept-Encoding", ae)
255		}
256		srv.ServeHTTP(rec, r)
257		return rec
258	}
259	u := srv.assets.urls
260	for _, tc := range []struct {
261		name, url, ae string
262		wantGzip      bool
263		vary          bool
264	}{
265		{"css gzip", u.CSS, "gzip, br", true, true},
266		{"css identity", u.CSS, "", false, true},
267		{"css refused", u.CSS, "gzip;q=0", false, true},
268		{"js gzip", u.HTMX, "gzip", true, true},
269		{"rules gzip", u.Rules, "gzip", true, true},
270		{"svg gzip", u.Icon, "gzip", true, true},
271		{"font never", u.Font, "gzip", false, false},
272	} {
273		t.Run(tc.name, func(t *testing.T) {
274			t.Parallel()
275			rec := get(tc.url, tc.ae)
276			require.Equal(t, 200, rec.Code)
277			assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control"))
278			assert.Equal(t, tc.vary, strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "Accept-Encoding"))
279			assert.Equal(t, itoa(rec.Body.Len()), rec.Header().Get("Content-Length"))
280			plain := srv.assets.byName[strings.TrimPrefix(tc.url, StaticPrefix)].body
281			if tc.wantGzip {
282				assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
283				assert.Less(t, rec.Body.Len(), len(plain))
284				assert.Equal(t, string(plain), gunzip(t, rec.Body.Bytes()))
285			} else {
286				assert.Empty(t, rec.Header().Get("Content-Encoding"))
287				assert.Equal(t, string(plain), rec.Body.String())
288			}
289		})
290	}
291
292	t.Run("HEAD has the length of the encoded body", func(t *testing.T) {
293		rec := httptest.NewRecorder()
294		r := httptest.NewRequestWithContext(context.Background(), "HEAD", u.CSS, nil)
295		r.Header.Set("Accept-Encoding", "gzip")
296		srv.ServeHTTP(rec, r)
297		assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
298		assert.Equal(t, itoa(len(srv.assets.byName[strings.TrimPrefix(u.CSS, StaticPrefix)].gz)), rec.Header().Get("Content-Length"))
299		assert.Zero(t, rec.Body.Len())
300	})
301
302	t.Run("compressible types", func(t *testing.T) {
303		for typ, want := range map[string]bool{
304			"text/css; charset=utf-8": true, "text/javascript; charset=utf-8": true,
305			"application/speculationrules+json": true, "image/svg+xml": true,
306			"font/woff2": false, "image/png": false,
307		} {
308			assert.Equal(t, want, compressible(typ), typ)
309		}
310	})
311}
312
313// The event stream is never compressed, even for a client that accepts gzip.
314func TestEventsNotCompressed(t *testing.T) {
315	t.Parallel()
316	f := &watchProbe{release: make(chan struct{})}
317	ts := eventsServer(t, f, 20*time.Millisecond, 0)
318	resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"})
319	require.Equal(t, http.StatusOK, resp.StatusCode)
320	assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
321	assert.Empty(t, resp.Header.Get("Content-Encoding"))
322	assert.Empty(t, resp.Header.Get("ETag"))
323	assert.Contains(t, resp.Header.Get("Cache-Control"), "no-transform")
324}

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close