v0.10.1 e4c85c7

server_test.go RawHistoryFolder

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'",
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close