1package proxy
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10
11 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
12 "github.com/bnema/gordon/internal/domain"
13)
14
15func testContext() context.Context {
16 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
17}
18
19// stubProvider is a fixed TargetProvider for proxy tests.
20type stubProvider struct {
21 backends map[string]domain.AppBackend
22}
23
24func (s *stubProvider) LookupHost(host string) (domain.AppBackend, bool) {
25 backend, ok := s.backends[host]
26 return backend, ok
27}
28
29func testService(t *testing.T, configSvc *inmocks.MockConfigService, provider TargetProvider) *Service {
30 if configSvc == nil {
31 configSvc = inmocks.NewMockConfigService(t)
32 }
33 svc := NewService(configSvc, Config{})
34 if provider != nil {
35 svc.WithAppTargets(provider)
36 }
37 return svc
38}
39
40func appBackend(host string, port int) domain.AppBackend {
41 return domain.AppBackend{
42 Host: "127.0.0.1",
43 Port: port,
44 ContainerPort: 8080,
45 ContainerID: "c-app",
46 }
47}
48
49func TestService_GetTarget_FromCache(t *testing.T) {
50 svc := testService(t, nil, nil)
51 ctx := testContext()
52
53 // Pre-populate cache
54 cached := &domain.ProxyTarget{
55 Host: "127.0.0.1",
56 Port: 18080,
57 ContainerID: "container-123",
58 Scheme: "http",
59 }
60 svc.targets["app.example.com"] = cachedTarget{target: cached}
61
62 // No provider needed - should return from cache
63 result, err := svc.GetTarget(ctx, "app.example.com")
64
65 assert.NoError(t, err)
66 assert.Equal(t, cached, result)
67}
68
69func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) {
70 configSvc := inmocks.NewMockConfigService(t)
71 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
72 svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{}})
73 ctx := testContext()
74
75 result, err := svc.GetTarget(ctx, "App.Example.com")
76 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
77 assert.Nil(t, result)
78}
79
80func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) {
81 svc := testService(t, nil, nil)
82 result, err := svc.GetTarget(testContext(), "app.example.com:8080")
83 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
84 assert.Nil(t, result)
85}
86
87func TestService_GetTarget_AppBackend(t *testing.T) {
88 configSvc := inmocks.NewMockConfigService(t)
89 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
90 svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{
91 "app.example.com": appBackend("127.0.0.1", 18080),
92 }})
93 ctx := testContext()
94
95 result, err := svc.GetTarget(ctx, "app.example.com")
96
97 assert.NoError(t, err)
98 assert.Equal(t, "127.0.0.1", result.Host)
99 assert.Equal(t, 18080, result.Port)
100 assert.Equal(t, "c-app", result.ContainerID)
101 assert.Equal(t, "http", result.Scheme)
102 assert.Equal(t, "app.example.com", result.RouteHost)
103}
104
105func TestService_GetTarget_AppBackendUnresolved(t *testing.T) {
106 configSvc := inmocks.NewMockConfigService(t)
107 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
108 // Zero backend: recorded but unbound (fail closed).
109 svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{
110 "app.example.com": {ContainerPort: 8080},
111 }})
112 ctx := testContext()
113
114 result, err := svc.GetTarget(ctx, "app.example.com")
115 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
116 assert.Nil(t, result)
117}
118
119func TestService_GetTarget_NoProvider(t *testing.T) {
120 configSvc := inmocks.NewMockConfigService(t)
121 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
122 svc := testService(t, configSvc, nil)
123 ctx := testContext()
124
125 result, err := svc.GetTarget(ctx, "app.example.com")
126 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
127 assert.Nil(t, result)
128}
129
130func TestService_GetTarget_NoStaleAppBackendAfterReplacement(t *testing.T) {
131 configSvc := inmocks.NewMockConfigService(t)
132 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
133 provider := &stubProvider{backends: map[string]domain.AppBackend{
134 "app.example.com": appBackend("127.0.0.1", 32768),
135 }}
136 svc := testService(t, configSvc, provider)
137 ctx := testContext()
138
139 // First resolution observes the v1 bind.
140 result, err := svc.GetTarget(ctx, "app.example.com")
141 assert.NoError(t, err)
142 assert.Equal(t, 32768, result.Port)
143
144 // Deploy replacement: the index now records the v2 bind. No
145 // invalidation call happens; the next lookup must observe v2.
146 provider.backends["app.example.com"] = appBackend("127.0.0.1", 32769)
147 result, err = svc.GetTarget(ctx, "app.example.com")
148 assert.NoError(t, err)
149 assert.Equal(t, 32769, result.Port)
150
151 // App resolutions never populate the target cache.
152 svc.mu.RLock()
153 _, exists := svc.targets["app.example.com"]
154 svc.mu.RUnlock()
155 assert.False(t, exists)
156
157 // Withdrawal fails closed: no stale-target fallback.
158 delete(provider.backends, "app.example.com")
159 result, err = svc.GetTarget(ctx, "app.example.com")
160 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
161 assert.Nil(t, result)
162}
163
164func TestService_GetTarget_ExternalRoute(t *testing.T) {
165 configSvc := inmocks.NewMockConfigService(t)
166 svc := testService(t, configSvc, nil)
167 ctx := testContext()
168
169 // Mock external routes - use public IP to pass SSRF check
170 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
171 "reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range
172 })
173
174 result, err := svc.GetTarget(ctx, "reg.example.com")
175
176 assert.NoError(t, err)
177 assert.Equal(t, "203.0.113.10", result.Host)
178 assert.Equal(t, 5000, result.Port)
179 assert.Equal(t, "", result.ContainerID)
180 assert.Equal(t, "http", result.Scheme)
181}
182
183func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
184 configSvc := inmocks.NewMockConfigService(t)
185 svc := testService(t, configSvc, nil)
186 ctx := testContext()
187
188 // First call - should resolve external route (use public IP)
189 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
190 "reg.example.com": "203.0.113.10:5000",
191 }).Once()
192
193 result1, err := svc.GetTarget(ctx, "reg.example.com")
194 assert.NoError(t, err)
195 assert.Equal(t, "203.0.113.10", result1.Host)
196
197 // Second call - should return from cache (no mock call needed)
198 result2, err := svc.GetTarget(ctx, "reg.example.com")
199 assert.NoError(t, err)
200 assert.Equal(t, result1, result2)
201}
202
203func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) {
204 configSvc := inmocks.NewMockConfigService(t)
205 svc := testService(t, configSvc, nil)
206 ctx := testContext()
207
208 tests := []struct {
209 name string
210 target string
211 }{
212 {"localhost", "localhost:5000"},
213 {"loopback IP", "127.0.0.1:5000"},
214 {"private network 10.x", "10.0.0.1:5000"},
215 {"private network 172.x", "172.16.0.1:5000"},
216 {"private network 192.168.x", "192.168.1.1:5000"},
217 {"AWS metadata", "169.254.169.254:80"},
218 }
219
220 for _, tt := range tests {
221 t.Run(tt.name, func(t *testing.T) {
222 // Clear cache
223 svc.targets = make(map[string]cachedTarget)
224
225 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
226 "ssrf.example.com": tt.target,
227 }).Once()
228
229 result, err := svc.GetTarget(ctx, "ssrf.example.com")
230
231 assert.Error(t, err)
232 assert.ErrorIs(t, err, domain.ErrSSRFBlocked)
233 assert.Nil(t, result)
234 })
235 }
236}
237
238func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
239 configSvc := inmocks.NewMockConfigService(t)
240 svc := testService(t, configSvc, nil)
241 ctx := testContext()
242
243 // Mock external routes with invalid format (missing port)
244 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
245 "invalid.example.com": "not-valid-format",
246 })
247
248 result, err := svc.GetTarget(ctx, "invalid.example.com")
249
250 assert.Error(t, err)
251 assert.Nil(t, result)
252}
253
254func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
255 tests := []struct {
256 name string
257 target string
258 }{
259 {name: "not a number", target: "localhost:abc"},
260 {name: "zero", target: "localhost:0"},
261 {name: "too high", target: "localhost:65536"},
262 }
263
264 for _, tt := range tests {
265 t.Run(tt.name, func(t *testing.T) {
266 configSvc := inmocks.NewMockConfigService(t)
267 svc := testService(t, configSvc, nil)
268 ctx := testContext()
269
270 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
271 "invalid.example.com": tt.target,
272 })
273
274 result, err := svc.GetTarget(ctx, "invalid.example.com")
275
276 assert.Error(t, err)
277 assert.Nil(t, result)
278 })
279 }
280}
281
282func TestService_RegisterTarget(t *testing.T) {
283 svc := testService(t, nil, nil)
284 ctx := testContext()
285
286 target := &domain.ProxyTarget{
287 Host: "127.0.0.1",
288 Port: 18080,
289 ContainerID: "container-123",
290 Scheme: "http",
291 }
292
293 err := svc.RegisterTarget(ctx, "App.Example.com", target)
294
295 assert.NoError(t, err)
296
297 // Verify target is cached under the canonical domain.
298 svc.mu.RLock()
299 cached := svc.targets["app.example.com"]
300 svc.mu.RUnlock()
301
302 assert.Equal(t, target, cached.target)
303}
304
305func TestService_UnregisterTarget(t *testing.T) {
306 svc := testService(t, nil, nil)
307 ctx := testContext()
308
309 // Pre-populate
310 svc.targets["app.example.com"] = cachedTarget{target: &domain.ProxyTarget{
311 Host: "127.0.0.1",
312 Port: 18080,
313 }}
314
315 err := svc.UnregisterTarget(ctx, "App.Example.com")
316
317 assert.NoError(t, err)
318
319 // Verify target is removed
320 svc.mu.RLock()
321 _, exists := svc.targets["app.example.com"]
322 svc.mu.RUnlock()
323
324 assert.False(t, exists)
325}
326
327func TestService_RefreshTargets(t *testing.T) {
328 svc := testService(t, nil, nil)
329 ctx := testContext()
330
331 // Pre-populate with some targets
332 svc.targets["app1.example.com"] = cachedTarget{target: &domain.ProxyTarget{Host: "127.0.0.1"}}
333 svc.targets["app2.example.com"] = cachedTarget{target: &domain.ProxyTarget{Host: "127.0.0.1"}}
334
335 err := svc.RefreshTargets(ctx)
336
337 assert.NoError(t, err)
338
339 // Verify all targets are cleared
340 svc.mu.RLock()
341 count := len(svc.targets)
342 svc.mu.RUnlock()
343
344 assert.Equal(t, 0, count)
345}
346
347func TestService_UpdateConfig(t *testing.T) {
348 svc := testService(t, nil, nil)
349 svc.UpdateConfig(Config{
350 RegistryDomain: "old.registry.com",
351 RegistryPort: 5000,
352 })
353
354 newConfig := Config{
355 RegistryDomain: "new.registry.com",
356 RegistryPort: 5001,
357 }
358
359 svc.UpdateConfig(newConfig)
360
361 assert.Equal(t, "new.registry.com", svc.config.RegistryDomain)
362 assert.Equal(t, 5001, svc.config.RegistryPort)
363}
364
365func TestService_InvalidateTarget(t *testing.T) {
366 svc := testService(t, nil, nil)
367 ctx := testContext()
368
369 // Pre-populate cache with target
370 svc.targets["app.example.com"] = cachedTarget{target: &domain.ProxyTarget{
371 Host: "127.0.0.1",
372 Port: 18080,
373 ContainerID: "old-container",
374 }}
375
376 // Invalidate the target using mixed-case input.
377 svc.InvalidateTarget(ctx, "App.Example.com")
378
379 // Verify target is removed from cache
380 svc.mu.RLock()
381 _, exists := svc.targets["app.example.com"]
382 svc.mu.RUnlock()
383
384 assert.False(t, exists, "target should be removed from cache after invalidation")
385}
386
387func TestService_IsKnownHost_AppBackend(t *testing.T) {
388 configSvc := inmocks.NewMockConfigService(t)
389 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
390 svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{
391 "app.example.com": appBackend("127.0.0.1", 18080),
392 }})
393 ctx := testContext()
394
395 assert.True(t, svc.IsKnownHost(ctx, "app.example.com"))
396 assert.False(t, svc.IsKnownHost(ctx, "unknown.example.com"))
397}
398
399func TestRegistryInFlightTracking(t *testing.T) {
400 svc := &Service{
401 inFlight: make(map[string]int),
402 }
403
404 if got := svc.registryInFlight.Load(); got != 0 {
405 t.Fatalf("expected 0 in-flight, got %d", got)
406 }
407
408 svc.registryInFlight.Add(1)
409 if got := svc.registryInFlight.Load(); got != 1 {
410 t.Fatalf("expected 1 in-flight after Add, got %d", got)
411 }
412
413 svc.registryInFlight.Add(-1)
414 if got := svc.registryInFlight.Load(); got != 0 {
415 t.Fatalf("expected 0 in-flight after release, got %d", got)
416 }
417}
418
419func TestDrainRegistryInFlight(t *testing.T) {
420 svc := &Service{
421 inFlight: make(map[string]int),
422 }
423
424 svc.registryInFlight.Add(2)
425
426 result := make(chan bool, 1)
427 go func() {
428 result <- svc.DrainRegistryInFlight(50 * time.Millisecond)
429 }()
430
431 time.Sleep(5 * time.Millisecond)
432 svc.registryInFlight.Add(-1)
433 svc.registryInFlight.Add(-1)
434
435 select {
436 case drained := <-result:
437 if !drained {
438 t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed")
439 }
440 case <-time.After(500 * time.Millisecond):
441 t.Fatal("DrainRegistryInFlight did not return within timeout")
442 }
443}
444
445func TestDrainRegistryInFlightTimeout(t *testing.T) {
446 svc := &Service{
447 inFlight: make(map[string]int),
448 }
449
450 // Add a request and never release it
451 svc.registryInFlight.Add(1)
452
453 drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
454 if drained {
455 t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
456 }
457}
458
459func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
460 svc := testService(t, nil, nil)
461 svc.UpdateConfig(Config{
462 RegistryDomain: "old.registry.com",
463 RegistryPort: 5000,
464 MaxBodySize: 1024,
465 MaxResponseSize: 2048,
466 MaxConcurrentConns: 10,
467 })
468
469 cfg := svc.ProxyConfig()
470 assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
471 assert.Equal(t, 5000, cfg.RegistryPort)
472 assert.Equal(t, int64(1024), cfg.MaxBodySize)
473 assert.Equal(t, int64(2048), cfg.MaxResponseSize)
474 assert.Equal(t, 10, cfg.MaxConcurrentConns)
475
476 svc.UpdateConfig(Config{
477 RegistryDomain: "new.registry.com",
478 RegistryPort: 5001,
479 MaxBodySize: 4096,
480 MaxResponseSize: 8192,
481 MaxConcurrentConns: 50,
482 })
483
484 cfg = svc.ProxyConfig()
485 assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
486 assert.Equal(t, 5001, cfg.RegistryPort)
487 assert.Equal(t, int64(4096), cfg.MaxBodySize)
488 assert.Equal(t, int64(8192), cfg.MaxResponseSize)
489 assert.Equal(t, 50, cfg.MaxConcurrentConns)
490}
491
492func TestService_IsRegistryDomain(t *testing.T) {
493 svc := testService(t, nil, nil)
494 svc.UpdateConfig(Config{
495 RegistryDomain: "registry.example.com",
496 })
497
498 assert.True(t, svc.IsRegistryDomain("registry.example.com"))
499 assert.False(t, svc.IsRegistryDomain("other.example.com"))
500 assert.False(t, svc.IsRegistryDomain(""))
501}
502
503func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
504 svc := testService(t, nil, nil)
505 svc.UpdateConfig(Config{})
506
507 assert.False(t, svc.IsRegistryDomain("registry.example.com"))
508 assert.False(t, svc.IsRegistryDomain(""))
509}
510
511func TestService_TrackInFlight(t *testing.T) {
512 svc := &Service{
513 inFlight: make(map[string]int),
514 }
515
516 // Empty container ID returns noop
517 release := svc.TrackInFlight("")
518 release() // should not panic
519
520 // Track a container
521 release1 := svc.TrackInFlight("c-1")
522 svc.inFlightMu.Lock()
523 assert.Equal(t, 1, svc.inFlight["c-1"])
524 svc.inFlightMu.Unlock()
525
526 // Track same container again
527 release2 := svc.TrackInFlight("c-1")
528 svc.inFlightMu.Lock()
529 assert.Equal(t, 2, svc.inFlight["c-1"])
530 svc.inFlightMu.Unlock()
531
532 // Release one
533 release2()
534 svc.inFlightMu.Lock()
535 assert.Equal(t, 1, svc.inFlight["c-1"])
536 svc.inFlightMu.Unlock()
537
538 // Release last — should delete key
539 release1()
540 svc.inFlightMu.Lock()
541 _, exists := svc.inFlight["c-1"]
542 assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
543 svc.inFlightMu.Unlock()
544}
545
546func TestService_TrackRegistryRequest(t *testing.T) {
547 svc := &Service{
548 inFlight: make(map[string]int),
549 }
550
551 assert.Equal(t, int64(0), svc.RegistryInFlight())
552
553 svc.TrackRegistryRequest()
554 assert.Equal(t, int64(1), svc.RegistryInFlight())
555
556 svc.TrackRegistryRequest()
557 assert.Equal(t, int64(2), svc.RegistryInFlight())
558
559 svc.ReleaseRegistryRequest()
560 assert.Equal(t, int64(1), svc.RegistryInFlight())
561
562 svc.ReleaseRegistryRequest()
563 assert.Equal(t, int64(0), svc.RegistryInFlight())
564}