package haproxy import ( "strings" "testing" "github.com/wild-cloud/wild-central/internal/domains" ) func TestNewManager_DefaultPath(t *testing.T) { m := NewManager("") if m.GetConfigPath() != "/etc/haproxy/haproxy.cfg" { t.Errorf("got %q, want default path", m.GetConfigPath()) } } func TestNewManager_CustomPath(t *testing.T) { m := NewManager("/tmp/haproxy.cfg") if m.GetConfigPath() != "/tmp/haproxy.cfg" { t.Errorf("got %q, want /tmp/haproxy.cfg", m.GetConfigPath()) } } func TestSanitizeName(t *testing.T) { cases := []struct { in string want string }{ {"payne-cloud", "payne_cloud"}, {"cloud.payne.io", "cloud_payne_io"}, {"civil_ssh", "civil_ssh"}, {"test cloud 1", "test_cloud_1"}, {"abc123", "abc123"}, } for _, c := range cases { if got := sanitizeName(c.in); got != c.want { t.Errorf("sanitizeName(%q) = %q, want %q", c.in, got, c.want) } } } func TestGetListenPorts_BasePorts(t *testing.T) { m := NewManager("") ports := m.GetListenPorts(nil) want := map[int]bool{80: true, 443: true, 8404: true} for _, p := range ports { delete(want, p) } if len(want) > 0 { t.Errorf("missing base ports: %v", want) } } func TestGetListenPorts_IncludesCustom(t *testing.T) { m := NewManager("") custom := []CustomRoute{{Name: "ssh", Port: 2222, Backend: "192.168.1.10:22"}} ports := m.GetListenPorts(custom) found := false for _, p := range ports { if p == 2222 { found = true } } if !found { t.Errorf("custom port 2222 not in listen ports: %v", ports) } } func TestGenerate_AlwaysIncludesBoilerplate(t *testing.T) { m := NewManager("") out := m.Generate(nil, nil) for _, want := range []string{ "global\n", "defaults\n", "frontend stats\n", "frontend http_in\n", "redirect scheme https code 301", "bind *:8404", } { if !strings.Contains(out, want) { t.Errorf("expected %q in output, got:\n%s", want, out) } } } func TestGenerate_NoInstances_NoHTTPSFrontend(t *testing.T) { m := NewManager("") out := m.Generate(nil, nil) if strings.Contains(out, "frontend https_in") { t.Errorf("https_in frontend should be absent when no instances configured") } } func TestGenerate_WithInstance_SNIACLs(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, } out := m.Generate(instances, nil) // With subdomains:true, both ACL lines for OR-matching if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") { t.Errorf("expected subdomain SNI ACL, got:\n%s", out) } if !strings.Contains(out, "req_ssl_sni -m str cloud.payne.io") { t.Errorf("expected exact SNI ACL, got:\n%s", out) } if !strings.Contains(out, "use_backend be_https_payne_cloud if is_payne_cloud") { t.Errorf("expected use_backend directive, got:\n%s", out) } } func TestGenerate_WithInstance_Backend(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"}, } out := m.Generate(instances, nil) if !strings.Contains(out, "backend be_https_payne_cloud") { t.Errorf("expected backend block, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.8.20:443 check") { t.Errorf("expected server line with LB IP, got:\n%s", out) } } func TestGenerate_MultipleInstances(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"}, {Name: "test-cloud", Domain: "cloud2.payne.io", BackendIP: "192.168.8.30"}, } out := m.Generate(instances, nil) if !strings.Contains(out, "be_https_payne_cloud") { t.Errorf("expected payne-cloud backend, got:\n%s", out) } if !strings.Contains(out, "be_https_test_cloud") { t.Errorf("expected test-cloud backend, got:\n%s", out) } if !strings.Contains(out, "192.168.8.20:443") { t.Errorf("expected payne-cloud IP, got:\n%s", out) } if !strings.Contains(out, "192.168.8.30:443") { t.Errorf("expected test-cloud IP, got:\n%s", out) } } func TestGenerate_WithSubdomains(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false}, } out := m.Generate(instances, nil) // cloud.payne.io with subdomains:true should have wildcard ACL if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") { t.Errorf("expected wildcard ACL for cloud.payne.io, got:\n%s", out) } // payne.io with subdomains:false should have exact match only if !strings.Contains(out, "req_ssl_sni -m str payne.io") { t.Errorf("expected exact ACL for payne.io, got:\n%s", out) } // payne.io should NOT have wildcard match if strings.Contains(out, "req_ssl_sni -m end .payne.io") { t.Errorf("unexpected wildcard ACL for payne.io (subdomains:false), got:\n%s", out) } } func TestGenerate_ACLOrdering(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.240", Subdomains: true}, } httpRoutes := []HTTPRoute{ {Name: "wild-central", Domain: "central.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:15055"}}}, {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:5055"}}}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) // L7 exact matches must appear BEFORE L4 wildcard matches l7Pos := strings.Index(out, "is_l7_wild_central") l4Pos := strings.Index(out, "is_payne_cloud") if l7Pos < 0 { t.Fatalf("expected L7 ACL for wild-central, got:\n%s", out) } if l4Pos < 0 { t.Fatalf("expected L4 ACL for payne-cloud, got:\n%s", out) } if l7Pos > l4Pos { t.Errorf("L7 exact match (pos %d) must come before L4 wildcard (pos %d):\n%s", l7Pos, l4Pos, out) } // wild-cloud.payne.io should NOT match payne-cloud's wildcard // (it should be caught by the L7 exact match first) if strings.Contains(out, "use_backend be_https_payne_cloud if is_l7_wild_cloud") { t.Errorf("wild-cloud.payne.io should route to L7, not payne-cloud") } } func TestGenerate_CustomRoute(t *testing.T) { m := NewManager("") custom := []CustomRoute{ {Name: "civil_ssh", Port: 2222, Backend: "192.168.8.10:22"}, } out := m.Generate(nil, custom) if !strings.Contains(out, "frontend fe_civil_ssh") { t.Errorf("expected custom frontend, got:\n%s", out) } if !strings.Contains(out, "bind *:2222") { t.Errorf("expected bind on port 2222, got:\n%s", out) } if !strings.Contains(out, "backend be_civil_ssh") { t.Errorf("expected custom backend, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.8.10:22") { t.Errorf("expected server line with backend address, got:\n%s", out) } } // --- L7 HTTP route tests --- func TestGenerateWithOpts_HTTPRoutes_L7Frontend(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ {Name: "my-api", Domain: "my-api.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:9001", HealthPath: "/health"}}}, {Name: "dashboard", Domain: "dashboard.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:8080"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{ HTTPRoutes: httpRoutes, CertsDir: "/etc/haproxy/certs/", }) // L7 TLS termination frontend should exist if !strings.Contains(out, "frontend l7_https") { t.Errorf("expected L7 frontend, got:\n%s", out) } if !strings.Contains(out, "ssl crt /etc/haproxy/certs/") { t.Errorf("expected certs dir in L7 bind, got:\n%s", out) } // Host-based ACLs if !strings.Contains(out, "hdr(host) -i my-api.payne.io") { t.Errorf("expected Host ACL for my-api, got:\n%s", out) } if !strings.Contains(out, "hdr(host) -i dashboard.payne.io") { t.Errorf("expected Host ACL for dashboard, got:\n%s", out) } // L7 backends if !strings.Contains(out, "backend be_l7_my_api_0") { t.Errorf("expected L7 backend for my-api, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.8.60:9001 check") { t.Errorf("expected my-api server with health check, got:\n%s", out) } if !strings.Contains(out, "option httpchk GET /health") { t.Errorf("expected httpchk for my-api, got:\n%s", out) } // Dashboard backend (no health check) if !strings.Contains(out, "backend be_l7_dashboard_0") { t.Errorf("expected L7 backend for dashboard, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.8.60:8080") { t.Errorf("expected dashboard server line, got:\n%s", out) } } func TestGenerateWithOpts_MixedL4AndL7(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.100"}, } httpRoutes := []HTTPRoute{ {Name: "my-api", Domain: "my-api.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:9001"}}}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{ HTTPRoutes: httpRoutes, }) // Both L4 SNI route and L7 route should be in the same config if !strings.Contains(out, "use_backend be_https_payne_cloud if is_payne_cloud") { t.Errorf("expected L4 instance route, got:\n%s", out) } if !strings.Contains(out, "frontend l7_https") { t.Errorf("expected L7 frontend, got:\n%s", out) } if !strings.Contains(out, "backend be_l7_my_api_0") { t.Errorf("expected L7 backend for my-api, got:\n%s", out) } // L7 fallback should be present if !strings.Contains(out, "default_backend be_l7_termination") { t.Errorf("expected default_backend for L7 termination, got:\n%s", out) } } func TestGenerateWithOpts_ACLOrdering_L7BeforeL4Wildcard(t *testing.T) { m := NewManager("") // Scenario: cloud.payne.io with subdomains:true is an L4 wildcard route. // wild-cloud.payne.io is an L7 HTTP service. The L7 exact match MUST // appear before the L4 wildcard, otherwise HAProxy matches // wild-cloud.payne.io against *.payne.io and sends it to the wrong backend. instances := []L4Route{ {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.240", Subdomains: true}, } httpRoutes := []HTTPRoute{ {Name: "wild-central", Domain: "central.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:15055"}}}, {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:5055"}}}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) // All L7 exact matches must appear before L4 wildcard matches l7Central := strings.Index(out, "is_l7_wild_central") l7WildCloud := strings.Index(out, "is_l7_wild_cloud") l4Wildcard := strings.Index(out, "req_ssl_sni -m end .payne.io") if l7Central < 0 { t.Fatalf("expected L7 ACL for wild-central, got:\n%s", out) } if l7WildCloud < 0 { t.Fatalf("expected L7 ACL for wild-cloud, got:\n%s", out) } if l4Wildcard < 0 { t.Fatalf("expected L4 wildcard ACL for payne.io, got:\n%s", out) } if l7Central > l4Wildcard { t.Errorf("L7 central.payne.io (pos %d) must appear before L4 *.payne.io wildcard (pos %d)", l7Central, l4Wildcard) } if l7WildCloud > l4Wildcard { t.Errorf("L7 wild-cloud.payne.io (pos %d) must appear before L4 *.payne.io wildcard (pos %d)", l7WildCloud, l4Wildcard) } // Verify the config order overall: L7 exact → L4 wildcard → default defaultBackend := strings.Index(out, "default_backend be_l7_termination") if defaultBackend < 0 { t.Fatalf("expected default_backend for L7 termination, got:\n%s", out) } if l4Wildcard > defaultBackend { t.Errorf("L4 wildcard (pos %d) must appear before default_backend (pos %d)", l4Wildcard, defaultBackend) } } func TestGenerateWithOpts_SubdomainsFalse_ExactOnly(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) // subdomains:false must produce ONLY -m str (exact), NOT -m end (wildcard) if !strings.Contains(out, "req_ssl_sni -m str payne.io") { t.Errorf("expected exact match ACL for payne.io, got:\n%s", out) } if strings.Contains(out, "req_ssl_sni -m end .payne.io") { t.Errorf("subdomains:false must NOT produce wildcard ACL, got:\n%s", out) } } func TestGenerateWithOpts_SubdomainsTrue_WildcardAndExact(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "cloud-payne", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) // subdomains:true must produce BOTH -m end (wildcard) AND -m str (exact) if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") { t.Errorf("subdomains:true must produce wildcard ACL (-m end), got:\n%s", out) } if !strings.Contains(out, "req_ssl_sni -m str cloud.payne.io") { t.Errorf("subdomains:true must also produce exact ACL (-m str), got:\n%s", out) } // Both ACLs should use the same backend if !strings.Contains(out, "use_backend be_https_cloud_payne if is_cloud_payne") { t.Errorf("expected use_backend for cloud_payne, got:\n%s", out) } } func TestGenerateWithOpts_ACLOrdering_L4ExactBeforeL4Wildcard(t *testing.T) { m := NewManager("") // payne.io exact (subdomains:false) must appear before cloud.payne.io wildcard (subdomains:true) instances := []L4Route{ {Name: "cloud-payne", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) exactPos := strings.Index(out, "req_ssl_sni -m str payne.io") wildcardPos := strings.Index(out, "req_ssl_sni -m end .cloud.payne.io") if exactPos < 0 { t.Fatalf("expected exact match ACL for payne.io, got:\n%s", out) } if wildcardPos < 0 { t.Fatalf("expected wildcard ACL for cloud.payne.io, got:\n%s", out) } if exactPos > wildcardPos { t.Errorf("L4 exact match (pos %d) must come before L4 wildcard (pos %d):\n%s", exactPos, wildcardPos, out) } } func TestGenerateWithOpts_BackwardCompatible(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "test", Domain: "test.example.com", BackendIP: "10.0.0.1"}, } // Using old Generate function should still work out := m.Generate(instances, nil) if !strings.Contains(out, "be_https_test") { t.Errorf("backward compat: expected instance backend, got:\n%s", out) } // No L7 frontend when no HTTP routes if strings.Contains(out, "frontend l7_https") { t.Errorf("backward compat: unexpected L7 frontend, got:\n%s", out) } } func TestGenerateWithOpts_NoRoutes_NoHTTPSFrontend(t *testing.T) { m := NewManager("") // No instances, no HTTP routes out := m.GenerateWithOpts(nil, nil, GenerateOpts{}) if strings.Contains(out, "frontend https_in") { t.Errorf("expected no HTTPS frontend with empty instances and empty HTTP routes, got:\n%s", out) } if strings.Contains(out, "frontend l7_https") { t.Errorf("expected no L7 frontend with no HTTP routes, got:\n%s", out) } } func TestGenerateWithOpts_OnlyHTTPRoutes(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ {Name: "my-api", Domain: "api.example.com", Routes: []HTTPRouteBackend{{Backend: "192.168.1.10:9001"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) // HTTPS frontend should be present (needed for SNI dispatch to L7) if !strings.Contains(out, "frontend https_in") { t.Errorf("expected HTTPS frontend when HTTP routes present, got:\n%s", out) } // L7 frontend should be present if !strings.Contains(out, "frontend l7_https") { t.Errorf("expected L7 frontend for HTTP routes, got:\n%s", out) } // L7 backend should be present if !strings.Contains(out, "backend be_l7_my_api_0") { t.Errorf("expected L7 backend for my-api, got:\n%s", out) } // No L4 instance backends if strings.Contains(out, "be_https_") { t.Errorf("unexpected L4 instance backend when only HTTP routes, got:\n%s", out) } } func TestGenerateWithOpts_OnlyL4Routes(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "cloud", Domain: "cloud.example.com", BackendIP: "10.0.0.1", Subdomains: true}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) // HTTPS frontend should exist for L4 SNI routing if !strings.Contains(out, "frontend https_in") { t.Errorf("expected HTTPS frontend for L4 routes, got:\n%s", out) } // L4 backend should exist if !strings.Contains(out, "backend be_https_cloud") { t.Errorf("expected L4 backend, got:\n%s", out) } // No L7 frontend when no HTTP routes if strings.Contains(out, "frontend l7_https") { t.Errorf("unexpected L7 frontend with only L4 routes, got:\n%s", out) } // No L7 termination backend if strings.Contains(out, "be_l7_termination") { t.Errorf("unexpected L7 termination backend with only L4 routes, got:\n%s", out) } } func TestGenerateWithOpts_CertsDir(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ {Name: "app", Domain: "app.example.com", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:8080"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{ HTTPRoutes: httpRoutes, CertsDir: "/opt/certs/wildcard/", }) if !strings.Contains(out, "ssl crt /opt/certs/wildcard/") { t.Errorf("expected custom CertsDir in L7 bind, got:\n%s", out) } } func TestGenerateWithOpts_DefaultCertsDir(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ {Name: "app", Domain: "app.example.com", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:8080"}}}, } // Empty CertsDir should default to /etc/haproxy/certs/ out := m.GenerateWithOpts(nil, nil, GenerateOpts{ HTTPRoutes: httpRoutes, CertsDir: "", }) if !strings.Contains(out, "ssl crt /etc/haproxy/certs/") { t.Errorf("expected default CertsDir /etc/haproxy/certs/ in L7 bind, got:\n%s", out) } } func TestGenerate_CustomRoutePorts(t *testing.T) { m := NewManager("") custom := []CustomRoute{ {Name: "ssh", Port: 2222, Backend: "192.168.1.10:22"}, {Name: "mqtt", Port: 1883, Backend: "192.168.1.20:1883"}, } out := m.Generate(nil, custom) if !strings.Contains(out, "bind *:2222") { t.Errorf("expected bind on port 2222, got:\n%s", out) } if !strings.Contains(out, "bind *:1883") { t.Errorf("expected bind on port 1883, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.1.10:22") { t.Errorf("expected SSH backend, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.1.20:1883") { t.Errorf("expected MQTT backend, got:\n%s", out) } } func TestGenerateWithOpts_HealthCheckInL7Backend(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ {Name: "api", Domain: "api.example.com", Routes: []HTTPRouteBackend{{Backend: "10.0.0.5:8080", HealthPath: "/healthz"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) if !strings.Contains(out, "option httpchk GET /healthz") { t.Errorf("expected httpchk directive with health path, got:\n%s", out) } if !strings.Contains(out, "server s0 10.0.0.5:8080 check") { t.Errorf("expected server line with 'check' flag for health check, got:\n%s", out) } } // --- L7 extended features tests (paths, headers, IP whitelisting) --- func TestGenerateWithOpts_PathRestriction(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ { Name: "synapse-fed", Domain: "payne.io", Routes: []HTTPRouteBackend{{ Backend: "192.168.8.80:80", Paths: []string{"/.well-known/matrix", "/_matrix"}, }}, }, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) if !strings.Contains(out, "acl path_synapse_fed_0 path_beg /.well-known/matrix /_matrix") { t.Errorf("expected path ACL for synapse-fed, got:\n%s", out) } if !strings.Contains(out, "use_backend be_l7_synapse_fed_0 if host_synapse_fed path_synapse_fed_0") { t.Errorf("expected path-restricted use_backend, got:\n%s", out) } } func TestGenerateWithOpts_ResponseHeaders(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ { Name: "plausible", Domain: "plausible.cloud.payne.io", Routes: []HTTPRouteBackend{{ Backend: "192.168.8.80:80", Headers: &domains.HeaderConfig{ Response: map[string]string{ "Access-Control-Allow-Private-Network": "true", }, }, }}, }, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) if !strings.Contains(out, `http-response set-header Access-Control-Allow-Private-Network "true" if host_plausible`) { t.Errorf("expected response header rule, got:\n%s", out) } } func TestGenerateWithOpts_RequestHeaders(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ { Name: "my-api", Domain: "api.payne.io", Routes: []HTTPRouteBackend{{ Backend: "192.168.8.80:80", Headers: &domains.HeaderConfig{ Request: map[string]string{ "X-Forwarded-Proto": "https", }, }, }}, }, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) if !strings.Contains(out, `http-request set-header X-Forwarded-Proto "https" if host_my_api`) { t.Errorf("expected request header rule, got:\n%s", out) } } func TestGenerateWithOpts_IPWhitelist(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ { Name: "headlamp", Domain: "headlamp.internal.cloud.payne.io", Routes: []HTTPRouteBackend{{ Backend: "192.168.8.80:80", IPAllow: []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}, }}, }, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) if !strings.Contains(out, "acl ip_headlamp_0 src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16") { t.Errorf("expected IP ACL for headlamp, got:\n%s", out) } if !strings.Contains(out, "http-request deny if host_headlamp !ip_headlamp_0") { t.Errorf("expected IP deny rule for headlamp, got:\n%s", out) } } func TestGenerateWithOpts_PathRoutingOrder(t *testing.T) { m := NewManager("") // Path-restricted routes should appear BEFORE unrestricted routes // In the new model, multiple backends for the same domain go in one HTTPRoute httpRoutes := []HTTPRoute{ {Name: "app", Domain: "payne.io", Routes: []HTTPRouteBackend{ {Backend: "192.168.8.80:80", Paths: []string{"/_matrix"}}, {Backend: "192.168.8.80:80"}, }}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) pathPos := strings.Index(out, "use_backend be_l7_app_0 if") hostPos := strings.Index(out, "use_backend be_l7_app_1 if") if pathPos < 0 { t.Fatalf("expected path-restricted use_backend for route 0, got:\n%s", out) } if hostPos < 0 { t.Fatalf("expected unrestricted use_backend for route 1, got:\n%s", out) } if pathPos > hostPos { t.Errorf("path-restricted routes (pos %d) must appear before unrestricted (pos %d):\n%s", pathPos, hostPos, out) } } func TestGenerateWithOpts_NoL7Fields_BackwardCompat(t *testing.T) { m := NewManager("") // Route with no paths/headers/ipAllow should work exactly as before httpRoutes := []HTTPRoute{ {Name: "simple", Domain: "simple.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.80:80"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) if !strings.Contains(out, "acl host_simple hdr(host) -i simple.payne.io") { t.Errorf("expected host ACL, got:\n%s", out) } if !strings.Contains(out, "use_backend be_l7_simple_0 if host_simple") { t.Errorf("expected simple use_backend, got:\n%s", out) } // Should NOT have path, IP, or header rules if strings.Contains(out, "path_beg") { t.Errorf("unexpected path ACL in simple route, got:\n%s", out) } if strings.Contains(out, "http-request deny") { t.Errorf("unexpected IP deny in simple route, got:\n%s", out) } if strings.Contains(out, "http-response set-header") { t.Errorf("unexpected response header in simple route, got:\n%s", out) } } // --- Service marker and validation error tests --- func TestGenerateWithOpts_ServiceMarkers(t *testing.T) { m := NewManager("") instances := []L4Route{ {Name: "cloud", Domain: "cloud.example.com", BackendIP: "10.0.0.1", Subdomains: true}, } httpRoutes := []HTTPRoute{ {Name: "api", Domain: "api.example.com", Routes: []HTTPRouteBackend{{Backend: "10.0.0.5:8080"}}}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) // L4 instance should have service marker if !strings.Contains(out, "# service: cloud.example.com") { t.Errorf("expected service marker for cloud.example.com, got:\n%s", out) } // L7 HTTP should have service marker if !strings.Contains(out, "# service: api.example.com") { t.Errorf("expected service marker for api.example.com, got:\n%s", out) } } func TestParseValidationErrors(t *testing.T) { output := `[NOTICE] (123) : haproxy version is 3.0.10 [WARNING] (123) : config : parsing [/tmp/test.cfg:42] : some warning [ALERT] (123) : config : parsing [/tmp/test.cfg:111]: 'http-response set-header' expects 'if' [ALERT] (123) : config : parsing [/tmp/test.cfg:115]: unknown keyword [ALERT] (123) : config : Error(s) found in configuration file : /tmp/test.cfg` lines := ParseValidationErrors(output) if len(lines) != 2 { t.Fatalf("expected 2 failing lines, got %d: %v", len(lines), lines) } if lines[0] != 111 { t.Errorf("expected first line 111, got %d", lines[0]) } if lines[1] != 115 { t.Errorf("expected second line 115, got %d", lines[1]) } } func TestParseValidationErrors_NoAlerts(t *testing.T) { output := `[NOTICE] (123) : haproxy version is 3.0.10 [WARNING] (123) : config : parsing [/tmp/test.cfg:42] : some warning Warnings were found.` lines := ParseValidationErrors(output) if len(lines) != 0 { t.Errorf("expected 0 failing lines for warnings-only output, got %d", len(lines)) } } func TestFindBrokenServices(t *testing.T) { config := `# boilerplate frontend l7_https bind 127.0.0.1:8443 ssl crt /etc/haproxy/certs/ mode http # service: good.example.com acl host_good hdr(host) -i good.example.com # service: bad.example.com acl host_bad hdr(host) -i bad.example.com http-response set-header X-Bad broken value if host_bad # service: also-good.example.com acl host_also_good hdr(host) -i also-good.example.com ` // Line 10 is the broken http-response line (1-indexed) broken := FindBrokenServices(config, []int{10}) if len(broken) != 1 { t.Fatalf("expected 1 broken service, got %d: %v", len(broken), broken) } if broken[0] != "bad.example.com" { t.Errorf("expected bad.example.com, got %s", broken[0]) } } func TestFindBrokenServices_MultipleLinesOneDomain(t *testing.T) { config := `line1 # service: bad.example.com line3 line4 line5 ` // Multiple lines from the same service should deduplicate broken := FindBrokenServices(config, []int{3, 4, 5}) if len(broken) != 1 { t.Fatalf("expected 1 broken service (deduped), got %d: %v", len(broken), broken) } } func TestFindBrokenServices_NoMarker(t *testing.T) { config := `line1 line2 line3 ` // No service markers — should return empty broken := FindBrokenServices(config, []int{2}) if len(broken) != 0 { t.Errorf("expected 0 broken services when no markers, got %d", len(broken)) } }