Files
wild-central/internal/api/v1/handlers_reconciliation_test.go
Paul Payne 428d47f876 Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture:
- Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces
- Add locked modifyState helper to fix state.yaml read-modify-write race condition
- Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface
- Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease
- Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate

Complexity reduction:
- haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers)
- reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig)
- AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia)

Test coverage improvements:
- reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration)
- dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries)
- config: 67.3% → 89.8% (DHCP static lease mutation tests)
2026-07-14 04:21:30 +00:00

562 lines
17 KiB
Go

package v1
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
)
// extractHost gets the host part from a host:port string (test helper)
func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
}
// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
// logic correctly maps registered domains to HAProxy configuration. This
// replicates the route-building logic from reconcileNetworking() and asserts
// on the generated config without requiring haproxy or dnsmasq binaries.
func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t)
// Register a mix of domains like a real deployment
testDomains := []domains.Domain{
{
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true,
Public: true,
},
{
DomainName: "payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: false,
Public: true,
},
{
DomainName: "wild-cloud.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
},
{
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP, Health: "/health"},
},
}
for _, dom := range testDomains {
if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
}
}
// Replicate reconcileNetworking route-building logic
doms, err := api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
var instanceRoutes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),
Subdomains: dom.Subdomains,
})
case domains.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: dom.Backend.Address,
HealthPath: dom.Backend.Health,
}},
})
}
}
// Central registers itself as a domain; add it to the test data
if err := api.domains.Register(domains.Domain{
DomainName: "central.payne.io",
Source: "central",
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
TLS: domains.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Re-list to pick up Central
doms, err = api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
httpRoutes = nil
for _, dom := range doms {
if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: dom.Backend.Address,
HealthPath: dom.Backend.Health,
}},
})
}
}
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/etc/haproxy/certs/",
})
// --- Assertions on HAProxy config ---
// L7 domains have exact-match ACLs in the SNI frontend
if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") {
t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg)
}
if !strings.Contains(cfg, "acl is_l7_wild_cloud_payne_io req_ssl_sni -m str wild-cloud.payne.io") {
t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg)
}
// L4 domains with subdomains:true have wildcard ACLs
if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") {
t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg)
}
// L4 domains with subdomains:false have exact-match only
if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") {
t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg)
}
if strings.Contains(cfg, "req_ssl_sni -m end .payne.io") {
t.Errorf("payne.io (subdomains:false) must NOT have wildcard ACL:\n%s", cfg)
}
// Central's config domain appears as an L7 route
if !strings.Contains(cfg, "hdr(host) -i central.payne.io") {
t.Errorf("expected Host header ACL for central.payne.io in L7 frontend:\n%s", cfg)
}
// L7 ACLs appear BEFORE L4 wildcard ACLs
l7Pos := strings.Index(cfg, "is_l7_central_payne_io")
l4WildcardPos := strings.Index(cfg, "req_ssl_sni -m end .cloud.payne.io")
if l7Pos > l4WildcardPos {
t.Errorf("L7 exact matches (pos %d) must appear before L4 wildcards (pos %d)", l7Pos, l4WildcardPos)
}
// L4 exact (payne.io) appears before L4 wildcard (*.cloud.payne.io)
l4ExactPos := strings.Index(cfg, "acl is_payne_io req_ssl_sni -m str payne.io")
if l4ExactPos < 0 {
t.Fatalf("expected L4 exact ACL for payne.io:\n%s", cfg)
}
if l4ExactPos > l4WildcardPos {
t.Errorf("L4 exact match (pos %d) must come before L4 wildcard (pos %d)", l4ExactPos, l4WildcardPos)
}
// Health check path is present for my-api
if !strings.Contains(cfg, "option httpchk GET /health") {
t.Errorf("expected health check for my-api:\n%s", cfg)
}
}
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
// correctly generates dnsmasq config from registered domains. Wildcard
// domains (subdomains:true) get address=/, exact-match domains get
// host-record=. No local=/ directives — AAAA is handled by filter-AAAA.
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register domains with different backend types and subdomain settings
testDomains := []domains.Domain{
{
// tcp-passthrough, wildcard → address=/ with backend IP
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true,
Public: true,
},
{
// http, exact → host-record= with Central IP
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
},
{
// http, exact → host-record= with Central IP
DomainName: "public-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
Public: true,
},
}
for _, dom := range testDomains {
if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
}
}
// Build dnsmasq entries the same way reconcileNetworking does
doms, err := api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
globalCfg := &config.State{}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
dnsIP := centralIP
if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// --- Wildcard (subdomains:true) → address=/ with backend IP ---
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("wildcard domain must use address=/ with backend IP:\n%s", dnsmasqCfg)
}
// --- Exact-match HTTP domains → host-record= with Central IP ---
if !strings.Contains(dnsmasqCfg, "host-record=my-api.payne.io,192.168.8.151") {
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "host-record=public-app.payne.io,192.168.8.151") {
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
// --- No local=/ directives (AAAA handled by filter-AAAA) ---
if strings.Contains(dnsmasqCfg, "local=/") {
t.Errorf("must not produce local=/ directives:\n%s", dnsmasqCfg)
}
// --- filter-AAAA present ---
if !strings.Contains(dnsmasqCfg, "filter-AAAA") {
t.Errorf("must include filter-AAAA directive:\n%s", dnsmasqCfg)
}
}
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
// as a domain, produces correct HAProxy L7 routes.
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
api, _ := setupTestAPI(t)
centralPort := 15055
// Register Central as a domain (as EnsureCentralDomain does)
if err := api.domains.Register(domains.Domain{
DomainName: "central.payne.io",
Source: "central",
Backend: domains.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: domains.BackendHTTP},
TLS: domains.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Register another HTTP domain
if err := api.domains.Register(domains.Domain{
DomainName: "my-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
doms, _ := api.domains.List()
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: dom.Backend.Address,
}},
})
}
}
cfg := api.haproxy.GenerateWithOpts(nil, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
})
// Central domain should be present as both SNI ACL and Host header ACL
if !strings.Contains(cfg, "req_ssl_sni -m str central.payne.io") {
t.Errorf("expected Central domain SNI ACL:\n%s", cfg)
}
if !strings.Contains(cfg, "hdr(host) -i central.payne.io") {
t.Errorf("expected Central domain Host ACL:\n%s", cfg)
}
// Central backend must use the configured port (15055)
if !strings.Contains(cfg, fmt.Sprintf("server s0 127.0.0.1:%d", centralPort)) {
t.Errorf("Central backend must use port %d:\n%s", centralPort, cfg)
}
}
// TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough
// domains get DNS pointing to the backend IP, not Central's IP.
func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register a k8s instance with tcp-passthrough
if err := api.domains.Register(domains.Domain{
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true,
Public: true,
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
doms, _ := api.domains.List()
globalCfg := &config.State{}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
dnsIP := centralIP
if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// TCP passthrough with subdomains → address=/ to backend (k8s LB), NOT central
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("wildcard tcp-passthrough DNS must use address=/ to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
}
if strings.Contains(dnsmasqCfg, "host-record=cloud.payne.io,192.168.8.151") {
t.Errorf("wildcard tcp-passthrough DNS must NOT use host-record= to Central IP:\n%s", dnsmasqCfg)
}
}
// TestReconciliation_DNSOnlyHasNoDNSButNoProxy verifies that dns-only domains
// get DNS entries but no HAProxy routes.
func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register a dns-only domain (e.g. for SSH access)
if err := api.domains.Register(domains.Domain{
DomainName: "dev.payne.io",
Source: "manual",
Backend: domains.Backend{Address: "192.168.8.222", Type: domains.BackendDNSOnly},
Public: true,
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
// Also register a normal HTTP domain to verify it still works
if err := api.domains.Register(domains.Domain{
DomainName: "app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP},
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
doms, _ := api.domains.List()
// Build HAProxy routes — dns-only should be absent
var instanceRoutes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),
})
case domains.BackendDNSOnly:
// dns-only: no gateway routing
case domains.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{Backend: dom.Backend.Address}},
})
}
}
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/etc/haproxy/certs/",
})
// dns-only domain must NOT appear in HAProxy config
if strings.Contains(cfg, "dev.payne.io") {
t.Errorf("dns-only domain must NOT appear in HAProxy config:\n%s", cfg)
}
// HTTP domain should appear
if !strings.Contains(cfg, "app.payne.io") {
t.Errorf("http domain should appear in HAProxy config:\n%s", cfg)
}
// Build DNS config — dns-only should point to its backend IP
globalCfg := &config.State{}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
dnsIP := centralIP
bt := dom.Backend.Type
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// dns-only domain (public, no subdomains) → host-record= with backend IP
if !strings.Contains(dnsmasqCfg, "host-record=dev.payne.io,192.168.8.222") {
t.Errorf("dns-only domain must use host-record= with backend IP:\n%s", dnsmasqCfg)
}
// HTTP domain (no subdomains) → host-record= with Central IP
if !strings.Contains(dnsmasqCfg, "host-record=app.payne.io,192.168.8.151") {
t.Errorf("http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
}
// writeTestConfig writes a minimal global config with the given Central domain.
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
t.Helper()
configPath := filepath.Join(dataDir, "state.yaml")
content := fmt.Sprintf("cloud:\n central:\n domain: %s\n", centralDomain)
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
}
func TestEnsureCentralDomain_RegistersWhenDomainConfigured(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralDomain()
dom, err := api.domains.Get("central.example.com")
if err != nil {
t.Fatalf("expected Central domain to be registered: %v", err)
}
if dom.Source != "central" {
t.Errorf("source = %q, want %q", dom.Source, "central")
}
if dom.Backend.Address != "127.0.0.1:15055" {
t.Errorf("backend = %q, want %q", dom.Backend.Address, "127.0.0.1:15055")
}
if dom.TLS != domains.TLSTerminate {
t.Errorf("tls = %q, want %q", dom.TLS, domains.TLSTerminate)
}
}
func TestEnsureCentralDomain_Idempotent(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralDomain()
api.EnsureCentralDomain() // second call should be a no-op
doms, _ := api.domains.List()
count := 0
for _, d := range doms {
if d.Source == "central" {
count++
}
}
if count != 1 {
t.Errorf("expected 1 central domain, got %d", count)
}
}
func TestEnsureCentralDomain_CleansUpOnDomainChange(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
// Register with old domain
writeTestConfig(t, dataDir, "old.example.com")
api.EnsureCentralDomain()
// Change domain
writeTestConfig(t, dataDir, "new.example.com")
api.EnsureCentralDomain()
// Old domain should be gone
if _, err := api.domains.Get("old.example.com"); err == nil {
t.Error("old Central domain should have been deregistered")
}
// New domain should exist
dom, err := api.domains.Get("new.example.com")
if err != nil {
t.Fatalf("new Central domain should be registered: %v", err)
}
if dom.Source != "central" {
t.Errorf("source = %q, want %q", dom.Source, "central")
}
}
func TestEnsureCentralDomain_NoDomainIsNoop(t *testing.T) {
api, _ := setupTestAPI(t)
api.SetPort(15055)
// Default config has no Central domain
api.EnsureCentralDomain()
doms, _ := api.domains.List()
for _, d := range doms {
if d.Source == "central" {
t.Error("no Central domain should be registered when domain is empty")
}
}
}