1package app
2
3import (
4 "context"
5 "net"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
12 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13 "github.com/bnema/gordon/internal/boundaries/out"
14 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/internal/usecase/apptraffic"
16 servicecfg "github.com/bnema/gordon/internal/usecase/services"
17 "github.com/bnema/gordon/internal/usecase/traffic"
18)
19
20func TestTrafficRuntimeGraphKeepsSmartTCPHTTPAndTLSPassthroughRouters(t *testing.T) {
21 graph := domain.TrafficGraph{
22 EntryPoints: []domain.EntryPoint{
23 {Name: traffic.DefaultEdgeEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolSmartTCP, RawFallback: "ssh", RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"}},
24 },
25 Routers: []domain.TrafficRouter{
26 {Name: "app", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
27 {Name: "tls-raw", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"},
28 {Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTCP, Service: "network_service:ssh:ssh"},
29 },
30 Services: []domain.TrafficService{
31 {Name: "route:app.example.com"},
32 {Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}},
33 {Name: "network_service:ssh:ssh", Backends: []domain.TrafficBackend{{Name: "ssh:ssh", Host: "127.0.0.1", Port: 22, Protocol: domain.NetworkProtocolTCP}}},
34 },
35 }
36
37 filtered, err := trafficRuntimeGraph(graph)
38 require.NoError(t, err)
39 assert.ElementsMatch(t, []string{traffic.DefaultEdgeEntryPointName}, entryPointNames(filtered.EntryPoints))
40 assert.ElementsMatch(t, []string{"app", "tls-raw", "ssh"}, routerNames(filtered.Routers))
41 assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:raw:tls", "network_service:ssh:ssh"}, serviceNames(filtered.Services))
42}
43
44func TestTrafficRuntimeGraphHTTPEntrypointIsInvalidRatherThanSilentlyFiltered(t *testing.T) {
45 graph := domain.TrafficGraph{
46 EntryPoints: []domain.EntryPoint{{Name: "edge", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolHTTP}},
47 Routers: []domain.TrafficRouter{{Name: "app", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"}},
48 Services: []domain.TrafficService{{Name: "route:app.example.com"}},
49 }
50
51 require.ErrorContains(t, graph.Validate(), "unsupported traffic entrypoint protocol \"http\"")
52}
53
54func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) {
55 graph := domain.TrafficGraph{
56 EntryPoints: []domain.EntryPoint{
57 {Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},
58 {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP},
59 },
60 Routers: []domain.TrafficRouter{
61 {Name: "app-secure", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
62 {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"},
63 },
64 Services: []domain.TrafficService{
65 {Name: "route:app.example.com"},
66 {Name: "network_service:postgres:db", Backends: []domain.TrafficBackend{{Name: "db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP}}},
67 },
68 }
69
70 filtered, err := trafficRuntimeGraph(graph)
71 require.NoError(t, err)
72 assert.ElementsMatch(t, []string{"websecure", "postgres"}, entryPointNames(filtered.EntryPoints))
73 assert.ElementsMatch(t, []string{"app-secure", "postgres"}, routerNames(filtered.Routers))
74 assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:postgres:db"}, serviceNames(filtered.Services))
75}
76
77func TestTrafficRuntimeGraphAllowsTLSPassthroughOnDefaultTLS(t *testing.T) {
78 graph := domain.TrafficGraph{
79 EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},
80 Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: "websecure", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},
81 Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}},
82 }
83
84 filtered, err := trafficRuntimeGraph(graph)
85 require.NoError(t, err)
86 require.Len(t, filtered.EntryPoints, 1)
87 assert.Equal(t, "websecure", filtered.EntryPoints[0].Name)
88 require.Len(t, filtered.Routers, 1)
89 assert.Equal(t, "raw", filtered.Routers[0].Name)
90}
91
92// stubHosts builds an ACTIVE-derived host source for traffic tests.
93func stubHosts(domains ...string) *stubAppRoutes {
94 hosts := make([]out.AppHost, 0, len(domains))
95 for _, d := range domains {
96 hosts = append(hosts, out.AppHost{Host: d})
97 }
98 return &stubAppRoutes{hosts: hosts}
99}
100
101func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy(t *testing.T) {
102 manager := trafficadapter.NewManager()
103 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
104 cfg := Config{
105 EntryPoints: map[string]traffic.EntryPointConfig{
106 traffic.DefaultEdgeEntryPointName: {
107 Address: freeTCPAddress(t),
108 Protocol: domain.EntryPointProtocolSmartTCP,
109 RawFallback: "ssh",
110 RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"},
111 },
112 },
113 NetworkServices: []traffic.NetworkServiceConfig{{
114 Name: "ssh",
115 Ports: []traffic.PortConfig{{Name: "ssh", Container: 22, Protocol: domain.NetworkProtocolTCP}},
116 }},
117 }
118 cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Service: "network_service:ssh:ssh"}}
119
120 configSvc := inmocks.NewMockConfigService(t)
121 configSvc.EXPECT().GetExternalRoutes().Return(nil)
122
123 require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc, stubHosts("app.example.com")))
124 status := manager.Status()
125 require.Len(t, status.EntryPoints, 1)
126 assert.Equal(t, traffic.DefaultEdgeEntryPointName, status.EntryPoints[0].Name)
127 assert.True(t, status.EntryPoints[0].Active)
128 assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol)
129}
130
131func TestApplyTrafficRuntimeConfigPassesStandaloneServicesToBuilder(t *testing.T) {
132 manager := trafficadapter.NewManager()
133 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
134 cfg := Config{
135 EntryPoints: map[string]traffic.EntryPointConfig{
136 "rust": {Address: freeUDPAddress(t), Protocol: domain.EntryPointProtocolUDP},
137 },
138 Services: []servicecfg.Config{{
139 Name: "rust",
140 Image: "localhost/rust:latest",
141 Enabled: true,
142 Ports: []servicecfg.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
143 }},
144 }
145 cfg.Traffic.UDP.Routers = []traffic.RouterConfig{{Name: "rust-game", EntryPoint: "rust", Service: "service:rust:game"}}
146
147 configSvc := inmocks.NewMockConfigService(t)
148 configSvc.EXPECT().GetExternalRoutes().Return(nil)
149
150 require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc, stubHosts()))
151 status := manager.Status()
152 require.Len(t, status.EntryPoints, 1)
153 assert.Equal(t, "rust", status.EntryPoints[0].Name)
154 assert.True(t, status.EntryPoints[0].Active)
155}
156
157func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
158 manager := trafficadapter.NewManager()
159 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
160 cfg := Config{
161 EntryPoints: map[string]traffic.EntryPointConfig{
162 "postgres": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP},
163 },
164 NetworkServices: []traffic.NetworkServiceConfig{{
165 Name: "postgres",
166 Ports: []traffic.PortConfig{{Name: "db", Container: 5432, Protocol: domain.NetworkProtocolTCP}},
167 }},
168 }
169 cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "postgres", EntryPoint: "postgres", Service: "network_service:postgres:db"}}
170
171 configSvc := inmocks.NewMockConfigService(t)
172 configSvc.EXPECT().GetExternalRoutes().Return(nil)
173
174 require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc, stubHosts()))
175 status := manager.Status()
176 require.Len(t, status.EntryPoints, 1)
177 assert.Equal(t, "postgres", status.EntryPoints[0].Name)
178 assert.True(t, status.EntryPoints[0].Active)
179}
180
181// stubL4Hosts is an ACTIVE-derived source with resolved TCP+UDP entries.
182type stubL4Hosts struct {
183 stubAppRoutes
184 entries []apptraffic.RouteEntry
185}
186
187func (s *stubL4Hosts) L4Entries() []apptraffic.RouteEntry { return s.entries }
188
189// TestApplyTrafficRuntimeConfigFusesAppL4Routes proves ACTIVE TCP+UDP
190// projections join the applied graph as validated router/service pairs
191// alongside historical routes, with exact-protocol loopback backends.
192func TestApplyTrafficRuntimeConfigFusesAppL4Routes(t *testing.T) {
193 manager := trafficadapter.NewManager()
194 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
195 tcpAddress := freeTCPAddress(t)
196 udpAddress := freeUDPAddress(t)
197 tcpHost, tcpPort, err := domain.SplitListenerAddress(tcpAddress)
198 require.NoError(t, err)
199 udpHost, udpPort, err := domain.SplitListenerAddress(udpAddress)
200 require.NoError(t, err)
201 cfg := Config{
202 EntryPoints: map[string]traffic.EntryPointConfig{
203 "tcp": {Address: tcpAddress, Protocol: domain.EntryPointProtocolTCP},
204 "udp": {Address: udpAddress, Protocol: domain.EntryPointProtocolUDP},
205 },
206 }
207 hosts := &stubL4Hosts{
208 entries: []apptraffic.RouteEntry{
209 {
210 Kind: "tcp", RouterName: "app-game--server--tcp-9000", Entrypoint: "tcp",
211 BindIP: tcpHost, BindPort: tcpPort,
212 App: "game", Service: "server",
213 Backend: domain.AppBackend{Host: "127.0.0.1", Port: 19000, ContainerPort: 9000, ContainerID: "c-game"},
214 },
215 {
216 Kind: "udp", RouterName: "app-game--server--udp-9000", Entrypoint: "udp",
217 BindIP: udpHost, BindPort: udpPort,
218 App: "game", Service: "server",
219 Backend: domain.AppBackend{Host: "127.0.0.1", Port: 19001, ContainerPort: 9000, ContainerID: "c-game"},
220 },
221 },
222 }
223
224 configSvc := inmocks.NewMockConfigService(t)
225 configSvc.EXPECT().GetExternalRoutes().Return(nil)
226
227 require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc, hosts))
228 status := manager.Status()
229 assert.Equal(t, "ok", status.LastReloadStatus)
230 routers := map[string]domain.TrafficRouterStatus{}
231 for _, router := range status.Routers {
232 routers[router.Name] = router
233 }
234 require.Contains(t, routers, "app-game--server--tcp-9000")
235 require.Contains(t, routers, "app-game--server--udp-9000")
236 assert.Equal(t, domain.RouterProtocolTCP, routers["app-game--server--tcp-9000"].Protocol)
237 assert.Equal(t, domain.RouterProtocolUDP, routers["app-game--server--udp-9000"].Protocol)
238 assert.True(t, routers["app-game--server--tcp-9000"].Active)
239 assert.True(t, routers["app-game--server--udp-9000"].Active)
240 services := map[string]domain.TrafficServiceStatus{}
241 for _, service := range status.Services {
242 services[service.Name] = service
243 }
244 tcpSvc, ok := services["service:game--server:tcp-9000"]
245 require.True(t, ok)
246 require.Len(t, tcpSvc.Backends, 1)
247 assert.Equal(t, "127.0.0.1", tcpSvc.Backends[0].Host)
248 assert.Equal(t, 19000, tcpSvc.Backends[0].Port)
249 assert.Equal(t, domain.NetworkProtocolTCP, tcpSvc.Backends[0].Protocol)
250 udpSvc, ok := services["service:game--server:udp-9000"]
251 require.True(t, ok)
252 require.Len(t, udpSvc.Backends, 1)
253 assert.Equal(t, "127.0.0.1", udpSvc.Backends[0].Host)
254 assert.Equal(t, 19001, udpSvc.Backends[0].Port)
255 assert.Equal(t, domain.NetworkProtocolUDP, udpSvc.Backends[0].Protocol)
256}
257
258// TestApplyTrafficRuntimeConfigRejectsMismatchedAppL4Bind proves the graph
259// stage refuses a projected bind that differs from the entrypoint listener,
260// so a mismatched declaration can never be silently widened.
261func TestApplyTrafficRuntimeConfigRejectsMismatchedAppL4Bind(t *testing.T) {
262 manager := trafficadapter.NewManager()
263 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
264 tcpAddress := freeTCPAddress(t)
265 cfg := Config{
266 EntryPoints: map[string]traffic.EntryPointConfig{
267 "tcp": {Address: tcpAddress, Protocol: domain.EntryPointProtocolTCP},
268 },
269 }
270 hosts := &stubL4Hosts{
271 entries: []apptraffic.RouteEntry{{
272 Kind: "tcp", RouterName: "app-game--server--tcp-9000", Entrypoint: "tcp",
273 BindIP: "127.0.0.1", BindPort: 1,
274 App: "game", Service: "server",
275 Backend: domain.AppBackend{Host: "127.0.0.1", Port: 19000, ContainerPort: 9000, ContainerID: "c-game"},
276 }},
277 }
278 configSvc := inmocks.NewMockConfigService(t)
279 configSvc.EXPECT().GetExternalRoutes().Return(nil)
280
281 err := applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc, hosts)
282 require.Error(t, err)
283}
284
285func entryPointNames(entries []domain.EntryPoint) []string {
286 names := make([]string, 0, len(entries))
287 for _, entry := range entries {
288 names = append(names, entry.Name)
289 }
290 return names
291}
292
293func routerNames(routers []domain.TrafficRouter) []string {
294 names := make([]string, 0, len(routers))
295 for _, router := range routers {
296 names = append(names, router.Name)
297 }
298 return names
299}
300
301func serviceNames(services []domain.TrafficService) []string {
302 names := make([]string, 0, len(services))
303 for _, service := range services {
304 names = append(names, service.Name)
305 }
306 return names
307}
308
309func freeTCPAddress(t *testing.T) string {
310 t.Helper()
311 listener, err := net.Listen("tcp", "127.0.0.1:0")
312 require.NoError(t, err)
313 address := listener.Addr().String()
314 require.NoError(t, listener.Close())
315 return address
316}
317
318func freeUDPAddress(t *testing.T) string {
319 t.Helper()
320 packetConn, err := net.ListenPacket("udp", "127.0.0.1:0")
321 require.NoError(t, err)
322 address := packetConn.LocalAddr().String()
323 require.NoError(t, packetConn.Close())
324 return address
325}