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