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