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}