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
144func TestDirWritable(t *testing.T) {
145 dir := t.TempDir()
146 check := httpserver.DirWritable(dir)
147 require.NoError(t, check(context.Background()))
148 entries, err := os.ReadDir(dir)
149 require.NoError(t, err)
150 assert.Empty(t, entries, "the probe file is removed")
151
152 assert.Error(t, httpserver.DirWritable(dir+"/missing")(context.Background()))
153
154 if os.Geteuid() != 0 {
155 require.NoError(t, os.Chmod(dir, 0o500))
156 t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
157 assert.Error(t, check(context.Background()))
158 }
159}
160
161func TestServeAndGracefulShutdown(t *testing.T) {
162 ln, err := net.Listen("tcp", "127.0.0.1:0")
163 require.NoError(t, err)
164 ctx, cancel := context.WithCancel(context.Background())
165 defer cancel()
166
167 started := make(chan struct{})
168 release := make(chan struct{})
169 slow := httpserver.Check(func(context.Context) error {
170 close(started)
171 <-release
172 return nil
173 })
174 srv := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{slow}})
175 done := make(chan error, 1)
176 go func() { done <- srv.Serve(ctx, ln) }()
177
178 // A request in flight when shutdown starts is allowed to finish.
179 type result struct {
180 body string
181 err error
182 }
183 res := make(chan result, 1)
184 go func() {
185 resp, err := http.Get("http://" + ln.Addr().String() + "/-/health")
186 if err != nil {
187 res <- result{err: err}
188 return
189 }
190 defer func() { _ = resp.Body.Close() }()
191 b, err := io.ReadAll(resp.Body)
192 res <- result{body: string(b), err: err}
193 }()
194 <-started
195 cancel()
196 select {
197 case err := <-done:
198 t.Fatalf("Serve returned while a request was in flight: %v", err)
199 case <-time.After(200 * time.Millisecond):
200 }
201 close(release)
202
203 r := <-res
204 require.NoError(t, r.err)
205 assert.Equal(t, "ok\n", r.body)
206 select {
207 case err := <-done:
208 assert.NoError(t, err)
209 case <-time.After(5 * time.Second):
210 t.Fatal("Serve did not return after the request finished")
211 }
212 _, err = net.DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond)
213 assert.Error(t, err, "the listener is closed")
214}
215
216func TestRunListenError(t *testing.T) {
217 ln, err := net.Listen("tcp", "127.0.0.1:0")
218 require.NoError(t, err)
219 defer func() { _ = ln.Close() }()
220 err = httpserver.New(httpserver.Config{Addr: ln.Addr().String()}).Run(context.Background())
221 require.Error(t, err)
222 assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())
223}