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}