1package app
2
3import (
4 "context"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "testing"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
17 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
18)
19
20func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
21 t.Parallel()
22
23 cfg := Config{}
24 cfg.Auth.Enabled = false
25
26 store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default())
27 if err != nil {
28 t.Fatalf("expected no error when auth is disabled, got %v", err)
29 }
30 if store != nil {
31 t.Fatalf("expected nil token store when auth is disabled")
32 }
33 if authSvc != nil {
34 t.Fatalf("expected nil auth service when auth is disabled")
35 }
36}
37
38func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
39 t.Parallel()
40
41 cfg := Config{}
42 cfg.Auth.Enabled = false
43
44 svc := &services{adminHandler: &adminhttp.Handler{}}
45 registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
46
47 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
48 req.RemoteAddr = "192.0.2.10:12345"
49
50 rec := httptest.NewRecorder()
51 registryHandler.ServeHTTP(rec, req)
52
53 if rec.Code != http.StatusNotFound {
54 t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
55 }
56}
57
58func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) {
59 t.Parallel()
60
61 cfg := Config{}
62 cfg.Auth.Enabled = false
63
64 registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default())
65
66 req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
67 req.RemoteAddr = "192.0.2.20:12345"
68
69 rec := httptest.NewRecorder()
70 registryHandler.ServeHTTP(rec, req)
71
72 if rec.Code != http.StatusForbidden {
73 t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
74 }
75}
76
77// newTestCA creates a temporary CA adapter for integration tests.
78func newTestCA(t *testing.T) *pkiadapter.CA {
79 t.Helper()
80 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
81 require.NoError(t, err)
82 return ca
83}
84
85// newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled.
86func newOnboardingConfig() Config {
87 cfg := Config{}
88 cfg.Server.Port = 8088
89 cfg.Server.TLSPort = 8443
90 cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample
91 return cfg
92}
93
94// directAddr is an IP outside the trusted proxy CIDR range.
95const directAddr = "203.0.113.10:12345"
96
97// trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
98const trustedProxyAddr = "173.245.48.5:12345"
99
100func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
101 t.Parallel()
102 ca := newTestCA(t)
103 cfg := newOnboardingConfig()
104 svc := &services{caAdapter: ca}
105
106 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
107
108 req := httptest.NewRequest(http.MethodGet, "/", nil)
109 req.RemoteAddr = directAddr
110 req.Host = "o2.bnema.dev"
111 rec := httptest.NewRecorder()
112 httpHandler.ServeHTTP(rec, req)
113
114 assert.Equal(t, http.StatusOK, rec.Code)
115 assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
116 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
117}
118
119func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
120 t.Parallel()
121 ca := newTestCA(t)
122 cfg := newOnboardingConfig()
123 svc := &services{caAdapter: ca}
124
125 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
126
127 req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
128 req.RemoteAddr = directAddr
129 req.Host = "o2.bnema.dev"
130 rec := httptest.NewRecorder()
131 httpHandler.ServeHTTP(rec, req)
132
133 assert.Equal(t, http.StatusOK, rec.Code)
134 assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
135 assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
136}
137
138func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
139 t.Parallel()
140 ca := newTestCA(t)
141 cfg := newOnboardingConfig()
142 svc := &services{caAdapter: ca}
143
144 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
145
146 req := httptest.NewRequest(http.MethodGet, "/web", nil)
147 req.RemoteAddr = directAddr
148 req.Host = "o2.bnema.dev"
149 rec := httptest.NewRecorder()
150 httpHandler.ServeHTTP(rec, req)
151
152 assert.Equal(t, http.StatusForbidden, rec.Code)
153 assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
154}
155
156func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
157 t.Parallel()
158 ca := newTestCA(t)
159 cfg := newOnboardingConfig()
160 svc := &services{caAdapter: ca}
161
162 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
163
164 req := httptest.NewRequest(http.MethodGet, "/", nil)
165 req.RemoteAddr = trustedProxyAddr
166 req.Host = "app.example.com"
167 rec := httptest.NewRecorder()
168 httpHandler.ServeHTTP(rec, req)
169
170 // Trusted proxy traffic should NOT get the onboarding page.
171 // It should flow through to the normal proxy handler (which returns 502/404/etc,
172 // but definitely not onboarding HTML).
173 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
174}
175
176func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
177 t.Parallel()
178 ca := newTestCA(t)
179 cfg := newOnboardingConfig()
180 svc := &services{caAdapter: ca}
181
182 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
183
184 req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
185 req.RemoteAddr = trustedProxyAddr
186 req.Host = "app.example.com"
187 rec := httptest.NewRecorder()
188 httpHandler.ServeHTTP(rec, req)
189
190 // Trusted proxy traffic must NOT leak the internal CA cert.
191 assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type"))
192 assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE")
193}
194
195func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) {
196 t.Parallel()
197 ca := newTestCA(t)
198 cfg := newOnboardingConfig()
199 cfg.Server.ForceHTTPSRedirect = true
200 svc := &services{caAdapter: ca}
201
202 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
203
204 req := httptest.NewRequest(http.MethodGet, "/", nil)
205 req.RemoteAddr = directAddr
206 req.Host = "o2.bnema.dev"
207 rec := httptest.NewRecorder()
208 httpHandler.ServeHTTP(rec, req)
209
210 // Direct onboarding must win over force_https_redirect.
211 assert.Equal(t, http.StatusOK, rec.Code)
212 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
213}
214
215func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
216 t.Parallel()
217 ca := newTestCA(t)
218 cfg := newOnboardingConfig()
219 svc := &services{caAdapter: ca}
220
221 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
222
223 req := httptest.NewRequest(http.MethodGet, "/ca", nil)
224 req.RemoteAddr = directAddr
225 req.Host = "o2.bnema.dev"
226 rec := httptest.NewRecorder()
227 httpsHandler.ServeHTTP(rec, req)
228
229 assert.Equal(t, http.StatusOK, rec.Code)
230 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
231}
232
233func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
234 t.Parallel()
235 ca := newTestCA(t)
236 cfg := newOnboardingConfig()
237 svc := &services{caAdapter: ca}
238
239 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
240
241 // Use httptest.NewServer so Go's net/http server strips the body for HEAD.
242 // httptest.NewRecorder does not apply server-level HEAD body stripping.
243 ts := httptest.NewServer(httpsHandler)
244 defer ts.Close()
245
246 for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} {
247 resp, err := ts.Client().Head(ts.URL + path)
248 require.NoError(t, err)
249
250 body, err := io.ReadAll(resp.Body)
251 require.NoError(t, err)
252 require.NoError(t, resp.Body.Close())
253
254 assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path)
255 assert.Empty(t, body, "HEAD %s should have empty body", path)
256 }
257}
258
259func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
260 t.Parallel()
261 ca := newTestCA(t)
262 cfg := newOnboardingConfig()
263 svc := &services{caAdapter: ca}
264
265 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
266
267 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268 r.RemoteAddr = directAddr
269 r.Host = "o2.bnema.dev"
270 httpHandler.ServeHTTP(w, r)
271 }))
272 defer ts.Close()
273
274 for _, tc := range []struct {
275 path string
276 contentType string
277 }{
278 {path: "/ca", contentType: "text/html"},
279 {path: "/ca.crt", contentType: "application/x-x509-ca-cert"},
280 } {
281 resp, err := ts.Client().Head(ts.URL + tc.path)
282 require.NoError(t, err)
283
284 body, err := io.ReadAll(resp.Body)
285 require.NoError(t, err)
286 require.NoError(t, resp.Body.Close())
287
288 assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", tc.path)
289 assert.Contains(t, resp.Header.Get("Content-Type"), tc.contentType)
290 assert.Empty(t, body, "HEAD %s should have empty body", tc.path)
291 }
292}
293
294func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) {
295 t.Parallel()
296 ca := newTestCA(t)
297 cfg := newOnboardingConfig()
298 svc := &services{caAdapter: ca}
299
300 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
301
302 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303 r.RemoteAddr = directAddr
304 r.Host = "o2.bnema.dev"
305 httpHandler.ServeHTTP(w, r)
306 }))
307 defer ts.Close()
308
309 resp, err := ts.Client().Head(ts.URL + "/web")
310 require.NoError(t, err)
311
312 body, err := io.ReadAll(resp.Body)
313 require.NoError(t, err)
314 require.NoError(t, resp.Body.Close())
315
316 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
317 assert.Empty(t, body, "HEAD 403 should have empty body")
318}
319
320func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) {
321 t.Parallel()
322 ca := newTestCA(t)
323 cfg := newOnboardingConfig()
324 svc := &services{caAdapter: ca}
325
326 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
327
328 req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil)
329 req.RemoteAddr = directAddr
330 req.Host = "o2.bnema.dev"
331 rec := httptest.NewRecorder()
332 httpHandler.ServeHTTP(rec, req)
333
334 assert.Equal(t, http.StatusNotFound, rec.Code)
335}
336
337func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) {
338 t.Parallel()
339 ca := newTestCA(t)
340 cfg := newOnboardingConfig()
341 cfg.Server.TLSPort = 0 // TLS disabled
342 svc := &services{caAdapter: ca}
343
344 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
345
346 req := httptest.NewRequest(http.MethodGet, "/", nil)
347 req.RemoteAddr = directAddr
348 req.Host = "o2.bnema.dev"
349 rec := httptest.NewRecorder()
350 httpHandler.ServeHTTP(rec, req)
351
352 // With TLS disabled, onboarding should NOT be served.
353 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
354}
355
356func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
357 t.Parallel()
358 // Verify that initPKI returns an error when TLS is enabled but CA init fails.
359 // We test this by providing a non-writable data dir.
360 si := &serviceInit{
361 ctx: context.Background(),
362 cfg: Config{},
363 log: zerowrap.Default(),
364 svc: &services{},
365 }
366 si.cfg.Server.TLSPort = 8443
367 dataDir := t.TempDir()
368 blockingPath := filepath.Join(dataDir, "not-a-directory")
369 require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))
370 // Point data dir to a file path so PKI directory creation fails portably.
371 si.cfg.Server.DataDir = blockingPath
372
373 err := si.initPKI()
374 assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
375}
376
377func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
378 t.Parallel()
379
380 cfg := Config{}
381 cfg.Server.RegistryAllowedIPs = []string{"not-a-cidr"}
382
383 mw := buildRegistryCIDRAllowlistMiddleware(cfg, nil, zerowrap.Default())
384 if mw == nil {
385 t.Fatal("expected non-nil allowlist middleware")
386 }
387
388 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
389 w.WriteHeader(http.StatusOK)
390 }))
391
392 req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
393 req.RemoteAddr = "127.0.0.1:12345"
394
395 rec := httptest.NewRecorder()
396 handler.ServeHTTP(rec, req)
397
398 if rec.Code != http.StatusForbidden {
399 t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
400 }
401}