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}