1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package httpserver_test
4
5import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "errors"
10 "io"
11 "net"
12 "net/http"
13 "net/http/httptest"
14 "os"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "testing"
19 "testing/synctest"
20 "time"
21
22 "github.com/bnema/zerowrap"
23 "github.com/stretchr/testify/assert"
24 "github.com/stretchr/testify/require"
25
26 "bnema.dev/ermite/internal/adapters/httpserver"
27)
28
29var securityHeaders = map[string]string{
30 "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; media-src 'self' https://github.com http://github.com https://user-images.githubusercontent.com http://user-images.githubusercontent.com https://private-user-images.githubusercontent.com http://private-user-images.githubusercontent.com https://github-production-user-asset-6210df.s3.amazonaws.com http://github-production-user-asset-6210df.s3.amazonaws.com",
31 "X-Frame-Options": "DENY",
32 "X-Content-Type-Options": "nosniff",
33 "Referrer-Policy": "same-origin",
34}
35
36func do(t *testing.T, h http.Handler, method, target string) *httptest.ResponseRecorder {
37 t.Helper()
38 rec := httptest.NewRecorder()
39 h.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
40 return rec
41}
42
43func TestHealth(t *testing.T) {
44 ok := httpserver.Check(func(context.Context) error { return nil })
45 fail := httpserver.Check(func(context.Context) error { return errors.New("kv down: secret detail") })
46
47 tests := []struct {
48 name string
49 checks []httpserver.Check
50 method string
51 wantStatus int
52 wantBody string
53 }{
54 {"no checks", nil, "GET", 200, "ok\n"},
55 {"all pass", []httpserver.Check{ok, ok}, "GET", 200, "ok\n"},
56 {"head", []httpserver.Check{ok}, "HEAD", 200, ""}, // the real server drops the body
57 {"one fails", []httpserver.Check{ok, fail}, "GET", 503, "unavailable\n"},
58 {"head fails", []httpserver.Check{fail}, "HEAD", 503, ""},
59 {"post", []httpserver.Check{ok}, "POST", 405, ""},
60 {"delete", []httpserver.Check{ok}, "DELETE", 405, ""},
61 }
62 for _, tt := range tests {
63 t.Run(tt.name, func(t *testing.T) {
64 h := httpserver.New(httpserver.Config{HealthChecks: tt.checks}).Handler()
65 rec := do(t, h, tt.method, "/-/health")
66 assert.Equal(t, tt.wantStatus, rec.Code)
67 if tt.method == "GET" {
68 assert.Equal(t, tt.wantBody, rec.Body.String())
69 }
70 assert.NotContains(t, rec.Body.String(), "secret detail", "the failure cause stays in the log")
71 if tt.wantStatus == 405 {
72 assert.Contains(t, rec.Header().Get("Allow"), "GET")
73 }
74 for k, v := range securityHeaders {
75 assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, tt.name)
76 }
77 })
78 }
79}
80
81func TestHealthIsCached(t *testing.T) {
82 synctest.Test(t, func(t *testing.T) {
83 var calls atomic.Int32
84 var failing atomic.Bool
85 check := httpserver.Check(func(context.Context) error {
86 calls.Add(1)
87 if failing.Load() {
88 return errors.New("down")
89 }
90 return nil
91 })
92 h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler()
93
94 steps := []struct {
95 name string
96 sleep time.Duration
97 fail bool
98 wantStatus int
99 wantCalls int32
100 }{
101 {"first request runs the checks", 0, false, 200, 1},
102 {"a burst reuses the result", 4 * time.Second, true, 200, 1},
103 {"after the TTL the checks run again", time.Second, true, 503, 2},
104 {"a failure is cached too", time.Second, false, 503, 2},
105 {"and recovers after the TTL", 4 * time.Second, false, 200, 3},
106 }
107 for _, st := range steps {
108 time.Sleep(st.sleep)
109 failing.Store(st.fail)
110 rec := do(t, h, "GET", "/-/health")
111 assert.Equal(t, st.wantStatus, rec.Code, st.name)
112 assert.Equal(t, st.wantCalls, calls.Load(), st.name)
113 }
114 })
115}
116
117func TestHealthConcurrentRequestsShareOneRun(t *testing.T) {
118 var calls atomic.Int32
119 check := httpserver.Check(func(context.Context) error { calls.Add(1); return nil })
120 h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler()
121
122 var wg sync.WaitGroup
123 for range 20 {
124 wg.Go(func() { assert.Equal(t, 200, do(t, h, "GET", "/-/health").Code) })
125 }
126 wg.Wait()
127 assert.Equal(t, int32(1), calls.Load())
128}
129
130func TestStrictTransportSecurity(t *testing.T) {
131 tests := []struct {
132 name string
133 publicURL string
134 want string
135 }{
136 {"https", "https://forge.example.com", "max-age=31536000"},
137 {"https uppercase scheme", "HTTPS://forge.example.com", "max-age=31536000"},
138 {"http", "http://forge.example.com", ""},
139 {"http loopback", "http://127.0.0.1:7341", ""},
140 {"unset", "", ""},
141 {"garbage", "://%", ""},
142 }
143 for _, tt := range tests {
144 t.Run(tt.name, func(t *testing.T) {
145 h := httpserver.New(httpserver.Config{PublicURL: tt.publicURL}).Handler()
146 for _, target := range []string{"/-/health", "/nope"} {
147 rec := do(t, h, "GET", target)
148 assert.Equal(t, tt.want, rec.Header().Get("Strict-Transport-Security"), target)
149 }
150 })
151 }
152}
153
154func TestHeadOverTheWire(t *testing.T) {
155 ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())
156 defer ts.Close()
157 resp, err := http.Head(ts.URL + "/-/health")
158 require.NoError(t, err)
159 defer func() { _ = resp.Body.Close() }()
160 assert.Equal(t, 200, resp.StatusCode)
161 body, err := io.ReadAll(resp.Body)
162 require.NoError(t, err)
163 assert.Empty(t, body)
164}
165
166func TestNotFoundCarriesSecurityHeaders(t *testing.T) {
167 h := httpserver.New(httpserver.Config{}).Handler()
168 for _, target := range []string{"/", "/nope", "/-/", "/-/health/x", "/-/healthz", "/some-repo.git/info/refs"} {
169 rec := do(t, h, "GET", target)
170 assert.Equal(t, 404, rec.Code, target)
171 for k, v := range securityHeaders {
172 assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, target)
173 }
174 }
175}
176
177type syncBuffer struct {
178 mu sync.Mutex
179 b bytes.Buffer
180}
181
182func (s *syncBuffer) Write(p []byte) (int, error) {
183 s.mu.Lock()
184 defer s.mu.Unlock()
185 return s.b.Write(p)
186}
187
188func (s *syncBuffer) String() string {
189 s.mu.Lock()
190 defer s.mu.Unlock()
191 return s.b.String()
192}
193
194func TestRequestLog(t *testing.T) {
195 var buf syncBuffer
196 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
197 h := httpserver.New(httpserver.Config{}).Handler()
198
199 req := httptest.NewRequestWithContext(ctx, "GET", "/missing?token=hunter2", nil)
200 req.Header.Set("Authorization", "Bearer hunter2")
201 h.ServeHTTP(httptest.NewRecorder(), req)
202
203 var entry map[string]any
204 require.NoError(t, json.Unmarshal(bytes.TrimSpace([]byte(buf.String())), &entry), buf.String())
205 assert.Equal(t, "debug", entry["level"])
206 assert.Equal(t, "GET", entry["method"])
207 assert.Equal(t, "/missing", entry["path"])
208 assert.EqualValues(t, 404, entry["status"])
209 assert.Contains(t, entry, "duration")
210 assert.NotContains(t, buf.String(), "hunter2")
211
212 // At info level a request logs nothing.
213 var quiet syncBuffer
214 ctx = zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &quiet}))
215 h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "GET", "/missing", nil))
216 assert.Empty(t, quiet.String())
217}
218
219// The secret of a login link never reaches the request log, whatever shape
220// the path takes.
221func TestRequestLogCutsLoginLinks(t *testing.T) {
222 var buf syncBuffer
223 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
224 h := httpserver.New(httpserver.Config{}).Handler()
225 for _, target := range []string{"/-/login/sEcReT", "//-/login/sEcReT", "/x/../-/login/sEcReT", "/-/login/sEcReT/more", "/-/./login/sEcReT"} {
226 h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "POST", target, nil))
227 }
228 assert.NotContains(t, buf.String(), "sEcReT")
229 assert.Contains(t, buf.String(), "/-/login/…")
230}
231
232func TestDirWritable(t *testing.T) {
233 dir := t.TempDir()
234 check := httpserver.DirWritable(dir)
235 require.NoError(t, check(context.Background()))
236 entries, err := os.ReadDir(dir)
237 require.NoError(t, err)
238 assert.Empty(t, entries, "the probe file is removed")
239
240 assert.Error(t, httpserver.DirWritable(dir+"/missing")(context.Background()))
241
242 if os.Geteuid() != 0 {
243 require.NoError(t, os.Chmod(dir, 0o500))
244 t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
245 assert.Error(t, check(context.Background()))
246 }
247}
248
249func TestServeAndGracefulShutdown(t *testing.T) {
250 ln, err := net.Listen("tcp", "127.0.0.1:0")
251 require.NoError(t, err)
252 ctx, cancel := context.WithCancel(context.Background())
253 defer cancel()
254
255 started := make(chan struct{})
256 release := make(chan struct{})
257 slow := httpserver.Check(func(context.Context) error {
258 close(started)
259 <-release
260 return nil
261 })
262 srv := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{slow}})
263 done := make(chan error, 1)
264 go func() { done <- srv.Serve(ctx, ln) }()
265
266 // A request in flight when shutdown starts is allowed to finish.
267 type result struct {
268 body string
269 err error
270 }
271 res := make(chan result, 1)
272 go func() {
273 resp, err := http.Get("http://" + ln.Addr().String() + "/-/health")
274 if err != nil {
275 res <- result{err: err}
276 return
277 }
278 defer func() { _ = resp.Body.Close() }()
279 b, err := io.ReadAll(resp.Body)
280 res <- result{body: string(b), err: err}
281 }()
282 <-started
283 cancel()
284 select {
285 case err := <-done:
286 t.Fatalf("Serve returned while a request was in flight: %v", err)
287 case <-time.After(200 * time.Millisecond):
288 }
289 close(release)
290
291 r := <-res
292 require.NoError(t, r.err)
293 assert.Equal(t, "ok\n", r.body)
294 select {
295 case err := <-done:
296 assert.NoError(t, err)
297 case <-time.After(5 * time.Second):
298 t.Fatal("Serve did not return after the request finished")
299 }
300 _, err = net.DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond)
301 assert.Error(t, err, "the listener is closed")
302}
303
304func TestRunListenError(t *testing.T) {
305 ln, err := net.Listen("tcp", "127.0.0.1:0")
306 require.NoError(t, err)
307 defer func() { _ = ln.Close() }()
308 err = httpserver.New(httpserver.Config{Addr: ln.Addr().String()}).Run(context.Background())
309 require.Error(t, err)
310 assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())
311}
312
313func TestVideoHostCSP(t *testing.T) {
314 for _, hosts := range [][]string{{"clips.example"}, {}} {
315 t.Run(strings.Join(hosts, ","), func(t *testing.T) {
316 srv := httpserver.New(httpserver.Config{VideoHosts: hosts})
317 rec := httptest.NewRecorder()
318 srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/missing", nil))
319 csp := rec.Header().Get("Content-Security-Policy")
320 assert.Contains(t, csp, "; media-src 'self'")
321 assert.NotContains(t, csp, "github.com")
322 assert.NotContains(t, csp, "unsafe-inline")
323 if len(hosts) > 0 {
324 assert.Contains(t, csp, "https://clips.example http://clips.example")
325 }
326 })
327 }
328}