1package proxy
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/zerowrap"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"
10
11 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
12 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16func testContext() context.Context {
17 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
18}
19
20func TestService_GetTarget_FromCache(t *testing.T) {
21 runtime := outmocks.NewMockContainerRuntime(t)
22 containerSvc := inmocks.NewMockContainerService(t)
23 configSvc := inmocks.NewMockConfigService(t)
24
25 config := Config{
26 RegistryDomain: "registry.example.com",
27 RegistryPort: 5000,
28 }
29 svc := NewService(runtime, containerSvc, configSvc, config)
30 ctx := testContext()
31
32 // Pre-populate cache
33 cachedTarget := &domain.ProxyTarget{
34 Host: "192.168.1.100",
35 Port: 8080,
36 ContainerID: "container-123",
37 Scheme: "http",
38 }
39 svc.targets["app.example.com"] = cachedTarget
40
41 // No mock calls expected - should return from cache
42
43 result, err := svc.GetTarget(ctx, "app.example.com")
44
45 assert.NoError(t, err)
46 assert.Equal(t, cachedTarget, result)
47}
48
49func TestService_GetTarget_ContainerNotFound(t *testing.T) {
50 runtime := outmocks.NewMockContainerRuntime(t)
51 containerSvc := inmocks.NewMockContainerService(t)
52 configSvc := inmocks.NewMockConfigService(t)
53
54 config := Config{}
55 svc := NewService(runtime, containerSvc, configSvc, config)
56 ctx := testContext()
57
58 // Mock external routes (empty - no match)
59 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
60 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
61
62 result, err := svc.GetTarget(ctx, "app.example.com")
63
64 assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
65 assert.Nil(t, result)
66}
67
68func TestService_GetTarget_ExternalRoute(t *testing.T) {
69 runtime := outmocks.NewMockContainerRuntime(t)
70 containerSvc := inmocks.NewMockContainerService(t)
71 configSvc := inmocks.NewMockConfigService(t)
72
73 svc := NewService(runtime, containerSvc, configSvc, Config{})
74 ctx := testContext()
75
76 // Mock external routes - use public IP to pass SSRF check
77 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
78 "reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range
79 })
80
81 result, err := svc.GetTarget(ctx, "reg.example.com")
82
83 assert.NoError(t, err)
84 assert.Equal(t, "203.0.113.10", result.Host)
85 assert.Equal(t, 5000, result.Port)
86 assert.Equal(t, "", result.ContainerID)
87 assert.Equal(t, "http", result.Scheme)
88}
89
90func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
91 runtime := outmocks.NewMockContainerRuntime(t)
92 containerSvc := inmocks.NewMockContainerService(t)
93 configSvc := inmocks.NewMockConfigService(t)
94
95 svc := NewService(runtime, containerSvc, configSvc, Config{})
96 ctx := testContext()
97
98 // First call - should resolve external route (use public IP)
99 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100 "reg.example.com": "203.0.113.10:5000",
101 }).Once()
102
103 result1, err := svc.GetTarget(ctx, "reg.example.com")
104 assert.NoError(t, err)
105 assert.Equal(t, "203.0.113.10", result1.Host)
106
107 // Second call - should return from cache (no mock call needed)
108 result2, err := svc.GetTarget(ctx, "reg.example.com")
109 assert.NoError(t, err)
110 assert.Equal(t, result1, result2)
111}
112
113func TestService_GetTarget_ExternalRoute_SSRFBlocked(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 tests := []struct {
122 name string
123 target string
124 }{
125 {"localhost", "localhost:5000"},
126 {"loopback IP", "127.0.0.1:5000"},
127 {"private network 10.x", "10.0.0.1:5000"},
128 {"private network 172.x", "172.16.0.1:5000"},
129 {"private network 192.168.x", "192.168.1.1:5000"},
130 {"AWS metadata", "169.254.169.254:80"},
131 }
132
133 for _, tt := range tests {
134 t.Run(tt.name, func(t *testing.T) {
135 // Clear cache
136 svc.targets = make(map[string]*domain.ProxyTarget)
137
138 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
139 "ssrf.example.com": tt.target,
140 }).Once()
141
142 result, err := svc.GetTarget(ctx, "ssrf.example.com")
143
144 assert.Error(t, err)
145 assert.ErrorIs(t, err, domain.ErrSSRFBlocked)
146 assert.Nil(t, result)
147 })
148 }
149}
150
151func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
152 runtime := outmocks.NewMockContainerRuntime(t)
153 containerSvc := inmocks.NewMockContainerService(t)
154 configSvc := inmocks.NewMockConfigService(t)
155
156 svc := NewService(runtime, containerSvc, configSvc, Config{})
157 ctx := testContext()
158
159 // Mock external routes with invalid format (missing port)
160 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
161 "invalid.example.com": "not-valid-format",
162 })
163
164 result, err := svc.GetTarget(ctx, "invalid.example.com")
165
166 assert.Error(t, err)
167 assert.Nil(t, result)
168}
169
170func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
171 runtime := outmocks.NewMockContainerRuntime(t)
172 containerSvc := inmocks.NewMockContainerService(t)
173 configSvc := inmocks.NewMockConfigService(t)
174
175 svc := NewService(runtime, containerSvc, configSvc, Config{})
176 ctx := testContext()
177
178 // Mock external routes with invalid port
179 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
180 "invalid.example.com": "localhost:abc",
181 })
182
183 result, err := svc.GetTarget(ctx, "invalid.example.com")
184
185 assert.Error(t, err)
186 assert.Nil(t, result)
187}
188
189func TestService_RegisterTarget(t *testing.T) {
190 runtime := outmocks.NewMockContainerRuntime(t)
191 containerSvc := inmocks.NewMockContainerService(t)
192 configSvc := inmocks.NewMockConfigService(t)
193
194 svc := NewService(runtime, containerSvc, configSvc, Config{})
195 ctx := testContext()
196
197 target := &domain.ProxyTarget{
198 Host: "192.168.1.100",
199 Port: 8080,
200 ContainerID: "container-123",
201 Scheme: "http",
202 }
203
204 err := svc.RegisterTarget(ctx, "app.example.com", target)
205
206 assert.NoError(t, err)
207
208 // Verify target is cached
209 svc.mu.RLock()
210 cached := svc.targets["app.example.com"]
211 svc.mu.RUnlock()
212
213 assert.Equal(t, target, cached)
214}
215
216func TestService_UnregisterTarget(t *testing.T) {
217 runtime := outmocks.NewMockContainerRuntime(t)
218 containerSvc := inmocks.NewMockContainerService(t)
219 configSvc := inmocks.NewMockConfigService(t)
220
221 svc := NewService(runtime, containerSvc, configSvc, Config{})
222 ctx := testContext()
223
224 // Pre-populate
225 svc.targets["app.example.com"] = &domain.ProxyTarget{
226 Host: "192.168.1.100",
227 Port: 8080,
228 }
229
230 err := svc.UnregisterTarget(ctx, "app.example.com")
231
232 assert.NoError(t, err)
233
234 // Verify target is removed
235 svc.mu.RLock()
236 _, exists := svc.targets["app.example.com"]
237 svc.mu.RUnlock()
238
239 assert.False(t, exists)
240}
241
242func TestService_RefreshTargets(t *testing.T) {
243 runtime := outmocks.NewMockContainerRuntime(t)
244 containerSvc := inmocks.NewMockContainerService(t)
245 configSvc := inmocks.NewMockConfigService(t)
246
247 svc := NewService(runtime, containerSvc, configSvc, Config{})
248 ctx := testContext()
249
250 // Pre-populate with some targets
251 svc.targets["app1.example.com"] = &domain.ProxyTarget{Host: "192.168.1.100"}
252 svc.targets["app2.example.com"] = &domain.ProxyTarget{Host: "192.168.1.101"}
253
254 err := svc.RefreshTargets(ctx)
255
256 assert.NoError(t, err)
257
258 // Verify all targets are cleared
259 svc.mu.RLock()
260 count := len(svc.targets)
261 svc.mu.RUnlock()
262
263 assert.Equal(t, 0, count)
264}
265
266func TestService_UpdateConfig(t *testing.T) {
267 runtime := outmocks.NewMockContainerRuntime(t)
268 containerSvc := inmocks.NewMockContainerService(t)
269 configSvc := inmocks.NewMockConfigService(t)
270
271 svc := NewService(runtime, containerSvc, configSvc, Config{
272 RegistryDomain: "old.registry.com",
273 RegistryPort: 5000,
274 })
275
276 newConfig := Config{
277 RegistryDomain: "new.registry.com",
278 RegistryPort: 5001,
279 }
280
281 svc.UpdateConfig(newConfig)
282
283 assert.Equal(t, "new.registry.com", svc.config.RegistryDomain)
284 assert.Equal(t, 5001, svc.config.RegistryPort)
285}
286
287func TestService_isRunningInContainer(t *testing.T) {
288 runtime := outmocks.NewMockContainerRuntime(t)
289 containerSvc := inmocks.NewMockContainerService(t)
290 configSvc := inmocks.NewMockConfigService(t)
291
292 svc := NewService(runtime, containerSvc, configSvc, Config{})
293
294 // This test just verifies the method doesn't panic
295 // The actual result depends on the environment
296 result := svc.isRunningInContainer()
297 assert.IsType(t, true, result) // Just verify it returns a bool
298}
299
300func TestService_InvalidateTarget(t *testing.T) {
301 runtime := outmocks.NewMockContainerRuntime(t)
302 containerSvc := inmocks.NewMockContainerService(t)
303 configSvc := inmocks.NewMockConfigService(t)
304
305 svc := NewService(runtime, containerSvc, configSvc, Config{})
306 ctx := testContext()
307
308 // Pre-populate cache with target
309 svc.targets["app.example.com"] = &domain.ProxyTarget{
310 Host: "192.168.1.100",
311 Port: 8080,
312 ContainerID: "old-container",
313 }
314
315 // Invalidate the target
316 svc.InvalidateTarget(ctx, "app.example.com")
317
318 // Verify target is removed from cache
319 svc.mu.RLock()
320 _, exists := svc.targets["app.example.com"]
321 svc.mu.RUnlock()
322
323 assert.False(t, exists, "target should be removed from cache after invalidation")
324}
325
326func TestContainerDeployedHandler_CanHandle(t *testing.T) {
327 handler := NewContainerDeployedHandler(testContext(), nil)
328
329 assert.True(t, handler.CanHandle(domain.EventContainerDeployed))
330 assert.False(t, handler.CanHandle(domain.EventImagePushed))
331 assert.False(t, handler.CanHandle(domain.EventConfigReload))
332}
333
334func TestContainerDeployedHandler_Handle_InvalidatesCache(t *testing.T) {
335 runtime := outmocks.NewMockContainerRuntime(t)
336 containerSvc := inmocks.NewMockContainerService(t)
337 configSvc := inmocks.NewMockConfigService(t)
338
339 svc := NewService(runtime, containerSvc, configSvc, Config{})
340 ctx := testContext()
341
342 // Pre-populate cache
343 svc.targets["app.example.com"] = &domain.ProxyTarget{
344 Host: "192.168.1.100",
345 Port: 8080,
346 ContainerID: "old-container",
347 }
348
349 // Create handler with service as invalidator
350 handler := NewContainerDeployedHandler(ctx, svc)
351
352 // Simulate container deployed event
353 event := domain.Event{
354 ID: "event-123",
355 Type: domain.EventContainerDeployed,
356 Route: "app.example.com",
357 Data: &domain.ContainerEventPayload{
358 ContainerID: "new-container",
359 Domain: "app.example.com",
360 },
361 }
362
363 err := handler.Handle(event)
364
365 assert.NoError(t, err)
366
367 // Verify target was invalidated
368 svc.mu.RLock()
369 _, exists := svc.targets["app.example.com"]
370 svc.mu.RUnlock()
371
372 assert.False(t, exists, "cache should be invalidated after container deployed event")
373}
374
375func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
376 runtime := outmocks.NewMockContainerRuntime(t)
377 containerSvc := inmocks.NewMockContainerService(t)
378 configSvc := inmocks.NewMockConfigService(t)
379
380 svc := NewService(runtime, containerSvc, configSvc, Config{})
381 ctx := testContext()
382
383 handler := NewContainerDeployedHandler(ctx, svc)
384
385 // Event with no domain
386 event := domain.Event{
387 ID: "event-123",
388 Type: domain.EventContainerDeployed,
389 Data: &domain.ContainerEventPayload{
390 ContainerID: "new-container",
391 },
392 }
393
394 // Should not error, just skip
395 err := handler.Handle(event)
396 assert.NoError(t, err)
397}