v0.6.0 d5cc8fe

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

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 3

Tags 44

↑↓ moveenter openesc close