Files
wild-central/internal/haproxy/config_test.go
Paul Payne 548be903e7 feat: Fix HAProxy ACL ordering + Subdomains field
Rewrite HAProxy config generation with correct ACL ordering:
1. L7 HTTP exact matches first (central, wild-cloud app)
2. L4 exact matches (payne.io, civilsociety.dev)
3. L4 wildcard matches (*.cloud.payne.io) — only when subdomains:true
4. Default → L7 termination

Key changes:
- InstanceRoute: removed ExtraDomains, added Subdomains bool
- GenerateOpts: removed CentralDomain (Central is now just another
  HTTPRoute injected by reconciliation), renamed WildcardCert→CertsDir
- Central's domain comes from config, injected as HTTPRoute with
  the actual running port (no more hardcoded 5055)
- Added API.SetPort() so reconciliation knows the running port
- subdomains:false → exact match only (no *.domain shadowing)

New tests: TestGenerate_WithSubdomains, TestGenerate_ACLOrdering
(verifies L7 exact matches come before L4 wildcards).

Fixes: BUG 1 (central routing to wrong port), BUG 2 (wild-cloud
getting traefik cert), BUG 3 (*.payne.io too broad).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 20:29:30 +00:00

319 lines
9.8 KiB
Go

package haproxy
import (
"strings"
"testing"
)
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, 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(nil, 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 := []InstanceRoute{
{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 := []InstanceRoute{
{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 := []InstanceRoute{
{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 := []InstanceRoute{
{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 := []InstanceRoute{
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.240", Subdomains: true},
}
httpRoutes := []HTTPRoute{
{Name: "wild-central", Domain: "central.payne.io", Backend: "127.0.0.1:15055"},
{Name: "wild-cloud", Domain: "wild-cloud.payne.io", 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", Backend: "192.168.8.60:9001", HealthPath: "/health"},
{Name: "dashboard", Domain: "dashboard.payne.io", 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") {
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") {
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 := []InstanceRoute{
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.100"},
}
httpRoutes := []HTTPRoute{
{Name: "my-api", Domain: "my-api.payne.io", 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") {
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_BackwardCompatible(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{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)
}
}