- JSON tags: fix snake_case to camelCase in dnsmasq (configFile, domainsConfigured, lastRestart), crowdsec Machine (lastPush, lastHeartbeat), network (primaryIP, primaryInterface). cscli raw parsing structs keep snake_case to match CLI output. - Error wrapping: fix %v to %w in enableAuthelia for proper error chain preservation - Naming: rename dnsmasq.ConfigGenerator to dnsmasq.Manager (matches all other packages), rename ServiceStatus to Status in dnsmasq and haproxy (matches authelia, crowdsec, etc.) - Logging: standardize all slog calls to use "component" key instead of message prefixes. Affects reconcile, dnsfilter, ddns — now consistent with dnsmasq, haproxy, nftables, sse.
287 lines
8.5 KiB
Go
287 lines
8.5 KiB
Go
package reconcile
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
"github.com/wild-cloud/wild-central/internal/sse"
|
|
)
|
|
|
|
// --- Stubs ---
|
|
|
|
type stubDomainManager struct {
|
|
domains []domains.Domain
|
|
err error
|
|
}
|
|
|
|
func (s *stubDomainManager) List() ([]domains.Domain, error) {
|
|
return s.domains, s.err
|
|
}
|
|
|
|
type stubHAProxy struct {
|
|
generateCalls int
|
|
lastL4Routes []haproxy.L4Route
|
|
writtenConfig string
|
|
writeErr error
|
|
reloadCalled bool
|
|
}
|
|
|
|
func (s *stubHAProxy) GenerateWithOpts(l4 []haproxy.L4Route, _ []haproxy.CustomRoute, _ haproxy.GenerateOpts) string {
|
|
s.generateCalls++
|
|
s.lastL4Routes = l4
|
|
return "generated-config"
|
|
}
|
|
func (s *stubHAProxy) WriteConfig(c string) error { s.writtenConfig = c; return s.writeErr }
|
|
func (s *stubHAProxy) ReloadService() error { s.reloadCalled = true; return nil }
|
|
|
|
type stubDNS struct {
|
|
entries []dnsmasq.DNSEntry
|
|
filterPath string
|
|
updateCalled bool
|
|
}
|
|
|
|
func (s *stubDNS) UpdateConfig(_ *config.State, entries []dnsmasq.DNSEntry, _ bool) error {
|
|
s.entries = entries
|
|
s.updateCalled = true
|
|
return nil
|
|
}
|
|
func (s *stubDNS) SetFilterConfPath(p string) { s.filterPath = p }
|
|
func (s *stubDNS) GetStatus() (*dnsmasq.Status, error) { return nil, nil }
|
|
|
|
type stubAuth struct {
|
|
userCount int
|
|
restartCalled bool
|
|
}
|
|
|
|
func (s *stubAuth) UserCount() int { return s.userCount }
|
|
func (s *stubAuth) RestartService() error { s.restartCalled = true; return nil }
|
|
|
|
type stubDDNS struct{ triggerCalled bool }
|
|
|
|
func (s *stubDDNS) Trigger() { s.triggerCalled = true }
|
|
|
|
type stubDNSFilter struct{ hostsPath string }
|
|
|
|
func (s *stubDNSFilter) HostsFilePath() string { return s.hostsPath }
|
|
|
|
type stubSSE struct{ events []*sse.Event }
|
|
|
|
func (s *stubSSE) Broadcast(e *sse.Event) { s.events = append(s.events, e) }
|
|
|
|
func newTestReconciler(t *testing.T, doms []domains.Domain) (*Reconciler, *stubHAProxy, *stubDNS, *stubDDNS) {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
statePath := filepath.Join(tmpDir, "state.yaml")
|
|
// Write minimal state
|
|
_ = config.SaveState(&config.State{}, statePath)
|
|
|
|
hp := &stubHAProxy{}
|
|
dns := &stubDNS{}
|
|
ddns := &stubDDNS{}
|
|
|
|
r := &Reconciler{
|
|
Domains: &stubDomainManager{domains: doms},
|
|
HAProxy: hp,
|
|
DNS: dns,
|
|
Auth: &stubAuth{},
|
|
DDNS: ddns,
|
|
DNSFilter: &stubDNSFilter{},
|
|
SSE: &stubSSE{},
|
|
StatePath: statePath,
|
|
}
|
|
return r, hp, dns, ddns
|
|
}
|
|
|
|
// --- Pure function tests ---
|
|
|
|
func TestBuildRoutes_MixedBackendTypes(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
|
|
{DomainName: "api.example.com", Backend: domains.Backend{Address: "192.168.1.20:8080", Type: domains.BackendHTTP}},
|
|
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
|
|
}
|
|
|
|
l4, http := buildRoutes(doms)
|
|
|
|
if len(l4) != 1 {
|
|
t.Fatalf("expected 1 L4 route, got %d", len(l4))
|
|
}
|
|
if l4[0].Domain != "cloud.example.com" || l4[0].BackendIP != "192.168.1.10" || !l4[0].Subdomains {
|
|
t.Errorf("L4 route mismatch: %+v", l4[0])
|
|
}
|
|
|
|
if len(http) != 1 {
|
|
t.Fatalf("expected 1 HTTP route, got %d", len(http))
|
|
}
|
|
if http[0].Domain != "api.example.com" {
|
|
t.Errorf("HTTP route domain = %q, want api.example.com", http[0].Domain)
|
|
}
|
|
if len(http[0].Routes) != 1 || http[0].Routes[0].Backend != "192.168.1.20:8080" {
|
|
t.Errorf("HTTP route backend mismatch: %+v", http[0].Routes)
|
|
}
|
|
}
|
|
|
|
func TestBuildRoutes_EmptyDomains(t *testing.T) {
|
|
l4, http := buildRoutes(nil)
|
|
if l4 != nil || http != nil {
|
|
t.Error("expected nil routes for nil domains")
|
|
}
|
|
}
|
|
|
|
func TestBuildDNSEntries_IPAssignment(t *testing.T) {
|
|
centralIP := "10.0.0.1"
|
|
doms := []domains.Domain{
|
|
{DomainName: "central.example.com", Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}},
|
|
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}},
|
|
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
|
|
}
|
|
|
|
entries := buildDNSEntries(doms, centralIP)
|
|
|
|
if len(entries) != 3 {
|
|
t.Fatalf("expected 3 entries, got %d", len(entries))
|
|
}
|
|
|
|
// HTTP → centralIP
|
|
if entries[0].IP != centralIP {
|
|
t.Errorf("HTTP domain IP = %q, want %q", entries[0].IP, centralIP)
|
|
}
|
|
// TCP passthrough → backend IP
|
|
if entries[1].IP != "192.168.1.10" {
|
|
t.Errorf("TCP domain IP = %q, want 192.168.1.10", entries[1].IP)
|
|
}
|
|
// DNS-only → backend IP
|
|
if entries[2].IP != "192.168.1.30" {
|
|
t.Errorf("DNS-only domain IP = %q, want 192.168.1.30", entries[2].IP)
|
|
}
|
|
}
|
|
|
|
func TestBuildDNSEntries_SkipsEmptyDomainName(t *testing.T) {
|
|
entries := buildDNSEntries([]domains.Domain{{DomainName: ""}}, "10.0.0.1")
|
|
if len(entries) != 0 {
|
|
t.Error("expected empty domain to be skipped")
|
|
}
|
|
}
|
|
|
|
func TestBuildDNSEntries_WildcardFlag(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
|
|
{DomainName: "exact.example.com", Backend: domains.Backend{Address: "192.168.1.20:443", Type: domains.BackendTCPPassthrough}, Subdomains: false},
|
|
}
|
|
entries := buildDNSEntries(doms, "10.0.0.1")
|
|
if !entries[0].Wildcard {
|
|
t.Error("expected first entry to be wildcard")
|
|
}
|
|
if entries[1].Wildcard {
|
|
t.Error("expected second entry to not be wildcard")
|
|
}
|
|
}
|
|
|
|
// --- Integration tests with stubs ---
|
|
|
|
func TestReconcile_EmptyDomains(t *testing.T) {
|
|
r, hp, dns, ddns := newTestReconciler(t, nil)
|
|
r.Reconcile()
|
|
|
|
if !dns.updateCalled {
|
|
t.Error("expected dnsmasq update")
|
|
}
|
|
if len(dns.entries) != 0 {
|
|
t.Errorf("expected 0 DNS entries, got %d", len(dns.entries))
|
|
}
|
|
if !hp.reloadCalled {
|
|
// No routes but config is still generated and written
|
|
}
|
|
if !ddns.triggerCalled {
|
|
t.Error("expected DDNS trigger")
|
|
}
|
|
// With no routes, HAProxy should still generate (empty config is valid)
|
|
if hp.generateCalls != 1 {
|
|
t.Errorf("expected 1 HAProxy generate call, got %d", hp.generateCalls)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_DDNSTriggered(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "test.example.com", Backend: domains.Backend{Address: "127.0.0.1:80", Type: domains.BackendHTTP}},
|
|
}
|
|
r, _, _, ddns := newTestReconciler(t, doms)
|
|
r.Reconcile()
|
|
|
|
if !ddns.triggerCalled {
|
|
t.Error("expected DDNS.Trigger() to be called")
|
|
}
|
|
}
|
|
|
|
func TestReconcile_DNSEntriesBuiltFromDomains(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "app.example.com", Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP}},
|
|
{DomainName: "k8s.example.com", Backend: domains.Backend{Address: "192.168.1.50:443", Type: domains.BackendTCPPassthrough}},
|
|
}
|
|
r, _, dns, _ := newTestReconciler(t, doms)
|
|
r.Reconcile()
|
|
|
|
if len(dns.entries) != 2 {
|
|
t.Fatalf("expected 2 DNS entries, got %d", len(dns.entries))
|
|
}
|
|
// HTTP domain should use centralIP (127.0.0.1 fallback since state has no IP)
|
|
if dns.entries[0].Domain != "app.example.com" {
|
|
t.Errorf("first DNS entry domain = %q", dns.entries[0].Domain)
|
|
}
|
|
// TCP passthrough should use backend IP directly
|
|
if dns.entries[1].IP != "192.168.1.50" {
|
|
t.Errorf("TCP DNS entry IP = %q, want 192.168.1.50", dns.entries[1].IP)
|
|
}
|
|
}
|
|
|
|
// --- Helper tests ---
|
|
|
|
func TestIsValidCertFile_Valid(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
path := filepath.Join(tmp, "test.pem")
|
|
if err := os.WriteFile(path, []byte("--- cert content ---"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isValidCertFile(path) {
|
|
t.Error("expected valid cert file to return true")
|
|
}
|
|
}
|
|
|
|
func TestIsValidCertFile_Empty(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
path := filepath.Join(tmp, "empty.pem")
|
|
if err := os.WriteFile(path, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if isValidCertFile(path) {
|
|
t.Error("expected 0-byte cert file to return false")
|
|
}
|
|
}
|
|
|
|
func TestIsValidCertFile_Missing(t *testing.T) {
|
|
if isValidCertFile("/nonexistent/path.pem") {
|
|
t.Error("expected missing file to return false")
|
|
}
|
|
}
|
|
|
|
func TestExtractHost(t *testing.T) {
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"192.168.1.1:8080", "192.168.1.1"},
|
|
{"example.com:443", "example.com"},
|
|
{"localhost", "localhost"},
|
|
{"127.0.0.1", "127.0.0.1"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := extractHost(tt.input); got != tt.want {
|
|
t.Errorf("extractHost(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|