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 "testing"
18 "time"
19
20 "github.com/bnema/zerowrap"
21 "github.com/stretchr/testify/assert"
22 "github.com/stretchr/testify/require"
23
24 "bnema.dev/ermite/internal/adapters/httpserver"
25)
26
27var securityHeaders = map[string]string{
28 "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'",
29 "X-Frame-Options": "DENY",
30 "X-Content-Type-Options": "nosniff",
31 "Referrer-Policy": "same-origin",
32}
33
34func do(t *testing.T, h http.Handler, method, target string) *httptest.ResponseRecorder {
35 t.Helper()
36 rec := httptest.NewRecorder()
37 h.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
38 return rec
39}
40
41func TestHealth(t *testing.T) {
42 ok := httpserver.Check(func(context.Context) error { return nil })
43 fail := httpserver.Check(func(context.Context) error { return errors.New("kv down: secret detail") })
44
45 tests := []struct {
46 name string
47 checks []httpserver.Check
48 method string
49 wantStatus int
50 wantBody string
51 }{
52 {"no checks", nil, "GET", 200, "ok\n"},
53 {"all pass", []httpserver.Check{ok, ok}, "GET", 200, "ok\n"},
54 {"head", []httpserver.Check{ok}, "HEAD", 200, ""}, // the real server drops the body
55 {"one fails", []httpserver.Check{ok, fail}, "GET", 503, "unavailable\n"},
56 {"head fails", []httpserver.Check{fail}, "HEAD", 503, ""},
57 {"post", []httpserver.Check{ok}, "POST", 405, ""},
58 {"delete", []httpserver.Check{ok}, "DELETE", 405, ""},
59 }
60 for _, tt := range tests {
61 t.Run(tt.name, func(t *testing.T) {
62 h := httpserver.New(httpserver.Config{HealthChecks: tt.checks}).Handler()
63 rec := do(t, h, tt.method, "/-/health")
64 assert.Equal(t, tt.wantStatus, rec.Code)
65 if tt.method == "GET" {
66 assert.Equal(t, tt.wantBody, rec.Body.String())
67 }
68 assert.NotContains(t, rec.Body.String(), "secret detail", "the failure cause stays in the log")
69 if tt.wantStatus == 405 {
70 assert.Contains(t, rec.Header().Get("Allow"), "GET")
71 }
72 for k, v := range securityHeaders {
73 assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, tt.name)
74 }
75 })
76 }
77}
78
79func TestHeadOverTheWire(t *testing.T) {
80 ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())
81 defer ts.Close()
82 resp, err := http.Head(ts.URL + "/-/health")
83 require.NoError(t, err)
84 defer func() { _ = resp.Body.Close() }()
85 assert.Equal(t, 200, resp.StatusCode)
86 body, err := io.ReadAll(resp.Body)
87 require.NoError(t, err)
88 assert.Empty(t, body)
89}
90
91func TestNotFoundCarriesSecurityHeaders(t *testing.T) {
92 h := httpserver.New(httpserver.Config{}).Handler()
93 for _, target := range []string{"/", "/nope", "/-/", "/-/health/x", "/-/healthz", "/some-repo.git/info/refs"} {
94 rec := do(t, h, "GET", target)
95 assert.Equal(t, 404, rec.Code, target)
96 for k, v := range securityHeaders {
97 assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, target)
98 }
99 }
100}
101
102type syncBuffer struct {
103 mu sync.Mutex
104 b bytes.Buffer
105}
106
107func (s *syncBuffer) Write(p []byte) (int, error) {
108 s.mu.Lock()
109 defer s.mu.Unlock()
110 return s.b.Write(p)
111}
112
113func (s *syncBuffer) String() string {
114 s.mu.Lock()
115 defer s.mu.Unlock()
116 return s.b.String()
117}
118
119func TestRequestLog(t *testing.T) {
120 var buf syncBuffer
121 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
122 h := httpserver.New(httpserver.Config{}).Handler()
123
124 req := httptest.NewRequestWithContext(ctx, "GET", "/missing?token=hunter2", nil)
125 req.Header.Set("Authorization", "Bearer hunter2")
126 h.ServeHTTP(httptest.NewRecorder(), req)
127
128 var entry map[string]any
129 require.NoError(t, json.Unmarshal(bytes.TrimSpace([]byte(buf.String())), &entry), buf.String())
130 assert.Equal(t, "debug", entry["level"])
131 assert.Equal(t, "GET", entry["method"])
132 assert.Equal(t, "/missing", entry["path"])
133 assert.EqualValues(t, 404, entry["status"])
134 assert.Contains(t, entry, "duration")
135 assert.NotContains(t, buf.String(), "hunter2")
136
137 // At info level a request logs nothing.
138 var quiet syncBuffer
139 ctx = zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &quiet}))
140 h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "GET", "/missing", nil))
141 assert.Empty(t, quiet.String())
142}
143
144// The secret of a login link never reaches the request log, whatever shape
145// the path takes.
146func TestRequestLogCutsLoginLinks(t *testing.T) {
147 var buf syncBuffer
148 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
149 h := httpserver.New(httpserver.Config{}).Handler()
150 for _, target := range []string{"/-/login/sEcReT", "//-/login/sEcReT", "/x/../-/login/sEcReT", "/-/login/sEcReT/more", "/-/./login/sEcReT"} {
151 h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "POST", target, nil))
152 }
153 assert.NotContains(t, buf.String(), "sEcReT")
154 assert.Contains(t, buf.String(), "/-/login/…")
155}
156
157func TestDirWritable(t *testing.T) {
158 dir := t.TempDir()
159 check := httpserver.DirWritable(dir)
160 require.NoError(t, check(context.Background()))
161 entries, err := os.ReadDir(dir)
162 require.NoError(t, err)
163 assert.Empty(t, entries, "the probe file is removed")
164
165 assert.Error(t, httpserver.DirWritable(dir+"/missing")(context.Background()))
166
167 if os.Geteuid() != 0 {
168 require.NoError(t, os.Chmod(dir, 0o500))
169 t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
170 assert.Error(t, check(context.Background()))
171 }
172}
173
174func TestServeAndGracefulShutdown(t *testing.T) {
175 ln, err := net.Listen("tcp", "127.0.0.1:0")
176 require.NoError(t, err)
177 ctx, cancel := context.WithCancel(context.Background())
178 defer cancel()
179
180 started := make(chan struct{})
181 release := make(chan struct{})
182 slow := httpserver.Check(func(context.Context) error {
183 close(started)
184 <-release
185 return nil
186 })
187 srv := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{slow}})
188 done := make(chan error, 1)
189 go func() { done <- srv.Serve(ctx, ln) }()
190
191 // A request in flight when shutdown starts is allowed to finish.
192 type result struct {
193 body string
194 err error
195 }
196 res := make(chan result, 1)
197 go func() {
198 resp, err := http.Get("http://" + ln.Addr().String() + "/-/health")
199 if err != nil {
200 res <- result{err: err}
201 return
202 }
203 defer func() { _ = resp.Body.Close() }()
204 b, err := io.ReadAll(resp.Body)
205 res <- result{body: string(b), err: err}
206 }()
207 <-started
208 cancel()
209 select {
210 case err := <-done:
211 t.Fatalf("Serve returned while a request was in flight: %v", err)
212 case <-time.After(200 * time.Millisecond):
213 }
214 close(release)
215
216 r := <-res
217 require.NoError(t, r.err)
218 assert.Equal(t, "ok\n", r.body)
219 select {
220 case err := <-done:
221 assert.NoError(t, err)
222 case <-time.After(5 * time.Second):
223 t.Fatal("Serve did not return after the request finished")
224 }
225 _, err = net.DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond)
226 assert.Error(t, err, "the listener is closed")
227}
228
229func TestRunListenError(t *testing.T) {
230 ln, err := net.Listen("tcp", "127.0.0.1:0")
231 require.NoError(t, err)
232 defer func() { _ = ln.Close() }()
233 err = httpserver.New(httpserver.Config{Addr: ln.Addr().String()}).Run(context.Background())
234 require.Error(t, err)
235 assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())
236}