1package proxy
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11
12 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14 "github.com/bnema/gordon/internal/domain"
15)
16
17func testContext() context.Context {
18 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
19}
20
21func TestService_GetTarget_FromCache(t *testing.T) {
22 runtime := outmocks.NewMockContainerRuntime(t)
23 containerSvc := inmocks.NewMockContainerService(t)
24 configSvc := inmocks.NewMockConfigService(t)
25
26 config := Config{
27 RegistryDomain: "registry.example.com",
28 RegistryPort: 5000,
29 }
30 svc := NewService(runtime, containerSvc, configSvc, config)
31 ctx := testContext()
32
33 // Pre-populate cache
34 cachedTarget := &domain.ProxyTarget{
35 Host: "192.168.1.100",
36 Port: 8080,
37 ContainerID: "container-123",
38 Scheme: "http",
39 }
40 svc.targets["app.example.com"] = cachedTarget
41
42 // No mock calls expected - should return from cache
43
44 result, err := svc.GetTarget(ctx, "app.example.com")
45
46 assert.NoError(t, err)
47 assert.Equal(t, cachedTarget, result)
48}
49
50func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) {
51 runtime := outmocks.NewMockContainerRuntime(t)
52 containerSvc := inmocks.NewMockContainerService(t)
53 configSvc := inmocks.NewMockConfigService(t)
54 svc := NewService(runtime, containerSvc, configSvc, Config{})
55 ctx := testContext()
56
57 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
58 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
59
60 result, err := svc.GetTarget(ctx, "App.Example.com")
61 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
62 assert.Nil(t, result)
63}
64
65func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) {
66 svc := NewService(outmocks.NewMockContainerRuntime(t), inmocks.NewMockContainerService(t), inmocks.NewMockConfigService(t), Config{})
67 result, err := svc.GetTarget(testContext(), "app.example.com:8080")
68 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
69 assert.Nil(t, result)
70}
71
72func TestService_GetTarget_ContainerNotFound(t *testing.T) {
73 runtime := outmocks.NewMockContainerRuntime(t)
74 containerSvc := inmocks.NewMockContainerService(t)
75 configSvc := inmocks.NewMockConfigService(t)
76
77 config := Config{}
78 svc := NewService(runtime, containerSvc, configSvc, config)
79 ctx := testContext()
80
81 // Mock external routes (empty - no match)
82 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
83 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
84
85 result, err := svc.GetTarget(ctx, "app.example.com")
86
87 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
88 assert.Nil(t, result)
89}
90
91func TestService_GetTarget_ExternalRoute(t *testing.T) {
92 runtime := outmocks.NewMockContainerRuntime(t)
93 containerSvc := inmocks.NewMockContainerService(t)
94 configSvc := inmocks.NewMockConfigService(t)
95
96 svc := NewService(runtime, containerSvc, configSvc, Config{})
97 ctx := testContext()
98
99 // Mock external routes - use public IP to pass SSRF check
100 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
101 "reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range
102 })
103
104 result, err := svc.GetTarget(ctx, "reg.example.com")
105
106 assert.NoError(t, err)
107 assert.Equal(t, "203.0.113.10", result.Host)
108 assert.Equal(t, 5000, result.Port)
109 assert.Equal(t, "", result.ContainerID)
110 assert.Equal(t, "http", result.Scheme)
111}
112
113func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
114 runtime := outmocks.NewMockContainerRuntime(t)
115 containerSvc := inmocks.NewMockContainerService(t)
116 configSvc := inmocks.NewMockConfigService(t)
117
118 svc := NewService(runtime, containerSvc, configSvc, Config{})
119 ctx := testContext()
120
121 // First call - should resolve external route (use public IP)
122 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
123 "reg.example.com": "203.0.113.10:5000",
124 }).Once()
125
126 result1, err := svc.GetTarget(ctx, "reg.example.com")
127 assert.NoError(t, err)
128 assert.Equal(t, "203.0.113.10", result1.Host)
129
130 // Second call - should return from cache (no mock call needed)
131 result2, err := svc.GetTarget(ctx, "reg.example.com")
132 assert.NoError(t, err)
133 assert.Equal(t, result1, result2)
134}
135
136func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) {
137 runtime := outmocks.NewMockContainerRuntime(t)
138 containerSvc := inmocks.NewMockContainerService(t)
139 configSvc := inmocks.NewMockConfigService(t)
140
141 svc := NewService(runtime, containerSvc, configSvc, Config{})
142 ctx := testContext()
143
144 tests := []struct {
145 name string
146 target string
147 }{
148 {"localhost", "localhost:5000"},
149 {"loopback IP", "127.0.0.1:5000"},
150 {"private network 10.x", "10.0.0.1:5000"},
151 {"private network 172.x", "172.16.0.1:5000"},
152 {"private network 192.168.x", "192.168.1.1:5000"},
153 {"AWS metadata", "169.254.169.254:80"},
154 }
155
156 for _, tt := range tests {
157 t.Run(tt.name, func(t *testing.T) {
158 // Clear cache
159 svc.targets = make(map[string]*domain.ProxyTarget)
160
161 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
162 "ssrf.example.com": tt.target,
163 }).Once()
164
165 result, err := svc.GetTarget(ctx, "ssrf.example.com")
166
167 assert.Error(t, err)
168 assert.ErrorIs(t, err, domain.ErrSSRFBlocked)
169 assert.Nil(t, result)
170 })
171 }
172}
173
174func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
175 runtime := outmocks.NewMockContainerRuntime(t)
176 containerSvc := inmocks.NewMockContainerService(t)
177 configSvc := inmocks.NewMockConfigService(t)
178
179 svc := NewService(runtime, containerSvc, configSvc, Config{})
180 ctx := testContext()
181
182 // Mock external routes with invalid format (missing port)
183 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
184 "invalid.example.com": "not-valid-format",
185 })
186
187 result, err := svc.GetTarget(ctx, "invalid.example.com")
188
189 assert.Error(t, err)
190 assert.Nil(t, result)
191}
192
193func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
194 tests := []struct {
195 name string
196 target string
197 }{
198 {name: "not a number", target: "localhost:abc"},
199 {name: "zero", target: "localhost:0"},
200 {name: "too high", target: "localhost:65536"},
201 }
202
203 for _, tt := range tests {
204 t.Run(tt.name, func(t *testing.T) {
205 runtime := outmocks.NewMockContainerRuntime(t)
206 containerSvc := inmocks.NewMockContainerService(t)
207 configSvc := inmocks.NewMockConfigService(t)
208
209 svc := NewService(runtime, containerSvc, configSvc, Config{})
210 ctx := testContext()
211
212 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
213 "invalid.example.com": tt.target,
214 })
215
216 result, err := svc.GetTarget(ctx, "invalid.example.com")
217
218 assert.Error(t, err)
219 assert.Nil(t, result)
220 })
221 }
222}
223
224func TestService_RegisterTarget(t *testing.T) {
225 runtime := outmocks.NewMockContainerRuntime(t)
226 containerSvc := inmocks.NewMockContainerService(t)
227 configSvc := inmocks.NewMockConfigService(t)
228
229 svc := NewService(runtime, containerSvc, configSvc, Config{})
230 ctx := testContext()
231
232 target := &domain.ProxyTarget{
233 Host: "192.168.1.100",
234 Port: 8080,
235 ContainerID: "container-123",
236 Scheme: "http",
237 }
238
239 err := svc.RegisterTarget(ctx, "App.Example.com", target)
240
241 assert.NoError(t, err)
242
243 // Verify target is cached under the canonical domain.
244 svc.mu.RLock()
245 cached := svc.targets["app.example.com"]
246 svc.mu.RUnlock()
247
248 assert.Equal(t, target, cached)
249}
250
251func TestService_UnregisterTarget(t *testing.T) {
252 runtime := outmocks.NewMockContainerRuntime(t)
253 containerSvc := inmocks.NewMockContainerService(t)
254 configSvc := inmocks.NewMockConfigService(t)
255
256 svc := NewService(runtime, containerSvc, configSvc, Config{})
257 ctx := testContext()
258
259 // Pre-populate
260 svc.targets["app.example.com"] = &domain.ProxyTarget{
261 Host: "192.168.1.100",
262 Port: 8080,
263 }
264
265 err := svc.UnregisterTarget(ctx, "App.Example.com")
266
267 assert.NoError(t, err)
268
269 // Verify target is removed
270 svc.mu.RLock()
271 _, exists := svc.targets["app.example.com"]
272 svc.mu.RUnlock()
273
274 assert.False(t, exists)
275}
276
277func TestService_RefreshTargets(t *testing.T) {
278 runtime := outmocks.NewMockContainerRuntime(t)
279 containerSvc := inmocks.NewMockContainerService(t)
280 configSvc := inmocks.NewMockConfigService(t)
281
282 svc := NewService(runtime, containerSvc, configSvc, Config{})
283 ctx := testContext()
284
285 // Pre-populate with some targets
286 svc.targets["app1.example.com"] = &domain.ProxyTarget{Host: "192.168.1.100"}
287 svc.targets["app2.example.com"] = &domain.ProxyTarget{Host: "192.168.1.101"}
288
289 err := svc.RefreshTargets(ctx)
290
291 assert.NoError(t, err)
292
293 // Verify all targets are cleared
294 svc.mu.RLock()
295 count := len(svc.targets)
296 svc.mu.RUnlock()
297
298 assert.Equal(t, 0, count)
299}
300
301func TestService_UpdateConfig(t *testing.T) {
302 runtime := outmocks.NewMockContainerRuntime(t)
303 containerSvc := inmocks.NewMockContainerService(t)
304 configSvc := inmocks.NewMockConfigService(t)
305
306 svc := NewService(runtime, containerSvc, configSvc, Config{
307 RegistryDomain: "old.registry.com",
308 RegistryPort: 5000,
309 })
310
311 newConfig := Config{
312 RegistryDomain: "new.registry.com",
313 RegistryPort: 5001,
314 }
315
316 svc.UpdateConfig(newConfig)
317
318 assert.Equal(t, "new.registry.com", svc.config.RegistryDomain)
319 assert.Equal(t, 5001, svc.config.RegistryPort)
320}
321
322func TestService_isRunningInContainer(t *testing.T) {
323 runtime := outmocks.NewMockContainerRuntime(t)
324 containerSvc := inmocks.NewMockContainerService(t)
325 configSvc := inmocks.NewMockConfigService(t)
326
327 svc := NewService(runtime, containerSvc, configSvc, Config{})
328
329 // This test just verifies the method doesn't panic
330 // The actual result depends on the environment
331 result := svc.isRunningInContainer()
332 assert.IsType(t, true, result) // Just verify it returns a bool
333}
334
335func TestService_InvalidateTarget(t *testing.T) {
336 runtime := outmocks.NewMockContainerRuntime(t)
337 containerSvc := inmocks.NewMockContainerService(t)
338 configSvc := inmocks.NewMockConfigService(t)
339
340 svc := NewService(runtime, containerSvc, configSvc, Config{})
341 ctx := testContext()
342
343 // Pre-populate cache with target
344 svc.targets["app.example.com"] = &domain.ProxyTarget{
345 Host: "192.168.1.100",
346 Port: 8080,
347 ContainerID: "old-container",
348 }
349
350 // Invalidate the target using mixed-case input.
351 svc.InvalidateTarget(ctx, "App.Example.com")
352
353 // Verify target is removed from cache
354 svc.mu.RLock()
355 _, exists := svc.targets["app.example.com"]
356 svc.mu.RUnlock()
357
358 assert.False(t, exists, "target should be removed from cache after invalidation")
359}
360
361func TestContainerDeployedHandler_CanHandle(t *testing.T) {
362 handler := NewContainerDeployedHandler(testContext(), nil)
363
364 assert.True(t, handler.CanHandle(domain.EventContainerDeployed))
365 assert.False(t, handler.CanHandle(domain.EventImagePushed))
366 assert.False(t, handler.CanHandle(domain.EventConfigReload))
367}
368
369func TestContainerDeployedHandler_Handle_InvalidatesCache(t *testing.T) {
370 runtime := outmocks.NewMockContainerRuntime(t)
371 containerSvc := inmocks.NewMockContainerService(t)
372 configSvc := inmocks.NewMockConfigService(t)
373
374 svc := NewService(runtime, containerSvc, configSvc, Config{})
375 ctx := testContext()
376
377 // Pre-populate cache
378 svc.targets["app.example.com"] = &domain.ProxyTarget{
379 Host: "192.168.1.100",
380 Port: 8080,
381 ContainerID: "old-container",
382 }
383
384 // Create handler with service as invalidator
385 handler := NewContainerDeployedHandler(ctx, svc)
386
387 // Simulate container deployed event
388 event := domain.Event{
389 ID: "event-123",
390 Type: domain.EventContainerDeployed,
391 Route: "app.example.com",
392 Data: &domain.ContainerEventPayload{
393 ContainerID: "new-container",
394 Domain: "app.example.com",
395 },
396 }
397
398 err := handler.Handle(context.Background(), event)
399
400 assert.NoError(t, err)
401
402 // Verify target was invalidated
403 svc.mu.RLock()
404 _, exists := svc.targets["app.example.com"]
405 svc.mu.RUnlock()
406
407 assert.False(t, exists, "cache should be invalidated after container deployed event")
408}
409
410func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
411 runtime := outmocks.NewMockContainerRuntime(t)
412 containerSvc := inmocks.NewMockContainerService(t)
413 configSvc := inmocks.NewMockConfigService(t)
414
415 svc := NewService(runtime, containerSvc, configSvc, Config{})
416 ctx := testContext()
417
418 handler := NewContainerDeployedHandler(ctx, svc)
419
420 // Event with no domain
421 event := domain.Event{
422 ID: "event-123",
423 Type: domain.EventContainerDeployed,
424 Data: &domain.ContainerEventPayload{
425 ContainerID: "new-container",
426 },
427 }
428
429 // Should not error, just skip
430 err := handler.Handle(context.Background(), event)
431 assert.NoError(t, err)
432}
433
434func TestRegistryInFlightTracking(t *testing.T) {
435 svc := &Service{
436 inFlight: make(map[string]int),
437 }
438
439 if got := svc.registryInFlight.Load(); got != 0 {
440 t.Fatalf("expected 0 in-flight, got %d", got)
441 }
442
443 svc.registryInFlight.Add(1)
444 if got := svc.registryInFlight.Load(); got != 1 {
445 t.Fatalf("expected 1 in-flight after Add, got %d", got)
446 }
447
448 svc.registryInFlight.Add(-1)
449 if got := svc.registryInFlight.Load(); got != 0 {
450 t.Fatalf("expected 0 in-flight after release, got %d", got)
451 }
452}
453
454func TestDrainRegistryInFlight(t *testing.T) {
455 svc := &Service{
456 inFlight: make(map[string]int),
457 }
458
459 svc.registryInFlight.Add(2)
460
461 result := make(chan bool, 1)
462 go func() {
463 result <- svc.DrainRegistryInFlight(50 * time.Millisecond)
464 }()
465
466 time.Sleep(5 * time.Millisecond)
467 svc.registryInFlight.Add(-1)
468 svc.registryInFlight.Add(-1)
469
470 select {
471 case drained := <-result:
472 if !drained {
473 t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed")
474 }
475 case <-time.After(500 * time.Millisecond):
476 t.Fatal("DrainRegistryInFlight did not return within timeout")
477 }
478}
479
480func TestDrainRegistryInFlightTimeout(t *testing.T) {
481 svc := &Service{
482 inFlight: make(map[string]int),
483 }
484
485 // Add a request and never release it
486 svc.registryInFlight.Add(1)
487
488 drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
489 if drained {
490 t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
491 }
492}
493
494func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
495 runtime := outmocks.NewMockContainerRuntime(t)
496 containerSvc := inmocks.NewMockContainerService(t)
497 configSvc := inmocks.NewMockConfigService(t)
498
499 svc := NewService(runtime, containerSvc, configSvc, Config{
500 RegistryDomain: "old.registry.com",
501 RegistryPort: 5000,
502 MaxBodySize: 1024,
503 MaxResponseSize: 2048,
504 MaxConcurrentConns: 10,
505 })
506
507 cfg := svc.ProxyConfig()
508 assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
509 assert.Equal(t, 5000, cfg.RegistryPort)
510 assert.Equal(t, int64(1024), cfg.MaxBodySize)
511 assert.Equal(t, int64(2048), cfg.MaxResponseSize)
512 assert.Equal(t, 10, cfg.MaxConcurrentConns)
513
514 svc.UpdateConfig(Config{
515 RegistryDomain: "new.registry.com",
516 RegistryPort: 5001,
517 MaxBodySize: 4096,
518 MaxResponseSize: 8192,
519 MaxConcurrentConns: 50,
520 })
521
522 cfg = svc.ProxyConfig()
523 assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
524 assert.Equal(t, 5001, cfg.RegistryPort)
525 assert.Equal(t, int64(4096), cfg.MaxBodySize)
526 assert.Equal(t, int64(8192), cfg.MaxResponseSize)
527 assert.Equal(t, 50, cfg.MaxConcurrentConns)
528}
529
530func TestService_IsRegistryDomain(t *testing.T) {
531 runtime := outmocks.NewMockContainerRuntime(t)
532 containerSvc := inmocks.NewMockContainerService(t)
533 configSvc := inmocks.NewMockConfigService(t)
534
535 svc := NewService(runtime, containerSvc, configSvc, Config{
536 RegistryDomain: "registry.example.com",
537 })
538
539 assert.True(t, svc.IsRegistryDomain("registry.example.com"))
540 assert.False(t, svc.IsRegistryDomain("other.example.com"))
541 assert.False(t, svc.IsRegistryDomain(""))
542}
543
544func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
545 runtime := outmocks.NewMockContainerRuntime(t)
546 containerSvc := inmocks.NewMockContainerService(t)
547 configSvc := inmocks.NewMockConfigService(t)
548
549 svc := NewService(runtime, containerSvc, configSvc, Config{})
550
551 assert.False(t, svc.IsRegistryDomain("registry.example.com"))
552 assert.False(t, svc.IsRegistryDomain(""))
553}
554
555func TestService_TrackInFlight(t *testing.T) {
556 svc := &Service{
557 inFlight: make(map[string]int),
558 }
559
560 // Empty container ID returns noop
561 release := svc.TrackInFlight("")
562 release() // should not panic
563
564 // Track a container
565 release1 := svc.TrackInFlight("c-1")
566 svc.inFlightMu.Lock()
567 assert.Equal(t, 1, svc.inFlight["c-1"])
568 svc.inFlightMu.Unlock()
569
570 // Track same container again
571 release2 := svc.TrackInFlight("c-1")
572 svc.inFlightMu.Lock()
573 assert.Equal(t, 2, svc.inFlight["c-1"])
574 svc.inFlightMu.Unlock()
575
576 // Release one
577 release2()
578 svc.inFlightMu.Lock()
579 assert.Equal(t, 1, svc.inFlight["c-1"])
580 svc.inFlightMu.Unlock()
581
582 // Release last — should delete key
583 release1()
584 svc.inFlightMu.Lock()
585 _, exists := svc.inFlight["c-1"]
586 assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
587 svc.inFlightMu.Unlock()
588}
589
590func TestService_TrackRegistryRequest(t *testing.T) {
591 svc := &Service{
592 inFlight: make(map[string]int),
593 }
594
595 assert.Equal(t, int64(0), svc.RegistryInFlight())
596
597 svc.TrackRegistryRequest()
598 assert.Equal(t, int64(1), svc.RegistryInFlight())
599
600 svc.TrackRegistryRequest()
601 assert.Equal(t, int64(2), svc.RegistryInFlight())
602
603 svc.ReleaseRegistryRequest()
604 assert.Equal(t, int64(1), svc.RegistryInFlight())
605
606 svc.ReleaseRegistryRequest()
607 assert.Equal(t, int64(0), svc.RegistryInFlight())
608}
609
610func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
611 runtime := outmocks.NewMockContainerRuntime(t)
612 containerSvc := inmocks.NewMockContainerService(t)
613 configSvc := inmocks.NewMockConfigService(t)
614
615 svc := NewService(runtime, containerSvc, configSvc, Config{})
616 ctx := testContext()
617
618 // No external routes
619 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
620
621 // Container exists for this domain
622 container := &domain.Container{
623 ID: "c-gitea",
624 Image: "gitea/gitea:latest",
625 }
626 containerSvc.EXPECT().Get(mock.Anything, "git.example.com").Return(container, true)
627
628 // Route exists
629 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
630 {Domain: "git.example.com", Image: "gitea/gitea:latest"},
631 })
632
633 // Image has gordon.proxy.port=3000 label
634 runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
635 domain.LabelProxyPort: "3000",
636 }, nil)
637
638 // Host port mapping for internal port 3000
639 runtime.EXPECT().GetContainerPort(mock.Anything, "c-gitea", 3000).Return(32000, nil)
640
641 result, err := svc.GetTarget(ctx, "git.example.com")
642
643 assert.NoError(t, err)
644 assert.Equal(t, "localhost", result.Host)
645 assert.Equal(t, 32000, result.Port)
646 assert.Equal(t, "c-gitea", result.ContainerID)
647}
648
649func TestService_GetTarget_HostMode_UsesDeprecatedPortLabel(t *testing.T) {
650 runtime := outmocks.NewMockContainerRuntime(t)
651 containerSvc := inmocks.NewMockContainerService(t)
652 configSvc := inmocks.NewMockConfigService(t)
653
654 svc := NewService(runtime, containerSvc, configSvc, Config{})
655 ctx := testContext()
656
657 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
658
659 container := &domain.Container{
660 ID: "c-app",
661 Image: "myapp:latest",
662 }
663 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
664
665 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
666 {Domain: "app.example.com", Image: "myapp:latest"},
667 })
668
669 // Image has deprecated gordon.port label — should still work
670 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(map[string]string{
671 domain.LabelPort: "8080",
672 }, nil)
673
674 runtime.EXPECT().GetContainerPort(mock.Anything, "c-app", 8080).Return(33000, nil)
675
676 result, err := svc.GetTarget(ctx, "app.example.com")
677
678 assert.NoError(t, err)
679 assert.Equal(t, 33000, result.Port)
680}
681
682func TestService_GetTarget_HostMode_ProxyPortWinsOverPort(t *testing.T) {
683 runtime := outmocks.NewMockContainerRuntime(t)
684 containerSvc := inmocks.NewMockContainerService(t)
685 configSvc := inmocks.NewMockConfigService(t)
686
687 svc := NewService(runtime, containerSvc, configSvc, Config{})
688 ctx := testContext()
689
690 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
691
692 container := &domain.Container{
693 ID: "c-dual",
694 Image: "dualapp:latest",
695 }
696 containerSvc.EXPECT().Get(mock.Anything, "dual.example.com").Return(container, true)
697
698 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
699 {Domain: "dual.example.com", Image: "dualapp:latest"},
700 })
701
702 // Both labels set — gordon.proxy.port=9000 should win over gordon.port=3000
703 runtime.EXPECT().GetImageLabels(mock.Anything, "dualapp:latest").Return(map[string]string{
704 domain.LabelProxyPort: "9000",
705 domain.LabelPort: "3000",
706 }, nil)
707
708 // Should use 9000 (gordon.proxy.port), NOT 3000 (gordon.port)
709 runtime.EXPECT().GetContainerPort(mock.Anything, "c-dual", 9000).Return(34000, nil)
710
711 result, err := svc.GetTarget(ctx, "dual.example.com")
712
713 assert.NoError(t, err)
714 assert.Equal(t, 34000, result.Port)
715}
716
717func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
718 runtime := outmocks.NewMockContainerRuntime(t)
719 containerSvc := inmocks.NewMockContainerService(t)
720 configSvc := inmocks.NewMockConfigService(t)
721
722 svc := NewService(runtime, containerSvc, configSvc, Config{})
723 ctx := testContext()
724
725 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
726
727 container := &domain.Container{
728 ID: "c-plain",
729 Image: "plain:latest",
730 }
731 containerSvc.EXPECT().Get(mock.Anything, "plain.example.com").Return(container, true)
732
733 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
734 {Domain: "plain.example.com", Image: "plain:latest"},
735 })
736
737 // No port labels — should fall back to first exposed port
738 runtime.EXPECT().GetImageLabels(mock.Anything, "plain:latest").Return(map[string]string{}, nil)
739 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "plain:latest").Return([]int{8080}, nil)
740
741 runtime.EXPECT().GetContainerPort(mock.Anything, "c-plain", 8080).Return(35000, nil)
742
743 result, err := svc.GetTarget(ctx, "plain.example.com")
744
745 assert.NoError(t, err)
746 assert.Equal(t, 35000, result.Port)
747}
748
749func TestService_GetTarget_HostMode_H2CProtocolPropagated(t *testing.T) {
750 runtime := outmocks.NewMockContainerRuntime(t)
751 containerSvc := inmocks.NewMockContainerService(t)
752 configSvc := inmocks.NewMockConfigService(t)
753
754 svc := NewService(runtime, containerSvc, configSvc, Config{})
755 ctx := testContext()
756
757 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
758
759 container := &domain.Container{
760 ID: "c-grpc",
761 Image: "grpc-app:latest",
762 }
763 containerSvc.EXPECT().Get(mock.Anything, "grpc.example.com").Return(container, true)
764
765 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
766 {Domain: "grpc.example.com", Image: "grpc-app:latest"},
767 })
768
769 runtime.EXPECT().GetImageLabels(mock.Anything, "grpc-app:latest").Return(map[string]string{
770 domain.LabelProxyPort: "50051",
771 domain.LabelProxyProtocol: "h2c",
772 }, nil)
773
774 runtime.EXPECT().GetContainerPort(mock.Anything, "c-grpc", 50051).Return(50051, nil)
775
776 result, err := svc.GetTarget(ctx, "grpc.example.com")
777
778 assert.NoError(t, err)
779 assert.Equal(t, "h2c", result.Protocol)
780 assert.Equal(t, 50051, result.Port)
781}
782
783func TestService_GetTarget_HostMode_DefaultProtocol(t *testing.T) {
784 runtime := outmocks.NewMockContainerRuntime(t)
785 containerSvc := inmocks.NewMockContainerService(t)
786 configSvc := inmocks.NewMockConfigService(t)
787
788 svc := NewService(runtime, containerSvc, configSvc, Config{})
789 ctx := testContext()
790
791 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
792
793 container := &domain.Container{
794 ID: "c-web",
795 Image: "web:latest",
796 }
797 containerSvc.EXPECT().Get(mock.Anything, "web.example.com").Return(container, true)
798
799 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
800 {Domain: "web.example.com", Image: "web:latest"},
801 })
802
803 runtime.EXPECT().GetImageLabels(mock.Anything, "web:latest").Return(map[string]string{
804 domain.LabelProxyPort: "8080",
805 }, nil)
806
807 runtime.EXPECT().GetContainerPort(mock.Anything, "c-web", 8080).Return(8080, nil)
808
809 result, err := svc.GetTarget(ctx, "web.example.com")
810
811 assert.NoError(t, err)
812 assert.Equal(t, "", result.Protocol)
813}