v3.0.0-alpha.1 6fdddb8

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

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 8

Tags 106

↑↓ moveenter openesc close