Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
*.rpm
*.exe
*.out
*.cov
tmp/
.vscode/
20 changes: 10 additions & 10 deletions checker/patroni_leader_checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,20 @@ func TestGetChangeNotificationStream_StatusNoMatch(t *testing.T) {
}
}

// TestGetChangeNotificationStream_NonSuccessMatch verifies that a non-2xx
// status code that happens to equal the trigger value still emits true
// (the warning log does not prevent correct evaluation).
func TestGetChangeNotificationStream_NonSuccessMatch(t *testing.T) {
// TestGetChangeNotificationStream_Timeout verifies that a timeout waiting for a response
// causes false to be emitted.
func TestGetChangeNotificationStream_Timeout(t *testing.T) {
t.Parallel()
// Create a handler that delays the response beyond the client timeout
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable) // 503
time.Sleep(5 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

// Patroni uses 503 to signal "not the leader" – but if an operator
// configures trigger-value=503 they expect true here.
conf := patroniConfig(srv.URL, "/leader", "503")
if !runStream(t, conf) {
t.Error("expected true when non-2xx status code matches trigger value")
conf := patroniConfig(srv.URL, "/leader", "200")
result := runStream(t, conf)
if result != false {
t.Error("expected false on timeout")
}
}
302 changes: 302 additions & 0 deletions ipmanager/ip_manager_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package ipmanager

import (
"context"
"net"
"net/netip"
"strings"
"testing"
"time"

"github.com/cybertec-postgresql/vip-manager/vipconfig"
"go.uber.org/zap"
Expand Down Expand Up @@ -35,6 +39,29 @@ func TestGetNetIface_Nonexistent(t *testing.T) {
}
}

// TestGetNetIface_Success tests that getNetIface successfully returns a valid interface.
// On Windows, loopback is "Loopback Pseudo-Interface"; on Unix-like systems it's usually "lo".
// This test skips if no valid interface can be found.
func TestGetNetIface_Success(t *testing.T) {
t.Parallel()

// Try common loopback names
names := []string{"lo", "lo0", "Loopback Pseudo-Interface 1"}
var iface *net.Interface
var err error

for _, name := range names {
iface, err = getNetIface(name)
if err == nil {
break
}
}

if iface == nil || err != nil {
t.Skip("no valid loopback interface available for testing")
}
}

// ---------------------------------------------------------------------------
// NewIPManager
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -66,3 +93,278 @@ func TestNewIPManager_InvalidInterface(t *testing.T) {
t.Errorf("unexpected error message: %v", err)
}
}

// ---------------------------------------------------------------------------
// getMask
// ---------------------------------------------------------------------------

func TestGetMask_IPv4_ValidRange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
addr netip.Addr
mask int
want string
}{
{"IPv4 /24", netip.MustParseAddr("192.168.1.1"), 24, "ffffff00"},
{"IPv4 /32", netip.MustParseAddr("192.168.1.1"), 32, "ffffffff"},
{"IPv4 /16", netip.MustParseAddr("10.0.0.1"), 16, "ffff0000"},
{"IPv6 /64", netip.MustParseAddr("2001:db8::1"), 64, "ffffffffffffffff0000000000000000"},
{"IPv6 /128", netip.MustParseAddr("2001:db8::1"), 128, "ffffffffffffffffffffffffffffffff"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := getMask(tt.addr, tt.mask)
if m.String() != tt.want {
t.Errorf("getMask(%v, %d) = %v, want %v", tt.addr, tt.mask, m.String(), tt.want)
}
})
}
}

func TestGetMask_IPv4_OutOfRange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
addr netip.Addr
mask int
desc string
}{
{"IPv4 negative", netip.MustParseAddr("192.168.1.1"), -1, "negative mask"},
{"IPv4 > 32", netip.MustParseAddr("192.168.1.1"), 33, "mask > 32"},
{"IPv4 zero", netip.MustParseAddr("192.168.1.1"), 0, "zero mask"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := getMask(tt.addr, tt.mask)
// For out-of-range IPv4, we expect default mask
if m == nil {
t.Errorf("getMask(%v, %d) returned nil for %s", tt.addr, tt.mask, tt.desc)
}
})
}
}

// ---------------------------------------------------------------------------
// Mock configurer for testing applyLoop and SyncStates
// ---------------------------------------------------------------------------

type mockConfigurer struct {
queryAddressCount int
configureCount int
deconfigureCount int
shouldQueryFail bool
shouldConfigureFail bool
shouldDeconfigureFail bool
shouldQueryReturn bool
}

func (m *mockConfigurer) queryAddress() bool {
m.queryAddressCount++
if m.shouldQueryFail {
return false
}
return m.shouldQueryReturn
}

func (m *mockConfigurer) configureAddress() bool {
m.configureCount++
return !m.shouldConfigureFail
}

