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