v2.31.0-dev2 4b72742

run_http_handlers_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"io"
6	"net/http"
7	"net/http/httptest"
8	"os"
9	"path/filepath"
10	"testing"
11
12	"github.com/bnema/zerowrap"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
17	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
18)
19
20func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
21	t.Parallel()
22
23	cfg := Config{}
24	cfg.Auth.Enabled = false
25
26	store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default())
27	if err != nil {
28		t.Fatalf("expected no error when auth is disabled, got %v", err)
29	}
30	if store != nil {
31		t.Fatalf("expected nil token store when auth is disabled")
32	}
33	if authSvc != nil {
34		t.Fatalf("expected nil auth service when auth is disabled")
35	}
36}
37
38func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
39	t.Parallel()
40
41	cfg := Config{}
42	cfg.Auth.Enabled = false
43
44	svc := &services{adminHandler: &adminhttp.Handler{}}
45	registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
46
47	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
48	req.RemoteAddr = "192.0.2.10:12345"
49
50	rec := httptest.NewRecorder()
51	registryHandler.ServeHTTP(rec, req)
52
53	if rec.Code != http.StatusNotFound {
54		t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
55	}
56}
57
58func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) {
59	t.Parallel()
60
61	cfg := Config{}
62	cfg.Auth.Enabled = false
63
64	registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default())
65
66	req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
67	req.RemoteAddr = "192.0.2.20:12345"
68
69	rec := httptest.NewRecorder()
70	registryHandler.ServeHTTP(rec, req)
71
72	if rec.Code != http.StatusForbidden {
73		t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
74	}
75}
76
77// newTestCA creates a temporary CA adapter for integration tests.
78func newTestCA(t *testing.T) *pkiadapter.CA {
79	t.Helper()
80	ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
81	require.NoError(t, err)
82	return ca
83}
84
85// newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled.
86func newOnboardingConfig() Config {
87	cfg := Config{}
88	cfg.Server.Port = 8088
89	cfg.Server.TLSPort = 8443
90	cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample
91	return cfg
92}
93
94// directAddr is an IP outside the trusted proxy CIDR range.
95const directAddr = "203.0.113.10:12345"
96
97// trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
98const trustedProxyAddr = "173.245.48.5:12345"
99
100func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
101	t.Parallel()
102	ca := newTestCA(t)
103	cfg := newOnboardingConfig()
104	svc := &services{caAdapter: ca}
105
106	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
107
108	req := httptest.NewRequest(http.MethodGet, "/", nil)
109	req.RemoteAddr = directAddr
110	req.Host = "o2.bnema.dev"
111	rec := httptest.NewRecorder()
112	httpHandler.ServeHTTP(rec, req)
113
114	assert.Equal(t, http.StatusOK, rec.Code)
115	assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
116	assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
117}
118
119func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
120	t.Parallel()
121	ca := newTestCA(t)
122	cfg := newOnboardingConfig()
123	svc := &services{caAdapter: ca}
124
125	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
126
127	req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
128	req.RemoteAddr = directAddr
129	req.Host = "o2.bnema.dev"
130	rec := httptest.NewRecorder()
131	httpHandler.ServeHTTP(rec, req)
132
133	assert.Equal(t, http.StatusOK, rec.Code)
134	assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
135	assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
136}
137
138func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
139	t.Parallel()
140	ca := newTestCA(t)
141	cfg := newOnboardingConfig()
142	svc := &services{caAdapter: ca}
143
144	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
145
146	req := httptest.NewRequest(http.MethodGet, "/web", nil)
147	req.RemoteAddr = directAddr
148	req.Host = "o2.bnema.dev"
149	rec := httptest.NewRecorder()
150	httpHandler.ServeHTTP(rec, req)
151
152	assert.Equal(t, http.StatusForbidden, rec.Code)
153	assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
154}
155
156func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
157	t.Parallel()
158	ca := newTestCA(t)
159	cfg := newOnboardingConfig()
160	svc := &services{caAdapter: ca}
161
162	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
163
164	req := httptest.NewRequest(http.MethodGet, "/", nil)
165	req.RemoteAddr = trustedProxyAddr
166	req.Host = "app.example.com"
167	rec := httptest.NewRecorder()
168	httpHandler.ServeHTTP(rec, req)
169
170	// Trusted proxy traffic should NOT get the onboarding page.
171	// It should flow through to the normal proxy handler (which returns 502/404/etc,
172	// but definitely not onboarding HTML).
173	assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
174}
175
176func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
177	t.Parallel()
178	ca := newTestCA(t)
179	cfg := newOnboardingConfig()
180	svc := &services{caAdapter: ca}
181
182	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
183
184	req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
185	req.RemoteAddr = trustedProxyAddr
186	req.Host = "app.example.com"
187	rec := httptest.NewRecorder()
188	httpHandler.ServeHTTP(rec, req)
189
190	// Trusted proxy traffic must NOT leak the internal CA cert.
191	assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type"))
192	assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE")
193}
194
195func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) {
196	t.Parallel()
197	ca := newTestCA(t)
198	cfg := newOnboardingConfig()
199	cfg.Server.ForceHTTPSRedirect = true
200	svc := &services{caAdapter: ca}
201
202	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
203
204	req := httptest.NewRequest(http.MethodGet, "/", nil)
205	req.RemoteAddr = directAddr
206	req.Host = "o2.bnema.dev"
207	rec := httptest.NewRecorder()
208	httpHandler.ServeHTTP(rec, req)
209
210	// Direct onboarding must win over force_https_redirect.
211	assert.Equal(t, http.StatusOK, rec.Code)
212	assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
213}
214
215func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
216	t.Parallel()
217	ca := newTestCA(t)
218	cfg := newOnboardingConfig()
219	svc := &services{caAdapter: ca}
220
221	_, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
222
223	req := httptest.NewRequest(http.MethodGet, "/ca", nil)
224	req.RemoteAddr = directAddr
225	req.Host = "o2.bnema.dev"
226	rec := httptest.NewRecorder()
227	httpsHandler.ServeHTTP(rec, req)
228
229	assert.Equal(t, http.StatusOK, rec.Code)
230	assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
231}
232
233func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
234	t.Parallel()
235	ca := newTestCA(t)
236	cfg := newOnboardingConfig()
237	svc := &services{caAdapter: ca}
238
239	_, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
240
241	// Use httptest.NewServer so Go's net/http server strips the body for HEAD.
242	// httptest.NewRecorder does not apply server-level HEAD body stripping.
243	ts := httptest.NewServer(httpsHandler)
244	defer ts.Close()
245
246	for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} {
247		resp, err := ts.Client().Head(ts.URL + path)
248		require.NoError(t, err)
249
250		body, err := io.ReadAll(resp.Body)
251		require.NoError(t, err)
252		require.NoError(t, resp.Body.Close())
253
254		assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path)
255		assert.Empty(t, body, "HEAD %s should have empty body", path)
256	}
257}
258
259func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
260	t.Parallel()
261	ca := newTestCA(t)
262	cfg := newOnboardingConfig()
263	svc := &services{caAdapter: ca}
264
265	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
266
267	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268		r.RemoteAddr = directAddr
269		r.Host = "o2.bnema.dev"
270		httpHandler.ServeHTTP(w, r)
271	}))
272	defer ts.Close()
273
274	for _, tc := range []struct {
275		path        string
276		contentType string
277	}{
278		{path: "/ca", contentType: "text/html"},
279		{path: "/ca.crt", contentType: "application/x-x509-ca-cert"},
280	} {
281		resp, err := ts.Client().Head(ts.URL + tc.path)
282		require.NoError(t, err)
283
284		body, err := io.ReadAll(resp.Body)
285		require.NoError(t, err)
286		require.NoError(t, resp.Body.Close())
287
288		assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", tc.path)
289		assert.Contains(t, resp.Header.Get("Content-Type"), tc.contentType)
290		assert.Empty(t, body, "HEAD %s should have empty body", tc.path)
291	}
292}
293
294func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) {
295	t.Parallel()
296	ca := newTestCA(t)
297	cfg := newOnboardingConfig()
298	svc := &services{caAdapter: ca}
299
300	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
301
302	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303		r.RemoteAddr = directAddr
304		r.Host = "o2.bnema.dev"
305		httpHandler.ServeHTTP(w, r)
306	}))
307	defer ts.Close()
308
309	resp, err := ts.Client().Head(ts.URL + "/web")
310	require.NoError(t, err)
311
312	body, err := io.ReadAll(resp.Body)
313	require.NoError(t, err)
314	require.NoError(t, resp.Body.Close())
315
316	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
317	assert.Empty(t, body, "HEAD 403 should have empty body")
318}
319
320func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) {
321	t.Parallel()
322	ca := newTestCA(t)
323	cfg := newOnboardingConfig()
324	svc := &services{caAdapter: ca}
325
326	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
327
328	req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil)
329	req.RemoteAddr = directAddr
330	req.Host = "o2.bnema.dev"
331	rec := httptest.NewRecorder()
332	httpHandler.ServeHTTP(rec, req)
333
334	assert.Equal(t, http.StatusNotFound, rec.Code)
335}
336
337func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) {
338	t.Parallel()
339	ca := newTestCA(t)
340	cfg := newOnboardingConfig()
341	cfg.Server.TLSPort = 0 // TLS disabled
342	svc := &services{caAdapter: ca}
343
344	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
345
346	req := httptest.NewRequest(http.MethodGet, "/", nil)
347	req.RemoteAddr = directAddr
348	req.Host = "o2.bnema.dev"
349	rec := httptest.NewRecorder()
350	httpHandler.ServeHTTP(rec, req)
351
352	// With TLS disabled, onboarding should NOT be served.
353	assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
354}
355
356func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
357	t.Parallel()
358	// Verify that initPKI returns an error when TLS is enabled but CA init fails.
359	// We test this by providing a non-writable data dir.
360	si := &serviceInit{
361		ctx: context.Background(),
362		cfg: Config{},
363		log: zerowrap.Default(),
364		svc: &services{},
365	}
366	si.cfg.Server.TLSPort = 8443
367	dataDir := t.TempDir()
368	blockingPath := filepath.Join(dataDir, "not-a-directory")
369	require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))
370	// Point data dir to a file path so PKI directory creation fails portably.
371	si.cfg.Server.DataDir = blockingPath
372
373	err := si.initPKI()
374	assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
375}
376
377func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
378	t.Parallel()
379
380	cfg := Config{}
381	cfg.Server.RegistryAllowedIPs = []string{"not-a-cidr"}
382
383	mw := buildRegistryCIDRAllowlistMiddleware(cfg, nil, zerowrap.Default())
384	if mw == nil {
385		t.Fatal("expected non-nil allowlist middleware")
386	}
387
388	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
389		w.WriteHeader(http.StatusOK)
390	}))
391
392	req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
393	req.RemoteAddr = "127.0.0.1:12345"
394
395	rec := httptest.NewRecorder()
396	handler.ServeHTTP(rec, req)
397
398	if rec.Code != http.StatusForbidden {
399		t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
400	}
401}

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 7

Tags 106

↑↓ moveenter openesc close