func (m *mockConfigurer) deconfigureAddress() bool {
m.deconfigureCount++
return !m.shouldDeconfigureFail
}

func (m *mockConfigurer) getCIDR() string {
return "192.168.1.100/24"
}

func TestApplyLoop_DeconfigureWhenNeeded(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

conf := zap.NewNop()
log = conf.Sugar()

mock := &mockConfigurer{shouldQueryReturn: true}
m := &IPManager{
configurer: mock,
recheckChan: make(chan struct{}, 1),
}
m.shouldSetIPUp.Store(false)

m.applyLoop(ctx)

if mock.deconfigureCount == 0 {
t.Error("expected deconfigureAddress to be called")
}
}

// ---------------------------------------------------------------------------
// applyLoop
// ---------------------------------------------------------------------------

func TestApplyLoop_ConfigureWhenNeeded(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

conf := zap.NewNop()
log = conf.Sugar()

mock := &mockConfigurer{shouldQueryReturn: false}
m := &IPManager{
configurer: mock,
recheckChan: make(chan struct{}, 1),
}
m.shouldSetIPUp.Store(true)

m.applyLoop(ctx)

if mock.configureCount == 0 {
t.Error("expected configureAddress to be called")
}
}

func TestApplyLoop_ConfigureFailure(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()

conf := zap.NewNop()
log = conf.Sugar()

mock := &mockConfigurer{shouldQueryReturn: false, shouldConfigureFail: true}
m := &IPManager{
configurer: mock,
recheckChan: make(chan struct{}, 1),
}
m.shouldSetIPUp.Store(true)

m.applyLoop(ctx)

if mock.configureCount == 0 {
t.Error("expected configureAddress to be called even if it fails")
}
}

func TestApplyLoop_QueryFails(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()

conf := zap.NewNop()
log = conf.Sugar()

mock := &mockConfigurer{shouldQueryFail: true}
m := &IPManager{
configurer: mock,
recheckChan: make(chan struct{}, 1),
}
m.shouldSetIPUp.Store(true)

m.applyLoop(ctx)

// queryAddress should be called despite failure
if mock.queryAddressCount == 0 {
t.Error("expected queryAddress to be called")
}
}

func TestApplyLoop_NoChangeNeeded(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()

conf := zap.NewNop()
log = conf.Sugar()

mock := &mockConfigurer{shouldQueryReturn: true}
m := &IPManager{
configurer: mock,
recheckChan: make(chan struct{}, 1),
}
m.shouldSetIPUp.Store(true) // IP is up and should be up

m.applyLoop(ctx)

// Neither configure nor deconfigure should be called
if mock.configureCount > 0 || mock.deconfigureCount > 0 {
t.Error("expected no configuration changes when state matches")
}
}

// ---------------------------------------------------------------------------
// SyncStates
// ---------------------------------------------------------------------------

func TestSyncStates_StateChange(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

conf := zap.NewNop()
log = conf.Sugar()

mock := &mockConfigurer{shouldQueryReturn: false}
m := &IPManager{
configurer: mock,
recheckChan: make(chan struct{}, 10),
}

states := make(chan bool, 2)
states <- true
states <- false

go func() {
time.Sleep(100 * time.Millisecond)
close(states)
}()

m.SyncStates(ctx, states)

// After false is sent and processed, shouldSetIPUp should be false
if m.shouldSetIPUp.Load() {
t.Error("expected shouldSetIPUp to be false after state false was processed")
}
if mock.deconfigureCount == 0 {
t.Error("expected deconfigureAddress to be called on context done")
}
}

func TestNewIPManager_ValidIPv6(t *testing.T) {
t.Parallel()
states := make(chan bool)
conf := minimalConfig("2001:db8::1", "lo")
conf.Mask = 64
// This will fail because loopback is typically not used for VIPs, but it tests
// that we can parse IPv6 addresses
_, err := NewIPManager(conf, states)
// Error is expected due to loopback device validation, not IP parsing
if err != nil {
if !strings.Contains(err.Error(), "loopback device") {
// If it's not the loopback error, the test is still valid
// (we successfully parsed the IPv6 address)
t.Logf("Got expected error for IPv6 on loopback: %v", err)
}
}
}

func TestNewIPManager_Hetzner(t *testing.T) {
t.Parallel()
states := make(chan bool)
conf := minimalConfig("10.0.0.1", "definitely_nonexistent_iface_9999")
conf.HostingType = "hetzner"
// Hetzner configurer initialization will fail because the interface doesn't exist
_, err := NewIPManager(conf, states)
if err == nil {
t.Error("expected error for nonexistent interface")
return
}
if !strings.Contains(err.Error(), "failed to get interface") {
t.Errorf("unexpected error: %v", err)
}
}
Loading
Loading