v2.31.0-dev5 4162c86

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	"sync"
11	"testing"
12
13	"github.com/bnema/zerowrap"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
18	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
19	out "github.com/bnema/gordon/internal/boundaries/out"
20)
21
22// testAccessLogWriter is a thread-safe mock AccessLogWriter for tests.
23type testAccessLogWriter struct {
24	mu      sync.Mutex
25	entries []out.AccessLogEntry
26}
27
28func (w *testAccessLogWriter) Write(entry out.AccessLogEntry) error {
29	w.mu.Lock()
30	defer w.mu.Unlock()
31	w.entries = append(w.entries, entry)
32	return nil
33}
34
35func (w *testAccessLogWriter) snapshot() []out.AccessLogEntry {
36	w.mu.Lock()
37	defer w.mu.Unlock()
38	result := make([]out.AccessLogEntry, len(w.entries))
39	copy(result, w.entries)
40	return result
41}
42
43func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
44	t.Parallel()
45
46	cfg := Config{}
47	cfg.Auth.Enabled = false
48
49	store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default())
50	if err != nil {
51		t.Fatalf("expected no error when auth is disabled, got %v", err)
52	}
53	if store != nil {
54		t.Fatalf("expected nil token store when auth is disabled")
55	}
56	if authSvc != nil {
57		t.Fatalf("expected nil auth service when auth is disabled")
58	}
59}
60
61func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
62	t.Parallel()
63
64	cfg := Config{}
65	cfg.Auth.Enabled = false
66
67	svc := &services{adminHandler: &adminhttp.Handler{}}
68	registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
69
70	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
71	req.RemoteAddr = "192.0.2.10:12345"
72
73	rec := httptest.NewRecorder()
74	registryHandler.ServeHTTP(rec, req)
75
76	if rec.Code != http.StatusNotFound {
77		t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
78	}
79}
80
81func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) {
82	t.Parallel()
83
84	cfg := Config{}
85	cfg.Auth.Enabled = false
86
87	registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default(), nil)
88
89	req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
90	req.RemoteAddr = "192.0.2.20:12345"
91
92	rec := httptest.NewRecorder()
93	registryHandler.ServeHTTP(rec, req)
94
95	if rec.Code != http.StatusForbidden {
96		t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
97	}
98}
99
100// newTestCA creates a temporary CA adapter for integration tests.
101func newTestCA(t *testing.T) *pkiadapter.CA {
102	t.Helper()
103	ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
104	require.NoError(t, err)
105	return ca
106}
107
108// newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled.
109func newOnboardingConfig() Config {
110	cfg := Config{}
111	cfg.Server.Port = 8088
112	cfg.Server.TLSPort = 8443
113	cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample
114	return cfg
115}
116
117// directAddr is an IP outside the trusted proxy CIDR range.
118const directAddr = "203.0.113.10:12345"
119
120// trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
121const trustedProxyAddr = "173.245.48.5:12345"
122
123func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
124	t.Parallel()
125	ca := newTestCA(t)
126	cfg := newOnboardingConfig()
127	svc := &services{caAdapter: ca}
128
129	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
130
131	req := httptest.NewRequest(http.MethodGet, "/", nil)
132	req.RemoteAddr = directAddr
133	req.Host = "o2.bnema.dev"
134	rec := httptest.NewRecorder()
135	httpHandler.ServeHTTP(rec, req)
136
137	assert.Equal(t, http.StatusOK, rec.Code)
138	assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
139	assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
140}
141
142func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
143	t.Parallel()
144	ca := newTestCA(t)
145	cfg := newOnboardingConfig()
146	svc := &services{caAdapter: ca}
147
148	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
149
150	req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
151	req.RemoteAddr = directAddr
152	req.Host = "o2.bnema.dev"
153	rec := httptest.NewRecorder()
154	httpHandler.ServeHTTP(rec, req)
155
156	assert.Equal(t, http.StatusOK, rec.Code)
157	assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
158	assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
159}
160
161func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
162	t.Parallel()
163	ca := newTestCA(t)
164	cfg := newOnboardingConfig()
165	svc := &services{caAdapter: ca}
166
167	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
168
169	req := httptest.NewRequest(http.MethodGet, "/web", nil)
170	req.RemoteAddr = directAddr
171	req.Host = "o2.bnema.dev"
172	rec := httptest.NewRecorder()
173	httpHandler.ServeHTTP(rec, req)
174
175	assert.Equal(t, http.StatusForbidden, rec.Code)
176	assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
177}
178
179func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
180	t.Parallel()
181	ca := newTestCA(t)
182	cfg := newOnboardingConfig()
183	svc := &services{caAdapter: ca}
184
185	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
186
187	req := httptest.NewRequest(http.MethodGet, "/", nil)
188	req.RemoteAddr = trustedProxyAddr
189	req.Host = "app.example.com"
190	rec := httptest.NewRecorder()
191	httpHandler.ServeHTTP(rec, req)
192
193	// Trusted proxy traffic should NOT get the onboarding page.
194	// It should flow through to the normal proxy handler (which returns 502/404/etc,
195	// but definitely not onboarding HTML).
196	assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
197}
198
199func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
200	t.Parallel()
201	ca := newTestCA(t)
202	cfg := newOnboardingConfig()
203	svc := &services{caAdapter: ca}
204
205	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
206
207	req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
208	req.RemoteAddr = trustedProxyAddr
209	req.Host = "app.example.com"
210	rec := httptest.NewRecorder()
211	httpHandler.ServeHTTP(rec, req)
212
213	// Trusted proxy traffic must NOT leak the internal CA cert.
214	assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type"))
215	assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE")
216}
217
218func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) {
219	t.Parallel()
220	ca := newTestCA(t)
221	cfg := newOnboardingConfig()
222	cfg.Server.ForceHTTPSRedirect = true
223	svc := &services{caAdapter: ca}
224
225	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
226
227	req := httptest.NewRequest(http.MethodGet, "/", nil)
228	req.RemoteAddr = directAddr
229	req.Host = "o2.bnema.dev"
230	rec := httptest.NewRecorder()
231	httpHandler.ServeHTTP(rec, req)
232
233	// Direct onboarding must win over force_https_redirect.
234	assert.Equal(t, http.StatusOK, rec.Code)
235	assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
236}
237
238func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
239	t.Parallel()
240	ca := newTestCA(t)
241	cfg := newOnboardingConfig()
242	svc := &services{caAdapter: ca}
243
244	_, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
245
246	req := httptest.NewRequest(http.MethodGet, "/ca", nil)
247	req.RemoteAddr = directAddr
248	req.Host = "o2.bnema.dev"
249	rec := httptest.NewRecorder()
250	httpsHandler.ServeHTTP(rec, req)
251
252	assert.Equal(t, http.StatusOK, rec.Code)
253	assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
254}
255
256func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
257	t.Parallel()
258	ca := newTestCA(t)
259	cfg := newOnboardingConfig()
260	svc := &services{caAdapter: ca}
261
262	_, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
263
264	// Use httptest.NewServer so Go's net/http server strips the body for HEAD.
265	// httptest.NewRecorder does not apply server-level HEAD body stripping.
266	ts := httptest.NewServer(httpsHandler)
267	defer ts.Close()
268
269	for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} {
270		resp, err := ts.Client().Head(ts.URL + path)
271		require.NoError(t, err)
272
273		body, err := io.ReadAll(resp.Body)
274		require.NoError(t, err)
275		require.NoError(t, resp.Body.Close())
276
277		assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path)
278		assert.Empty(t, body, "HEAD %s should have empty body", path)
279	}
280}
281
282func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
283	t.Parallel()
284	ca := newTestCA(t)
285	cfg := newOnboardingConfig()
286	svc := &services{caAdapter: ca}
287
288	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
289
290	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
291		r.RemoteAddr = directAddr
292		r.Host = "o2.bnema.dev"
293		httpHandler.ServeHTTP(w, r)
294	}))
295	defer ts.Close()
296
297	for _, tc := range []struct {
298		path        string
299		contentType string
300	}{
301		{path: "/ca", contentType: "text/html"},
302		{path: "/ca.crt", contentType: "application/x-x509-ca-cert"},
303	} {
304		resp, err := ts.Client().Head(ts.URL + tc.path)
305		require.NoError(t, err)
306
307		body, err := io.ReadAll(resp.Body)
308		require.NoError(t, err)
309		require.NoError(t, resp.Body.Close())
310
311		assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", tc.path)
312		assert.Contains(t, resp.Header.Get("Content-Type"), tc.contentType)
313		assert.Empty(t, body, "HEAD %s should have empty body", tc.path)
314	}
315}
316
317func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) {
318	t.Parallel()
319	ca := newTestCA(t)
320	cfg := newOnboardingConfig()
321	svc := &services{caAdapter: ca}
322
323	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
324
325	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
326		r.RemoteAddr = directAddr
327		r.Host = "o2.bnema.dev"
328		httpHandler.ServeHTTP(w, r)
329	}))
330	defer ts.Close()
331
332	resp, err := ts.Client().Head(ts.URL + "/web")
333	require.NoError(t, err)
334
335	body, err := io.ReadAll(resp.Body)
336	require.NoError(t, err)
337	require.NoError(t, resp.Body.Close())
338
339	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
340	assert.Empty(t, body, "HEAD 403 should have empty body")
341}
342
343func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) {
344	t.Parallel()
345	ca := newTestCA(t)
346	cfg := newOnboardingConfig()
347	svc := &services{caAdapter: ca}
348
349	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
350
351	req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil)
352	req.RemoteAddr = directAddr
353	req.Host = "o2.bnema.dev"
354	rec := httptest.NewRecorder()
355	httpHandler.ServeHTTP(rec, req)
356
357	assert.Equal(t, http.StatusNotFound, rec.Code)
358}
359
360func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) {
361	t.Parallel()
362	ca := newTestCA(t)
363	cfg := newOnboardingConfig()
364	cfg.Server.TLSPort = 0 // TLS disabled
365	svc := &services{caAdapter: ca}
366
367	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
368
369	req := httptest.NewRequest(http.MethodGet, "/", nil)
370	req.RemoteAddr = directAddr
371	req.Host = "o2.bnema.dev"
372	rec := httptest.NewRecorder()
373	httpHandler.ServeHTTP(rec, req)
374
375	// With TLS disabled, onboarding should NOT be served.
376	assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
377}
378
379func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
380	t.Parallel()
381	// Verify that initPKI returns an error when TLS is enabled but CA init fails.
382	// We test this by providing a non-writable data dir.
383	si := &serviceInit{
384		ctx: context.Background(),
385		cfg: Config{},
386		log: zerowrap.Default(),
387		svc: &services{},
388	}
389	si.cfg.Server.TLSPort = 8443
390	dataDir := t.TempDir()
391	blockingPath := filepath.Join(dataDir, "not-a-directory")
392	require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))
393	// Point data dir to a file path so PKI directory creation fails portably.
394	si.cfg.Server.DataDir = blockingPath
395
396	err := si.initPKI()
397	assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
398}
399
400func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
401	t.Parallel()
402
403	cfg := Config{}
404	cfg.Server.RegistryAllowedIPs = []string{"not-a-cidr"}
405
406	mw := buildRegistryCIDRAllowlistMiddleware(cfg, nil, zerowrap.Default())
407	if mw == nil {
408		t.Fatal("expected non-nil allowlist middleware")
409	}
410
411	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
412		w.WriteHeader(http.StatusOK)
413	}))
414
415	req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
416	req.RemoteAddr = "127.0.0.1:12345"
417
418	rec := httptest.NewRecorder()
419	handler.ServeHTTP(rec, req)
420
421	if rec.Code != http.StatusForbidden {
422		t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
423	}
424}
425
426// TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged verifies that requests
427// to /admin/ blocked by loopbackOnly() still produce an access-log entry.
428// This exercises the outer-deny path: AccessLogger wraps the top-level
429// handler so even gates that run before any inner middleware are logged.
430func TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged(t *testing.T) {
431	t.Parallel()
432
433	cfg := Config{}
434	cfg.Auth.Enabled = true // required for admin routes to be registered
435
436	svc := &services{adminHandler: &adminhttp.Handler{}}
437	aw := &testAccessLogWriter{}
438	registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw)
439
440	// Non-loopback IP — loopbackOnly() will reject this with 403 before any
441	// inner middleware runs.
442	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
443	req.RemoteAddr = "192.0.2.10:12345"
444
445	rec := httptest.NewRecorder()
446	registryHandler.ServeHTTP(rec, req)
447
448	require.Equal(t, http.StatusForbidden, rec.Code)
449	entries := aw.snapshot()
450	require.Len(t, entries, 1, "access log must capture the loopback-rejected admin request")
451	assert.Equal(t, http.StatusForbidden, entries[0].Status)
452	assert.Equal(t, "/admin/status", entries[0].Path)
453}
454
455// TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged verifies that requests
456// to /v2/ blocked by loopbackOnly() in local mode still produce an access-log entry.
457func TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged(t *testing.T) {
458	t.Parallel()
459
460	cfg := Config{}
461	cfg.Auth.Enabled = false // local mode: /v2/ is wrapped by loopbackOnly()
462
463	svc := &services{}
464	aw := &testAccessLogWriter{}
465	registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw)
466
467	// Non-loopback IP — loopbackOnly() rejects with 403.
468	req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
469	req.RemoteAddr = "192.0.2.20:12345"
470
471	rec := httptest.NewRecorder()
472	registryHandler.ServeHTTP(rec, req)
473
474	require.Equal(t, http.StatusForbidden, rec.Code)
475	entries := aw.snapshot()
476	require.Len(t, entries, 1, "access log must capture the loopback-rejected registry request")
477	assert.Equal(t, http.StatusForbidden, entries[0].Status)
478}
479
480// TestAccessLog_DenyAllProxy_IsLogged verifies that requests rejected by the
481// fail-closed denyAllHandler (triggered by an all-invalid proxy_allowed_ips
482// config) still produce an access-log entry.
483func TestAccessLog_DenyAllProxy_IsLogged(t *testing.T) {
484	t.Parallel()
485
486	cfg := Config{}
487	// All-invalid entries cause buildProxyCIDRAllowlistMiddleware to return
488	// denyAllHandler, which wraps the proxy mux unconditionally with 403.
489	cfg.Server.ProxyAllowedIPs = []string{"not-a-valid-ip"}
490
491	svc := &services{}
492	aw := &testAccessLogWriter{}
493	_, httpProxyHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw)
494
495	req := httptest.NewRequest(http.MethodGet, "/", nil)
496	req.RemoteAddr = "203.0.113.5:54321"
497
498	rec := httptest.NewRecorder()
499	httpProxyHandler.ServeHTTP(rec, req)
500
501	require.Equal(t, http.StatusForbidden, rec.Code)
502	entries := aw.snapshot()
503	require.Len(t, entries, 1, "access log must capture the fail-closed proxy denial")
504	assert.Equal(t, http.StatusForbidden, entries[0].Status)
505}

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