v2.31.0-dev21 b59533b

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

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