package dnsmasq import ( "fmt" "strings" "testing" "github.com/wild-cloud/wild-central/internal/config" ) func instanceWith(domain, internalDomain, lbIP string) config.InstanceConfig { var inst config.InstanceConfig inst.Cluster.Name = "test" inst.Cloud.Domain = domain inst.Cloud.InternalDomain = internalDomain inst.Cluster.LoadBalancerIp = lbIP return inst } // Test: instanceLoadBalancerIP prefers cluster.loadBalancerIp func TestInstanceLoadBalancerIP_ClusterField(t *testing.T) { inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5") if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" { t.Errorf("got %q, want %q", got, "10.0.0.5") } } // Test: instanceLoadBalancerIP falls back to apps.metallb.loadBalancerIp func TestInstanceLoadBalancerIP_MetalLBFallback(t *testing.T) { var inst config.InstanceConfig inst.Apps = map[string]any{ "metallb": map[string]any{ "loadBalancerIp": "10.0.0.9", }, } if got := instanceLoadBalancerIP(inst); got != "10.0.0.9" { t.Errorf("got %q, want %q", got, "10.0.0.9") } } // Test: instanceLoadBalancerIP returns empty string when nothing is set func TestInstanceLoadBalancerIP_Empty(t *testing.T) { var inst config.InstanceConfig if got := instanceLoadBalancerIP(inst); got != "" { t.Errorf("got %q, want empty string", got) } } // Test: cluster.loadBalancerIp takes precedence over metallb func TestInstanceLoadBalancerIP_ClusterTakesPrecedence(t *testing.T) { inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5") inst.Apps = map[string]any{ "metallb": map[string]any{ "loadBalancerIp": "10.0.0.9", }, } if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" { t.Errorf("got %q, want cluster IP %q", got, "10.0.0.5") } } // Test: GenerateInstanceConfig with load balancer IP produces active DNS entries func TestGenerateInstanceConfig_WithLBIP(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10") out := g.GenerateInstanceConfig(inst) if !strings.Contains(out, "local=/internal.example.com/") { t.Errorf("expected local=/ directive for internal domain, got:\n%s", out) } if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") { t.Errorf("expected address entry for internal domain, got:\n%s", out) } if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") { t.Errorf("expected address entry for external domain, got:\n%s", out) } if strings.Contains(out, "WARNING") { t.Errorf("unexpected WARNING in output:\n%s", out) } } // Test: GenerateInstanceConfig without load balancer IP produces commented entries func TestGenerateInstanceConfig_NoLBIP(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") inst := instanceWith("cloud.example.com", "internal.example.com", "") out := g.GenerateInstanceConfig(inst) if !strings.Contains(out, "WARNING") { t.Errorf("expected WARNING in output when no LB IP, got:\n%s", out) } if !strings.Contains(out, "# local=/internal.example.com/") { t.Errorf("expected commented local=/ directive, got:\n%s", out) } if !strings.Contains(out, "# address=/internal.example.com/") { t.Errorf("expected commented address entry for internal domain, got:\n%s", out) } if !strings.Contains(out, "# address=/cloud.example.com/") { t.Errorf("expected commented address entry for external domain, got:\n%s", out) } if strings.Contains(out, "\nlocal=/") { t.Errorf("unexpected active local=/ directive when no LB IP, got:\n%s", out) } } // Test: GenerateInstanceConfig includes instance name in header comment func TestGenerateInstanceConfig_IncludesInstanceName(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10") inst.Cluster.Name = "my-instance" out := g.GenerateInstanceConfig(inst) if !strings.Contains(out, "my-instance") { t.Errorf("expected instance name in config header, got:\n%s", out) } } // Test: Generate (monolithic) with instances produces expected DNS entries func TestGenerate_WithInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10") out := g.Generate(nil, []config.InstanceConfig{inst}) if !strings.Contains(out, "local=/internal.example.com/") { t.Errorf("expected local=/ for internal domain, got:\n%s", out) } if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") { t.Errorf("expected address entry for internal domain, got:\n%s", out) } if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") { t.Errorf("expected address entry for external domain, got:\n%s", out) } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server, got:\n%s", out) } } // Test: Generate with no instances still produces valid config skeleton func TestGenerate_NoInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") out := g.Generate(nil, []config.InstanceConfig{}) if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected bind-interfaces in config, got:\n%s", out) } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server, got:\n%s", out) } if !strings.Contains(out, "server=8.8.8.8") { t.Errorf("expected upstream DNS server 8.8.8.8, got:\n%s", out) } } // Test: Generate with instance missing LB IP produces commented entries func TestGenerate_InstanceWithoutLBIP(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") inst := instanceWith("cloud.example.com", "internal.example.com", "") inst.Cluster.Name = "no-lb-instance" out := g.Generate(nil, []config.InstanceConfig{inst}) if !strings.Contains(out, "# No load balancer IP configured for instance no-lb-instance") { t.Errorf("expected comment about missing LB IP, got:\n%s", out) } } // Test: GenerateMainConfig produces conf-dir directive func TestGenerateMainConfig(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") out := g.GenerateMainConfig(nil) if !strings.Contains(out, "conf-dir=") { t.Errorf("expected conf-dir directive in main config, got:\n%s", out) } if !strings.Contains(out, instanceConfigDir) { t.Errorf("expected instance config dir %q in conf-dir, got:\n%s", instanceConfigDir, out) } if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected bind-interfaces in main config, got:\n%s", out) } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server in main config, got:\n%s", out) } } // Test: GenerateMainConfig with DHCP disabled does not include dhcp-range func TestGenerateMainConfig_DHCPDisabled(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cfg.Cloud.Dnsmasq.DHCP.Enabled = false out := g.GenerateMainConfig(cfg) if strings.Contains(out, "dhcp-range") { t.Errorf("expected no dhcp-range when DHCP disabled, got:\n%s", out) } } // Test: GenerateMainConfig with DHCP enabled includes dhcp-range func TestGenerateMainConfig_DHCPEnabled(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.LeaseTime = "12h" cfg.Cloud.Router.IP = "192.168.8.1" out := g.GenerateMainConfig(cfg) if !strings.Contains(out, "dhcp-range=192.168.8.100,192.168.8.200,12h") { t.Errorf("expected dhcp-range directive, got:\n%s", out) } if !strings.Contains(out, "dhcp-option=3,192.168.8.1") { t.Errorf("expected gateway dhcp-option=3, got:\n%s", out) } } // Test: DHCP gateway falls back to router IP when not set func TestGenerateMainConfig_DHCPGatewayFallback(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Router.IP = "192.168.8.1" // Gateway not set — should fall back to router IP out := g.GenerateMainConfig(cfg) if !strings.Contains(out, "dhcp-option=3,192.168.8.1") { t.Errorf("expected router IP as fallback gateway, got:\n%s", out) } } // Test: DHCP explicit gateway takes precedence over router IP func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.254" cfg.Cloud.Router.IP = "192.168.8.1" out := g.GenerateMainConfig(cfg) if !strings.Contains(out, "dhcp-option=3,192.168.8.254") { t.Errorf("expected explicit gateway in dhcp-option=3, got:\n%s", out) } if strings.Contains(out, "dhcp-option=3,192.168.8.1") { t.Errorf("router IP should not appear as gateway when explicit gateway set, got:\n%s", out) } } // Test: DHCP static leases appear in output func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.StaticLeases = []config.DHCPStaticLease{ {MAC: "aa:bb:cc:dd:ee:01", IP: "192.168.8.10", Hostname: "node1"}, {MAC: "aa:bb:cc:dd:ee:02", IP: "192.168.8.11"}, } out := g.GenerateMainConfig(cfg) if !strings.Contains(out, "dhcp-host=aa:bb:cc:dd:ee:01,192.168.8.10,node1") { t.Errorf("expected static lease with hostname, got:\n%s", out) } if !strings.Contains(out, "dhcp-host=aa:bb:cc:dd:ee:02,192.168.8.11") { t.Errorf("expected static lease without hostname, got:\n%s", out) } } // Test: DHCP lease time defaults to 24h when not specified func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" // LeaseTime not set out := g.GenerateMainConfig(cfg) if !strings.Contains(out, "dhcp-range=192.168.8.100,192.168.8.200,24h") { t.Errorf("expected default lease time of 24h, got:\n%s", out) } } // Test: NewConfigGenerator uses provided path func TestNewConfigGenerator_CustomPath(t *testing.T) { g := NewConfigGenerator("/custom/path/dnsmasq.conf") if g.GetConfigPath() != "/custom/path/dnsmasq.conf" { t.Errorf("got %q, want /custom/path/dnsmasq.conf", g.GetConfigPath()) } } // Test: NewConfigGenerator uses default path when empty func TestNewConfigGenerator_DefaultPath(t *testing.T) { g := NewConfigGenerator("") if g.GetConfigPath() != "/etc/dnsmasq.d/wild-cloud.conf" { t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath()) } } // Test: Service registration — domain set but no internal domain func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.GlobalConfig{} // Service registration: only domain + backend IP, no internal domain inst := instanceWith("my-api.payne.io", "", "192.168.8.151") out := g.Generate(globalCfg, []config.InstanceConfig{inst}) // Should have address entry for the domain if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { t.Errorf("expected address entry for service domain, got:\n%s", out) } // Must NOT have empty local=// entry if strings.Contains(out, "local=//") { t.Errorf("unexpected empty local=// in output:\n%s", out) } // Must NOT have empty address=// entry if strings.Contains(out, "address=//") { t.Errorf("unexpected empty address=// in output:\n%s", out) } } // Test: GenerateInstanceConfig — service without internal domain func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") inst := instanceWith("central.payne.io", "", "192.168.8.151") out := g.GenerateInstanceConfig(inst) if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") { t.Errorf("expected address entry, got:\n%s", out) } if strings.Contains(out, "local=//") { t.Errorf("unexpected empty local=// in output:\n%s", out) } if strings.Contains(out, "address=//") { t.Errorf("unexpected empty address=// in output:\n%s", out) } } // Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding) func TestGenerate_ReachInternal_HasLocal(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.GlobalConfig{} // Simulate reconciliation for an internal-reach service: // InternalDomain is set (reach:internal), Domain is empty (reach:public) inst := instanceWith("", "my-api.payne.io", "192.168.8.151") out := g.Generate(globalCfg, []config.InstanceConfig{inst}) if !strings.Contains(out, "local=/my-api.payne.io/") { t.Errorf("reach:internal must produce local=/ directive, got:\n%s", out) } if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { t.Errorf("reach:internal must produce address entry, got:\n%s", out) } } // Test: reach:public → no local=/ directive (allows upstream DNS forwarding) func TestGenerate_ReachPublic_NoLocal(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.GlobalConfig{} // Simulate reconciliation for a public-reach service: // Domain is set (reach:public), InternalDomain is empty inst := instanceWith("cloud.payne.io", "", "192.168.8.240") out := g.Generate(globalCfg, []config.InstanceConfig{inst}) if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") { t.Errorf("reach:public must produce address entry, got:\n%s", out) } if strings.Contains(out, "local=/cloud.payne.io/") { t.Errorf("reach:public must NOT produce local=/ directive, got:\n%s", out) } } // Test: InternalDomain field → local=/ entry for LAN-only resolution func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.GlobalConfig{} // Instance with only an internal domain (no external domain) inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240") out := g.Generate(globalCfg, []config.InstanceConfig{inst}) if !strings.Contains(out, "local=/internal.cloud.payne.io/") { t.Errorf("InternalDomain must produce local=/ entry, got:\n%s", out) } if !strings.Contains(out, "address=/internal.cloud.payne.io/192.168.8.240") { t.Errorf("InternalDomain must produce address entry, got:\n%s", out) } // No external domain → no address for empty domain if strings.Contains(out, "address=//") { t.Errorf("no external domain must not produce empty address=// entry, got:\n%s", out) } } // Test: Mix of instances with and without internal domains func TestGenerate_MixedServicesAndInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.GlobalConfig{} instances := []config.InstanceConfig{ instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance instanceWith("central.payne.io", "", "192.168.8.151"), // service (no internal domain) instanceWith("wild-cloud.payne.io", "", "192.168.8.151"), // service (no internal domain) } out := g.Generate(globalCfg, instances) // k8s instance should have both local= and address= for internal domain if !strings.Contains(out, "local=/internal.cloud.payne.io/") { t.Errorf("expected local=/ for k8s internal domain, got:\n%s", out) } // Services should have address= entries if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") { t.Errorf("expected address for central, got:\n%s", out) } // No broken empty entries if strings.Contains(out, "local=//") { t.Errorf("unexpected empty local=// in output:\n%s", out) } } // Test: empty instances list produces base config with no address= entries func TestGenerate_EmptyInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.GlobalConfig{} out := g.Generate(globalCfg, []config.InstanceConfig{}) // Should have base config skeleton if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected bind-interfaces in config, got:\n%s", out) } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server, got:\n%s", out) } // Should not have any address= entries if strings.Contains(out, "address=/") { t.Errorf("expected no address= entries with empty instances, got:\n%s", out) } // Should not have any local=/ entries if strings.Contains(out, "local=/") { t.Errorf("expected no local=/ entries with empty instances, got:\n%s", out) } } // Test: multiple instances each get their own entries func TestGenerate_MultipleInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.GlobalConfig{} instances := []config.InstanceConfig{ instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"), instanceWith("cloud2.example.com", "internal2.example.com", "10.0.0.2"), instanceWith("cloud3.example.com", "internal3.example.com", "10.0.0.3"), } out := g.Generate(globalCfg, instances) for i, inst := range instances { ip := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}[i] if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.Domain, ip)) { t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.Domain, out) } if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.InternalDomain, ip)) { t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.InternalDomain, out) } if !strings.Contains(out, fmt.Sprintf("local=/%s/", inst.Cloud.InternalDomain)) { t.Errorf("expected local=/ entry for %s, got:\n%s", inst.Cloud.InternalDomain, out) } } } // Test: nil config doesn't panic, uses auto-detect for IP func TestGenerate_NilConfig(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") // Should not panic with nil config out := g.Generate(nil, []config.InstanceConfig{}) if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected valid config with nil cfg, got:\n%s", out) } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server with nil cfg, got:\n%s", out) } } // Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cfg.Cloud.Dnsmasq.IP = "192.168.8.100" out := g.Generate(cfg, []config.InstanceConfig{}) if !strings.Contains(out, "listen-address=192.168.8.100") { t.Errorf("expected configured dnsmasq IP in listen-address, got:\n%s", out) } } // Test: verify no address=// or local=// entries appear in any test output func TestGenerate_NoLeakedEmptyEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} cases := []struct { name string instances []config.InstanceConfig }{ {"empty", nil}, {"domain_only", []config.InstanceConfig{instanceWith("example.com", "", "10.0.0.1")}}, {"internal_only", []config.InstanceConfig{instanceWith("", "internal.example.com", "10.0.0.1")}}, {"both", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "10.0.0.1")}}, {"no_lb_ip", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "")}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { out := g.Generate(cfg, tc.instances) if strings.Contains(out, "address=//") { t.Errorf("leaked empty address=// in output:\n%s", out) } if strings.Contains(out, "local=//") { t.Errorf("leaked empty local=// in output:\n%s", out) } }) } } // Test: GenerateInstanceConfig with all fields empty doesn't produce broken entries func TestGenerateInstanceConfig_EmptyFields(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") var inst config.InstanceConfig // All fields empty: no domain, no internal domain, no LB IP out := g.GenerateInstanceConfig(inst) if strings.Contains(out, "address=//") { t.Errorf("empty fields produced broken address=// entry:\n%s", out) } if strings.Contains(out, "local=//") { t.Errorf("empty fields produced broken local=// entry:\n%s", out) } // Should still contain the header comment if !strings.Contains(out, "DNS configuration for instance") { t.Errorf("expected header comment in output, got:\n%s", out) } }