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}