- Add storage.WriteFileAtomic (temp + rename) and storage.CopyFile helpers - Convert all 8 production config writers to atomic writes: config/state.yaml, dnsmasq, nftables, domains, wireguard (config + secrets + peers + wg0.conf), tunnel/cloudflared - Add sync.Mutex to Reconciler to serialize concurrent Reconcile() calls triggered by domain registration goroutines - Add state.yaml backup (.bak) before every write; LoadState falls back to backup if primary is corrupted - Reconciler refuses to use empty config on corruption (only on first run when no state file exists yet)
319 lines
8.1 KiB
Go
319 lines
8.1 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Test: LoadState loads valid state
|
|
func TestLoadState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
stateYAML string
|
|
verify func(t *testing.T, config *State)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "loads complete state",
|
|
stateYAML: `operator:
|
|
email: "admin@example.com"
|
|
cloud:
|
|
central:
|
|
domain: "central.example.com"
|
|
`,
|
|
verify: func(t *testing.T, config *State) {
|
|
if config.Operator.Email != "admin@example.com" {
|
|
t.Error("operator email not loaded correctly")
|
|
}
|
|
if config.Cloud.Central.Domain != "central.example.com" {
|
|
t.Error("central domain not loaded correctly")
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "loads minimal state",
|
|
stateYAML: `operator:
|
|
email: "admin@example.com"
|
|
`,
|
|
verify: func(t *testing.T, config *State) {
|
|
if config.Operator.Email != "admin@example.com" {
|
|
t.Error("operator email not loaded correctly")
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "loads empty state",
|
|
stateYAML: "{}\n",
|
|
verify: func(t *testing.T, config *State) {
|
|
if config.Operator.Email != "" {
|
|
t.Error("expected empty operator email")
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
statePath := filepath.Join(tempDir, "state.yaml")
|
|
|
|
if err := os.WriteFile(statePath, []byte(tt.stateYAML), 0644); err != nil {
|
|
t.Fatalf("setup failed: %v", err)
|
|
}
|
|
|
|
config, err := LoadState(statePath)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
return
|
|
}
|
|
|
|
if config == nil {
|
|
t.Fatal("config is nil")
|
|
}
|
|
|
|
if tt.verify != nil {
|
|
tt.verify(t, config)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test: LoadState error cases
|
|
func TestLoadState_Errors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setupFunc func(t *testing.T) string
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "non-existent file",
|
|
setupFunc: func(t *testing.T) string {
|
|
return filepath.Join(t.TempDir(), "nonexistent.yaml")
|
|
},
|
|
errContains: "reading config file",
|
|
},
|
|
{
|
|
name: "invalid yaml",
|
|
setupFunc: func(t *testing.T) string {
|
|
tempDir := t.TempDir()
|
|
statePath := filepath.Join(tempDir, "state.yaml")
|
|
content := `invalid: yaml: [[[`
|
|
if err := os.WriteFile(statePath, []byte(content), 0644); err != nil {
|
|
t.Fatalf("setup failed: %v", err)
|
|
}
|
|
return statePath
|
|
},
|
|
errContains: "yaml:",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
statePath := tt.setupFunc(t)
|
|
_, err := LoadState(statePath)
|
|
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
} else if !strings.Contains(err.Error(), tt.errContains) {
|
|
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test: SaveState saves state correctly
|
|
func TestSaveState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config *State
|
|
verify func(t *testing.T, statePath string)
|
|
}{
|
|
{
|
|
name: "saves complete state",
|
|
config: func() *State {
|
|
cfg := &State{}
|
|
cfg.Operator.Email = "admin@example.com"
|
|
cfg.Cloud.Central.Domain = "central.example.com"
|
|
return cfg
|
|
}(),
|
|
verify: func(t *testing.T, statePath string) {
|
|
content, err := os.ReadFile(statePath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read saved state: %v", err)
|
|
}
|
|
contentStr := string(content)
|
|
if !strings.Contains(contentStr, "admin@example.com") {
|
|
t.Error("saved state missing operator email")
|
|
}
|
|
if !strings.Contains(contentStr, "central.example.com") {
|
|
t.Error("saved state missing central domain")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "saves empty state",
|
|
config: &State{},
|
|
verify: func(t *testing.T, statePath string) {
|
|
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
|
t.Error("state file not created")
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
statePath := filepath.Join(tempDir, "subdir", "state.yaml")
|
|
|
|
err := SaveState(tt.config, statePath)
|
|
if err != nil {
|
|
t.Errorf("SaveState failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// Verify file exists
|
|
if _, err := os.Stat(statePath); err != nil {
|
|
t.Errorf("state file not created: %v", err)
|
|
return
|
|
}
|
|
|
|
// Verify file permissions
|
|
info, err := os.Stat(statePath)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat state file: %v", err)
|
|
}
|
|
if info.Mode().Perm() != 0644 {
|
|
t.Errorf("expected permissions 0644, got %v", info.Mode().Perm())
|
|
}
|
|
|
|
// Verify content can be loaded back
|
|
loadedConfig, err := LoadState(statePath)
|
|
if err != nil {
|
|
t.Errorf("failed to reload saved state: %v", err)
|
|
} else if loadedConfig == nil {
|
|
t.Error("loaded config is nil")
|
|
}
|
|
|
|
if tt.verify != nil {
|
|
tt.verify(t, statePath)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test: SaveState creates directory
|
|
func TestSaveState_CreatesDirectory(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml")
|
|
|
|
config := &State{}
|
|
err := SaveState(config, statePath)
|
|
if err != nil {
|
|
t.Fatalf("SaveState failed: %v", err)
|
|
}
|
|
|
|
// Verify nested directories were created
|
|
if _, err := os.Stat(filepath.Dir(statePath)); err != nil {
|
|
t.Errorf("directory not created: %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
if _, err := os.Stat(statePath); err != nil {
|
|
t.Errorf("state file not created: %v", err)
|
|
}
|
|
}
|
|
|
|
// Test: Round-trip save and load preserves data
|
|
func TestState_RoundTrip(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
statePath := filepath.Join(tempDir, "state.yaml")
|
|
|
|
// Create config with all fields
|
|
original := &State{}
|
|
original.Operator.Email = "admin@example.com"
|
|
original.Cloud.Central.Domain = "central.example.com"
|
|
|
|
// Save
|
|
if err := SaveState(original, statePath); err != nil {
|
|
t.Fatalf("SaveState failed: %v", err)
|
|
}
|
|
|
|
// Load
|
|
loaded, err := LoadState(statePath)
|
|
if err != nil {
|
|
t.Fatalf("LoadState failed: %v", err)
|
|
}
|
|
|
|
// Verify all fields match
|
|
if loaded.Operator.Email != original.Operator.Email {
|
|
t.Errorf("email mismatch: got %q, want %q", loaded.Operator.Email, original.Operator.Email)
|
|
}
|
|
if loaded.Cloud.Central.Domain != original.Cloud.Central.Domain {
|
|
t.Errorf("central domain mismatch: got %q, want %q", loaded.Cloud.Central.Domain, original.Cloud.Central.Domain)
|
|
}
|
|
}
|
|
|
|
func TestAddDHCPStaticLease_New(t *testing.T) {
|
|
s := &State{}
|
|
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100", Hostname: "host1"})
|
|
|
|
if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 {
|
|
t.Fatalf("expected 1 lease, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases))
|
|
}
|
|
l := s.Cloud.Dnsmasq.DHCP.StaticLeases[0]
|
|
if l.MAC != "aa:bb:cc:dd:ee:ff" || l.IP != "192.168.1.100" || l.Hostname != "host1" {
|
|
t.Errorf("lease mismatch: %+v", l)
|
|
}
|
|
}
|
|
|
|
func TestAddDHCPStaticLease_ReplaceByMAC(t *testing.T) {
|
|
s := &State{}
|
|
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100"})
|
|
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.200"})
|
|
|
|
if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 {
|
|
t.Fatalf("expected 1 lease after replace, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases))
|
|
}
|
|
if s.Cloud.Dnsmasq.DHCP.StaticLeases[0].IP != "192.168.1.200" {
|
|
t.Errorf("expected IP to be updated to 192.168.1.200, got %s", s.Cloud.Dnsmasq.DHCP.StaticLeases[0].IP)
|
|
}
|
|
}
|
|
|
|
func TestRemoveDHCPStaticLease_Exists(t *testing.T) {
|
|
s := &State{}
|
|
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100"})
|
|
s.AddDHCPStaticLease(DHCPStaticLease{MAC: "11:22:33:44:55:66", IP: "192.168.1.101"})
|
|
|
|
found := s.RemoveDHCPStaticLease("aa:bb:cc:dd:ee:ff")
|
|
if !found {
|
|
t.Error("expected RemoveDHCPStaticLease to return true")
|
|
}
|
|
if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 {
|
|
t.Fatalf("expected 1 lease remaining, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases))
|
|
}
|
|
if s.Cloud.Dnsmasq.DHCP.StaticLeases[0].MAC != "11:22:33:44:55:66" {
|
|
t.Error("wrong lease was removed")
|
|
}
|
|
}
|
|
|
|
func TestRemoveDHCPStaticLease_NotFound(t *testing.T) {
|
|
s := &State{}
|
|
found := s.RemoveDHCPStaticLease("nonexistent")
|
|
if found {
|
|
t.Error("expected RemoveDHCPStaticLease to return false for missing MAC")
|
|
}
|
|
}
|