v0.1.0 3f267b5

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

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