v2.31.0-dev23 58a92f3

run_http_handlers_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"io"
7	"net/http"
8	"net/http/httptest"
9	"os"
10	"path/filepath"
11	"sync"
12	"testing"
13	"time"
14
15	"github.com/bnema/zerowrap"
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19
20	adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
21	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
22	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
23	out "github.com/bnema/gordon/internal/boundaries/out"
24	"github.com/bnema/gordon/internal/domain"
25	proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"
26	traffic "github.com/bnema/gordon/internal/usecase/traffic"
27)
28
29func newNotFoundProxyService(t *testing.T) *proxyusecase.Service {
30	t.Helper()
31	configSvc := inmocks.NewMockConfigService(t)
32	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
33	configSvc.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe()
34	containerSvc := inmocks.NewMockContainerService(t)
35	containerSvc.EXPECT().Get(mock.Anything, mock.Anything).Return(nil, false).Maybe()
36	return proxyusecase.NewService(nil, containerSvc, configSvc, proxyusecase.Config{})
37}
38
39// testAccessLogWriter is a thread-safe mock AccessLogWriter for tests.
40type testAccessLogWriter struct {
41	mu      sync.Mutex
42	entries []out.AccessLogEntry
43}
44
45func (w *testAccessLogWriter) Write(entry out.AccessLogEntry) error {
46	w.mu.Lock()
47	defer w.mu.Unlock()
48	w.entries = append(w.entries, entry)
49	return nil
50}
51
52func (w *testAccessLogWriter) snapshot() []out.AccessLogEntry {
53	w.mu.Lock()
54	defer w.mu.Unlock()
55	result := make([]out.AccessLogEntry, len(w.entries))
56	copy(result, w.entries)
57	return result
58}
59
60func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
61	t.Parallel()
62
63	cfg := Config{}
64	cfg.Auth.Enabled = false
65
66	store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default())
67	if err != nil {
68		t.Fatalf("expected no error when auth is disabled, got %v", err)
69	}
70	if store != nil {
71		t.Fatalf("expected nil token store when auth is disabled")
72	}
73	if authSvc != nil {
74		t.Fatalf("expected nil auth service when auth is disabled")
75	}
76}
77
78func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
79	t.Parallel()
80
81	cfg := Config{}
82	cfg.Auth.Enabled = false
83
84	svc := &services{adminHandler: &adminhttp.Handler{}}
85	registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
86
87	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
88	req.RemoteAddr = "192.0.2.10:12345"
89
90	rec := httptest.NewRecorder()
91	registryHandler.ServeHTTP(rec, req)
92
93	if rec.Code != http.StatusNotFound {
94		t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
95	}
96}
97
98func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) {
99	t.Parallel()
100
101	cfg := Config{}
102	cfg.Auth.Enabled = false
103
104	registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default(), nil)
105
106	req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
107	req.RemoteAddr = "192.0.2.20:12345"
108
109	rec := httptest.NewRecorder()
110	registryHandler.ServeHTTP(rec, req)
111
112	if rec.Code != http.StatusForbidden {
113		t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
114	}
115}
116
117// newTestCA creates a temporary CA adapter for integration tests.
118func newTestCA(t *testing.T) *pkiadapter.CA {
119	t.Helper()
120	ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
121	require.NoError(t, err)
122	return ca
123}
124
125// newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled.
126func newOnboardingConfig() Config {
127	cfg := Config{}
128	cfg.Server.Port = 8088
129	cfg.Server.TLSPort = 8443
130	cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample
131	cfg.EntryPoints = map[string]traffic.EntryPointConfig{
132		traffic.DefaultEdgeEntryPointName: {Address: ":8443", Protocol: domain.EntryPointProtocolSmartTCP},
133	}
134	return cfg
135}
136
137// directAddr is an IP outside the trusted proxy CIDR range.
138const directAddr = "203.0.113.10:12345"
139
140// trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
141const trustedProxyAddr = "173.245.48.5:12345"
142
143func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) {
144	t.Parallel()
145
146	tests := []struct {
147		name        string
148		path        string
149		wantCode    int
150		wantContent string // substring check
151		wantType    string // Content-Type substring
152	}{
153		{name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
154		{name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
155		{name: ".well-known/gordon/ca.crt serves CA cert", path: "/.well-known/gordon/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
156		{name: ".well-known/gordon/ca.mobileconfig serves mobileconfig", path: "/.well-known/gordon/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
157		{name: "root returns forbidden", path: "/", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
158		{name: "former /ca alias returns forbidden", path: "/ca", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
159		{name: "former /ca.crt alias returns forbidden", path: "/ca.crt", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
160		{name: "former /ca.mobileconfig alias returns forbidden", path: "/ca.mobileconfig", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
161		{name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
162	}
163
164	for _, tc := range tests {
165		t.Run(tc.name, func(t *testing.T) {
166			ca := newTestCA(t)
167			cfg := newOnboardingConfig()
168			svc := &services{caAdapter: ca}
169
170			_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
171
172			req := httptest.NewRequest(http.MethodGet, tc.path, nil)
173			req.RemoteAddr = directAddr
174			req.Host = "o2.bnema.dev"
175			rec := httptest.NewRecorder()
176			httpHandler.ServeHTTP(rec, req)
177
178			assert.Equal(t, tc.wantCode, rec.Code, "%s: status code", tc.name)
179			if tc.wantType != "" {
180				assert.Contains(t, rec.Header().Get("Content-Type"), tc.wantType, "%s: content type", tc.name)
181			}
182			if tc.wantContent != "" {
183				assert.Contains(t, rec.Body.String(), tc.wantContent, "%s: body content", tc.name)
184			}
185		})
186	}
187}
188
189func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
190	t.Parallel()
191	ca := newTestCA(t)
192	cfg := newOnboardingConfig()
193	svc := &services{caAdapter: ca}
194
195	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
196
197	req := httptest.NewRequest(http.MethodGet, "/", nil)
198	req.RemoteAddr = trustedProxyAddr
199	req.Host = "app.example.com"
200	rec := httptest.NewRecorder()
201	httpHandler.ServeHTTP(rec, req)
202
203	// Trusted proxy traffic should NOT get the onboarding page.
204	// It should flow through to the normal proxy handler (which returns 502/404/etc,
205	// but definitely not onboarding HTML).
206	assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
207}
208
209func TestCreateHTTPHandlers_TrustedProxyWellKnownCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
210	t.Parallel()
211	ca := newTestCA(t)
212	cfg := newOnboardingConfig()
213	svc := &services{caAdapter: ca}
214
215	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
216
217	req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil)
218	req.RemoteAddr = trustedProxyAddr
219	req.Host = "app.example.com"
220	rec := httptest.NewRecorder()
221	httpHandler.ServeHTTP(rec, req)
222
223	// Trusted proxy traffic must NOT leak the internal CA cert.
224	assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type"))
225	assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE")
226}
227
228func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) {
229	t.Parallel()
230	ca := newTestCA(t)
231	cfg := newOnboardingConfig()
232	cfg.Server.ForceHTTPSRedirect = true
233	svc := &services{caAdapter: ca}
234
235	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
236
237	req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
238	req.RemoteAddr = directAddr
239	req.Host = "o2.bnema.dev"
240	rec := httptest.NewRecorder()
241	httpHandler.ServeHTTP(rec, req)
242
243	// Direct onboarding must win over force_https_redirect.
244	assert.Equal(t, http.StatusOK, rec.Code)
245	assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
246}
247
248func TestCreateHTTPHandlers_RedirectUsesSelectedEntrypointPorts(t *testing.T) {
249	t.Parallel()
250	cfg := Config{}
251	cfg.Server.Port = 8088
252	cfg.Server.TLSPort = 8443
253	cfg.Server.ForceHTTPSRedirect = true
254	cfg.EntryPoints = map[string]traffic.EntryPointConfig{
255		traffic.DefaultEdgeEntryPointName: {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
256	}
257	configSvc := inmocks.NewMockConfigService(t)
258	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com"}, nil)
259	svc := &services{proxySvc: proxyusecase.NewService(nil, nil, configSvc, proxyusecase.Config{})}
260
261	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
262
263	req := httptest.NewRequest(http.MethodGet, "/path", nil)
264	req.RemoteAddr = directAddr
265	req.Host = "app.example.com:9443"
266	rec := httptest.NewRecorder()
267	httpHandler.ServeHTTP(rec, req)
268
269	assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
270	assert.Equal(t, "https://app.example.com:9443/path", rec.Header().Get("Location"))
271}
272
273func TestCreateHTTPHandlers_OnboardingUsesSelectedEntrypointPorts(t *testing.T) {
274	t.Parallel()
275	ca := newTestCA(t)
276	cfg := newOnboardingConfig()
277	cfg.EntryPoints = map[string]traffic.EntryPointConfig{
278		"public": {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
279	}
280	svc := &services{caAdapter: ca}
281
282	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
283
284	req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
285	req.RemoteAddr = directAddr
286	req.Host = "o2.bnema.dev:9443"
287	rec := httptest.NewRecorder()
288	httpHandler.ServeHTTP(rec, req)
289
290	assert.Equal(t, http.StatusOK, rec.Code)
291	assert.Contains(t, rec.Body.String(), "https://o2.bnema.dev:9443/")
292}
293
294func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
295	t.Parallel()
296	ca := newTestCA(t)
297	cfg := newOnboardingConfig()
298	cfg.Server.GordonDomain = "gordon.example.com"
299	svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
300
301	_, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
302
303	for _, host := range []string{"gordon.example.com", "Gordon.Example.Com.:8443"} {
304		req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
305		req.RemoteAddr = directAddr
306		req.Host = host
307		rec := httptest.NewRecorder()
308		httpsHandler.ServeHTTP(rec, req)
309
310		assert.Equal(t, http.StatusOK, rec.Code, "host %s", host)
311		assert.Contains(t, rec.Body.String(), "Trust CA Certificate", "host %s", host)
312	}
313}
314
315func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) {
316	t.Parallel()
317	ca := newTestCA(t)
318	cfg := newOnboardingConfig()
319	cfg.Server.GordonDomain = "gordon.example.com"
320	svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
321
322	_, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
323
324	req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
325	req.RemoteAddr = directAddr
326	req.Host = "app.example.com"
327	rec := httptest.NewRecorder()
328	httpsHandler.ServeHTTP(rec, req)
329
330	assert.Equal(t, http.StatusNotFound, rec.Code)
331	assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
332
333	normalizedReq := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
334	normalizedReq.RemoteAddr = directAddr
335	normalizedReq.Host = "Gordon.Example.Com.:8443"
336	normalizedRec := httptest.NewRecorder()
337	httpsHandler.ServeHTTP(normalizedRec, normalizedReq)
338
339	assert.Equal(t, http.StatusOK, normalizedRec.Code)
340	assert.Contains(t, normalizedRec.Body.String(), "Trust CA Certificate")
341}
342
343func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
344	t.Parallel()
345	ca := newTestCA(t)
346	cfg := newOnboardingConfig()
347	cfg.Server.GordonDomain = "gordon.example.com"
348	svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
349
350	_, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
351
352	// Use httptest.NewServer so Go's net/http server strips the body for HEAD.
353	// httptest.NewRecorder does not apply server-level HEAD body stripping.
354	ts := httptest.NewServer(httpsHandler)
355	defer ts.Close()
356
357	for _, path := range []string{"/.well-known/gordon/ca", "/.well-known/gordon/ca.crt", "/.well-known/gordon/ca.mobileconfig"} {
358		req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil)
359		require.NoError(t, err)
360		req.Host = "Gordon.Example.Com.:8443"
361		resp, err := ts.Client().Do(req)
362		require.NoError(t, err)
363
364		body, err := io.ReadAll(resp.Body)
365		require.NoError(t, err)
366		require.NoError(t, resp.Body.Close())
367
368		assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path)
369		assert.Empty(t, body, "HEAD %s should have empty body", path)
370	}
371}
372
373func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
374	t.Parallel()
375	ca := newTestCA(t)
376	cfg := newOnboardingConfig()
377	svc := &services{caAdapter: ca}
378
379	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
380
381	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
382		r.RemoteAddr = directAddr
383		r.Host = "o2.bnema.dev"
384		httpHandler.ServeHTTP(w, r)
385	}))
386	defer ts.Close()
387
388	for _, tc := range []struct {
389		path        string
390		contentType string
391	}{
392		{path: "/.well-known/gordon/ca", contentType: "text/html"},
393		{path: "/.well-known/gordon/ca.crt", contentType: "application/x-x509-ca-cert"},
394	} {
395		resp, err := ts.Client().Head(ts.URL + tc.path)
396		require.NoError(t, err)
397
398		body, err := io.ReadAll(resp.Body)
399		require.NoError(t, err)
400		require.NoError(t, resp.Body.Close())
401
402		assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", tc.path)
403		assert.Contains(t, resp.Header.Get("Content-Type"), tc.contentType)
404		assert.Empty(t, body, "HEAD %s should have empty body", tc.path)
405	}
406}
407
408func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) {
409	t.Parallel()
410	ca := newTestCA(t)
411	cfg := newOnboardingConfig()
412	svc := &services{caAdapter: ca}
413
414	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
415
416	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
417		r.RemoteAddr = directAddr
418		r.Host = "o2.bnema.dev"
419		httpHandler.ServeHTTP(w, r)
420	}))
421	defer ts.Close()
422
423	resp, err := ts.Client().Head(ts.URL + "/web")
424	require.NoError(t, err)
425
426	body, err := io.ReadAll(resp.Body)
427	require.NoError(t, err)
428	require.NoError(t, resp.Body.Close())
429
430	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
431	assert.Empty(t, body, "HEAD 403 should have empty body")
432}
433
434func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) {
435	t.Parallel()
436	ca := newTestCA(t)
437	cfg := newOnboardingConfig()
438	svc := &services{caAdapter: ca}
439
440	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
441
442	req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil)
443	req.RemoteAddr = directAddr
444	req.Host = "o2.bnema.dev"
445	rec := httptest.NewRecorder()
446	httpHandler.ServeHTTP(rec, req)
447
448	assert.Equal(t, http.StatusNotFound, rec.Code)
449}
450
451func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) {
452	t.Parallel()
453	ca := newTestCA(t)
454	cfg := newOnboardingConfig()
455	cfg.EntryPoints = nil // TLS disabled: no TLS-capable traffic entrypoint
456	svc := &services{caAdapter: ca}
457
458	_, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
459
460	req := httptest.NewRequest(http.MethodGet, "/", nil)
461	req.RemoteAddr = directAddr
462	req.Host = "o2.bnema.dev"
463	rec := httptest.NewRecorder()
464	httpHandler.ServeHTTP(rec, req)
465
466	// With TLS disabled, onboarding should NOT be served.
467	assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
468}
469
470type recordingPublicTLSRuntime struct {
471	reconcileCalls int
472	loopCalls      int
473	reconcileErr   error
474}
475
476func (r *recordingPublicTLSRuntime) Reconcile(context.Context) error {
477	r.reconcileCalls++
478	return r.reconcileErr
479}
480
481func (r *recordingPublicTLSRuntime) StartRenewalLoop(context.Context, time.Duration) <-chan struct{} {
482	r.loopCalls++
483	done := make(chan struct{})
484	close(done)
485	return done
486}
487
488func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) {
489	t.Parallel()
490
491	runtime := &recordingPublicTLSRuntime{}
492	err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default())
493
494	require.NoError(t, err)
495	assert.Equal(t, 1, runtime.reconcileCalls)
496	assert.Equal(t, 1, runtime.loopCalls)
497}
498
499func TestStartPublicTLSRuntime_NilSvc(t *testing.T) {
500	t.Parallel()
501
502	err := startPublicTLSRuntime(context.Background(), nil, zerowrap.Default())
503	require.NoError(t, err)
504}
505
506func TestStartPublicTLSRuntime_StartsRenewalLoopWhenReconcileFails(t *testing.T) {
507	t.Parallel()
508
509	runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")}
510	err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default())
511
512	require.Error(t, err)
513	assert.Equal(t, 1, runtime.reconcileCalls)
514	assert.Equal(t, 1, runtime.loopCalls)
515}
516
517func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
518	t.Parallel()
519	// Verify that initPKI returns an error when TLS is enabled but CA init fails.
520	// We test this by providing a non-writable data dir.
521	si := &serviceInit{
522		ctx: context.Background(),
523		cfg: Config{},
524		log: zerowrap.Default(),
525		svc: &services{},
526	}
527	si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
528		traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
529	}
530	dataDir := t.TempDir()
531	blockingPath := filepath.Join(dataDir, "not-a-directory")
532	require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))
533	// Point data dir to a file path so PKI directory creation fails portably.
534	si.cfg.Server.DataDir = blockingPath
535
536	err := si.initPKI()
537	assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
538}
539
540func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
541	t.Parallel()
542
543	cfg := Config{}
544	cfg.Server.RegistryAllowedIPs = []string{"not-a-cidr"}
545
546	mw := buildRegistryCIDRAllowlistMiddleware(cfg, nil, zerowrap.Default())
547	if mw == nil {
548		t.Fatal("expected non-nil allowlist middleware")
549	}
550
551	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
552		w.WriteHeader(http.StatusOK)
553	}))
554
555	req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
556	req.RemoteAddr = "127.0.0.1:12345"
557
558	rec := httptest.NewRecorder()
559	handler.ServeHTTP(rec, req)
560
561	if rec.Code != http.StatusForbidden {
562		t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
563	}
564}
565
566// TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged verifies that requests
567// to /admin/ blocked by loopbackOnly() still produce an access-log entry.
568// This exercises the outer-deny path: AccessLogger wraps the top-level
569// handler so even gates that run before any inner middleware are logged.
570func TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged(t *testing.T) {
571	t.Parallel()
572
573	cfg := Config{}
574	cfg.Auth.Enabled = true // required for admin routes to be registered
575
576	svc := &services{adminHandler: &adminhttp.Handler{}}
577	aw := &testAccessLogWriter{}
578	registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw)
579
580	// Non-loopback IP — loopbackOnly() will reject this with 403 before any
581	// inner middleware runs.
582	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
583	req.RemoteAddr = "192.0.2.10:12345"
584
585	rec := httptest.NewRecorder()
586	registryHandler.ServeHTTP(rec, req)
587
588	require.Equal(t, http.StatusForbidden, rec.Code)
589	entries := aw.snapshot()
590	require.Len(t, entries, 1, "access log must capture the loopback-rejected admin request")
591	assert.Equal(t, http.StatusForbidden, entries[0].Status)
592	assert.Equal(t, "/admin/status", entries[0].Path)
593}
594
595// TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged verifies that requests
596// to /v2/ blocked by loopbackOnly() in local mode still produce an access-log entry.
597func TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged(t *testing.T) {
598	t.Parallel()
599
600	cfg := Config{}
601	cfg.Auth.Enabled = false // local mode: /v2/ is wrapped by loopbackOnly()
602
603	svc := &services{}
604	aw := &testAccessLogWriter{}
605	registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw)
606
607	// Non-loopback IP — loopbackOnly() rejects with 403.
608	req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
609	req.RemoteAddr = "192.0.2.20:12345"
610
611	rec := httptest.NewRecorder()
612	registryHandler.ServeHTTP(rec, req)
613
614	require.Equal(t, http.StatusForbidden, rec.Code)
615	entries := aw.snapshot()
616	require.Len(t, entries, 1, "access log must capture the loopback-rejected registry request")
617	assert.Equal(t, http.StatusForbidden, entries[0].Status)
618}
619
620// TestAccessLog_DenyAllProxy_IsLogged verifies that requests rejected by the
621// fail-closed denyAllHandler (triggered by an all-invalid proxy_allowed_ips
622// config) still produce an access-log entry.
623func TestAccessLog_DenyAllProxy_IsLogged(t *testing.T) {
624	t.Parallel()
625
626	cfg := Config{}
627	// All-invalid entries cause buildProxyCIDRAllowlistMiddleware to return
628	// denyAllHandler, which wraps the proxy mux unconditionally with 403.
629	cfg.Server.ProxyAllowedIPs = []string{"not-a-valid-ip"}
630
631	svc := &services{}
632	aw := &testAccessLogWriter{}
633	_, httpProxyHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw)
634
635	req := httptest.NewRequest(http.MethodGet, "/", nil)
636	req.RemoteAddr = "203.0.113.5:54321"
637
638	rec := httptest.NewRecorder()
639	httpProxyHandler.ServeHTTP(rec, req)
640
641	require.Equal(t, http.StatusForbidden, rec.Code)
642	entries := aw.snapshot()
643	require.Len(t, entries, 1, "access log must capture the fail-closed proxy denial")
644	assert.Equal(t, http.StatusForbidden, entries[0].Status)
645}

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