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)