diff --git a/client/internal/routemanager/systemops/systemops_darwin_test.go b/client/internal/routemanager/systemops/systemops_darwin_test.go new file mode 100644 index 000000000..24db430cc --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_darwin_test.go @@ -0,0 +1,125 @@ +//go:build darwin && !ios + +package systemops + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// TestAfOf verifies that afOf returns the correct string for each address family. +func TestAfOf(t *testing.T) { + tests := []struct { + name string + addr netip.Addr + want string + }{ + { + name: "IPv4 unspecified", + addr: netip.IPv4Unspecified(), + want: "IPv4", + }, + { + name: "IPv4 private", + addr: netip.MustParseAddr("10.0.0.1"), + want: "IPv4", + }, + { + name: "IPv4 loopback", + addr: netip.MustParseAddr("127.0.0.1"), + want: "IPv4", + }, + { + name: "IPv6 unspecified", + addr: netip.IPv6Unspecified(), + want: "IPv6", + }, + { + name: "IPv6 loopback", + addr: netip.MustParseAddr("::1"), + want: "IPv6", + }, + { + name: "IPv6 unicast", + addr: netip.MustParseAddr("2001:db8::1"), + want: "IPv6", + }, + { + name: "IPv6 link-local", + addr: netip.MustParseAddr("fe80::1"), + want: "IPv6", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, afOf(tt.addr)) + }) + } +} + +// TestIsAddrRouted_AdvancedRoutingBypassesTunnelLookup verifies that when +// AdvancedRouting is active, IsAddrRouted immediately returns (false, zero) +// regardless of the provided vpn routes, because the WG socket is bound to +// the physical interface via IP_BOUND_IF and bypasses the main routing table. +func TestIsAddrRouted_AdvancedRoutingBypassesTunnelLookup(t *testing.T) { + // On darwin, AdvancedRouting returns true unless overridden. + // Ensure we reset the state after the test. + t.Setenv("NB_USE_LEGACY_ROUTING", "false") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + nbnet.Init() + + require.True(t, nbnet.AdvancedRouting(), "test requires advanced routing to be active on darwin") + + vpnRoutes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.1.0/24"), + netip.MustParsePrefix("0.0.0.0/0"), + } + + tests := []struct { + name string + addr netip.Addr + }{ + {"IPv4 in VPN route", netip.MustParseAddr("10.0.0.1")}, + {"IPv4 in narrow VPN route", netip.MustParseAddr("192.168.1.100")}, + {"IPv4 default route covered", netip.MustParseAddr("8.8.8.8")}, + {"IPv6 in VPN route", netip.MustParseAddr("2001:db8::1")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + routed, prefix := IsAddrRouted(tt.addr, vpnRoutes) + assert.False(t, routed, "should not be marked as routed via VPN when advanced routing is active") + assert.Equal(t, netip.Prefix{}, prefix, "matched prefix should be zero when advanced routing is active") + }) + } +} + +// TestIsAddrRouted_LegacyModeFallsThroughToTable verifies that when +// NB_USE_LEGACY_ROUTING=true disables advanced routing, IsAddrRouted +// performs the normal VPN-route vs local-route comparison. +func TestIsAddrRouted_LegacyModeFallsThroughToTable(t *testing.T) { + t.Setenv("NB_USE_LEGACY_ROUTING", "true") + nbnet.Init() + + require.False(t, nbnet.AdvancedRouting(), "test requires advanced routing to be disabled") + + // Use an address that is very unlikely to exist in the host routing table + // as a local route, so the VPN route wins. + vpnRoutes := []netip.Prefix{ + netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2 – not in normal routing tables + } + + addr := netip.MustParseAddr("198.51.100.1") + routed, _ := IsAddrRouted(addr, vpnRoutes) + // We cannot assert a specific outcome because it depends on the host's + // routing table, but we CAN assert that the call did not panic and returned + // a consistent pair. + _ = routed +} \ No newline at end of file diff --git a/client/internal/routemanager/systemops/systemops_isaddrrouted_test.go b/client/internal/routemanager/systemops/systemops_isaddrrouted_test.go new file mode 100644 index 000000000..6d2f2db07 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_isaddrrouted_test.go @@ -0,0 +1,140 @@ +//go:build !android && !ios + +package systemops + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// withLegacyRouting forcibly puts the nbnet package into legacy (non-advanced) +// routing mode for the duration of the test, then restores the previous value. +func withLegacyRouting(t *testing.T) { + t.Helper() + t.Setenv("NB_USE_LEGACY_ROUTING", "true") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + nbnet.Init() + t.Cleanup(func() { + // After the test, re-initialise with no overrides so that subsequent + // tests start with a clean state. + nbnet.Init() + }) +} + +// withAdvancedRouting attempts to enable advanced routing for the test. +// On platforms where Init() cannot produce AdvancedRouting()=true (e.g. Linux +// without root), the calling test is skipped. +func withAdvancedRouting(t *testing.T) { + t.Helper() + t.Setenv("NB_USE_LEGACY_ROUTING", "false") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + nbnet.Init() + t.Cleanup(func() { + nbnet.Init() + }) + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing not available in this environment (need root or darwin)") + } +} + +// TestIsAddrRouted_AdvancedRoutingShortCircuit verifies that when +// AdvancedRouting() returns true, IsAddrRouted immediately returns +// (false, zero prefix) regardless of any VPN routes, because the WG socket is +// bound directly to the physical interface and bypasses the kernel routing table. +func TestIsAddrRouted_AdvancedRoutingShortCircuit(t *testing.T) { + withAdvancedRouting(t) + + vpnRoutes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("0.0.0.0/0"), + netip.MustParsePrefix("::/0"), + } + + tests := []struct { + name string + addr string + }{ + {"IPv4 matched by 10/8", "10.0.0.1"}, + {"IPv4 matched by 192.168/16", "192.168.1.1"}, + {"IPv4 caught by default", "8.8.8.8"}, + {"IPv6 caught by default", "2001:db8::1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr := netip.MustParseAddr(tt.addr) + routed, prefix := IsAddrRouted(addr, vpnRoutes) + assert.False(t, routed, "advanced routing must short-circuit VPN route check") + assert.Equal(t, netip.Prefix{}, prefix, "returned prefix must be zero under advanced routing") + }) + } +} + +// TestIsAddrRouted_AdvancedRouting_EmptyRoutes verifies the short-circuit with +// an empty vpnRoutes slice — the result must still be (false, zero). +func TestIsAddrRouted_AdvancedRouting_EmptyRoutes(t *testing.T) { + withAdvancedRouting(t) + + routed, prefix := IsAddrRouted(netip.MustParseAddr("10.0.0.1"), nil) + assert.False(t, routed) + assert.Equal(t, netip.Prefix{}, prefix) +} + +// TestIsAddrRouted_LegacyMode_NonVPNAddress verifies that under legacy routing +// an address that is not covered by any VPN prefix returns false. +func TestIsAddrRouted_LegacyMode_NonVPNAddress(t *testing.T) { + withLegacyRouting(t) + + // 198.51.100.x (TEST-NET-2) is not normally in any routing table. + vpnRoutes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + } + + addr := netip.MustParseAddr("198.51.100.1") + routed, _ := IsAddrRouted(addr, vpnRoutes) + assert.False(t, routed, "address not in VPN routes should not be marked as VPN-routed") +} + +// TestIsAddrRouted_LegacyMode_EmptyVPNRoutes verifies that an empty vpnRoutes +// slice always yields (false, zero prefix) even under legacy routing. +func TestIsAddrRouted_LegacyMode_EmptyVPNRoutes(t *testing.T) { + withLegacyRouting(t) + + routed, prefix := IsAddrRouted(netip.MustParseAddr("10.0.0.1"), nil) + assert.False(t, routed) + assert.Equal(t, netip.Prefix{}, prefix) +} + +// TestIsAddrRouted_AdvancedVsLegacy_ContrastiveBehaviour documents the +// contract difference between the two modes: with a VPN default route and an +// address that matches it, legacy mode may mark it as VPN-routed while advanced +// mode must never do so. +func TestIsAddrRouted_AdvancedVsLegacy_ContrastiveBehaviour(t *testing.T) { + vpnRoutes := []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), + } + addr := netip.MustParseAddr("8.8.8.8") + + // --- advanced routing: must always return false --- + t.Run("advanced routing", func(t *testing.T) { + withAdvancedRouting(t) + routed, prefix := IsAddrRouted(addr, vpnRoutes) + assert.False(t, routed, "advanced routing must bypass VPN route lookup") + assert.Equal(t, netip.Prefix{}, prefix) + }) + + // --- legacy routing: delegates to kernel table check, does not panic --- + t.Run("legacy routing", func(t *testing.T) { + withLegacyRouting(t) + // We don't assert true/false here because it depends on the host + // routing table, but the call must not panic and must return + // a valid (bool, prefix) pair. + routed, prefix := IsAddrRouted(addr, vpnRoutes) + t.Logf("legacy IsAddrRouted(%s, %v) = (%v, %v)", addr, vpnRoutes, routed, prefix) + }) +} \ No newline at end of file diff --git a/client/net/env_bound_iface_test.go b/client/net/env_bound_iface_test.go new file mode 100644 index 000000000..a1ffad816 --- /dev/null +++ b/client/net/env_bound_iface_test.go @@ -0,0 +1,121 @@ +//go:build (darwin && !ios) || windows + +package net + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// resetAdvancedRoutingState resets the package-level advancedRoutingSupported variable +// and cleans up after the test. +func resetAdvancedRoutingState(t *testing.T) { + t.Helper() + orig := advancedRoutingSupported + t.Cleanup(func() { advancedRoutingSupported = orig }) +} + +// TestCheckAdvancedRoutingSupport_LegacyRoutingTrue verifies that setting +// NB_USE_LEGACY_ROUTING=true disables advanced routing. +func TestCheckAdvancedRoutingSupport_LegacyRoutingTrue(t *testing.T) { + t.Setenv(envUseLegacyRouting, "true") + assert.False(t, checkAdvancedRoutingSupport()) +} + +// TestCheckAdvancedRoutingSupport_LegacyRoutingFalse verifies that +// NB_USE_LEGACY_ROUTING=false still allows advanced routing when netstack is off. +func TestCheckAdvancedRoutingSupport_LegacyRoutingFalse(t *testing.T) { + t.Setenv(envUseLegacyRouting, "false") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + assert.True(t, checkAdvancedRoutingSupport()) +} + +// TestCheckAdvancedRoutingSupport_LegacyRoutingInvalid verifies that an invalid +// value for NB_USE_LEGACY_ROUTING is ignored (treated as false), so advanced +// routing remains enabled when netstack is off. +func TestCheckAdvancedRoutingSupport_LegacyRoutingInvalid(t *testing.T) { + t.Setenv(envUseLegacyRouting, "notabool") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + // The invalid value is ignored; the default (false) is kept, so advanced routing + // is not suppressed by the legacy-routing flag. + assert.True(t, checkAdvancedRoutingSupport()) +} + +// TestCheckAdvancedRoutingSupport_NetstackEnabled verifies that netstack mode +// disables advanced routing. +func TestCheckAdvancedRoutingSupport_NetstackEnabled(t *testing.T) { + t.Setenv(envUseLegacyRouting, "false") + t.Setenv("NB_USE_NETSTACK_MODE", "true") + assert.False(t, checkAdvancedRoutingSupport()) +} + +// TestCheckAdvancedRoutingSupport_NoEnvVars verifies that with no env overrides +// advanced routing is supported (the happy path). +func TestCheckAdvancedRoutingSupport_NoEnvVars(t *testing.T) { + // Unset both controlling variables so we hit the default path. + t.Setenv(envUseLegacyRouting, "") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + assert.True(t, checkAdvancedRoutingSupport()) +} + +// TestCheckAdvancedRoutingSupport_LegacyRoutingEmptyString verifies that an +// empty NB_USE_LEGACY_ROUTING is treated as "not set" and does not disable +// advanced routing. +func TestCheckAdvancedRoutingSupport_LegacyRoutingEmptyString(t *testing.T) { + t.Setenv(envUseLegacyRouting, "") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + assert.True(t, checkAdvancedRoutingSupport()) +} + +// TestAdvancedRouting_ReflectsInit verifies that after calling Init() with +// NB_USE_LEGACY_ROUTING=true, AdvancedRouting() returns false. +func TestAdvancedRouting_ReflectsInit(t *testing.T) { + resetAdvancedRoutingState(t) + + t.Setenv(envUseLegacyRouting, "true") + Init() + + assert.False(t, AdvancedRouting(), "AdvancedRouting should return false after Init with legacy routing") +} + +// TestAdvancedRouting_ReflectsInit_Advanced verifies that after calling Init() +// without legacy overrides, AdvancedRouting() returns true. +func TestAdvancedRouting_ReflectsInit_Advanced(t *testing.T) { + resetAdvancedRoutingState(t) + + t.Setenv(envUseLegacyRouting, "false") + t.Setenv("NB_USE_NETSTACK_MODE", "false") + Init() + + assert.True(t, AdvancedRouting(), "AdvancedRouting should return true after Init without legacy overrides") +} + +// TestSetAndGetVPNInterfaceName verifies SetVPNInterfaceName and GetVPNInterfaceName +// are consistent. +func TestSetAndGetVPNInterfaceName(t *testing.T) { + orig := GetVPNInterfaceName() + t.Cleanup(func() { SetVPNInterfaceName(orig) }) + + SetVPNInterfaceName("utun3") + assert.Equal(t, "utun3", GetVPNInterfaceName()) +} + +// TestSetVPNInterfaceName_Empty verifies that setting an empty name is accepted. +func TestSetVPNInterfaceName_Empty(t *testing.T) { + orig := GetVPNInterfaceName() + t.Cleanup(func() { SetVPNInterfaceName(orig) }) + + SetVPNInterfaceName("") + assert.Equal(t, "", GetVPNInterfaceName()) +} + +// TestSetVPNInterfaceName_OverwritesPrevious verifies that the second call wins. +func TestSetVPNInterfaceName_OverwritesPrevious(t *testing.T) { + orig := GetVPNInterfaceName() + t.Cleanup(func() { SetVPNInterfaceName(orig) }) + + SetVPNInterfaceName("utun1") + SetVPNInterfaceName("utun9") + assert.Equal(t, "utun9", GetVPNInterfaceName()) +} \ No newline at end of file diff --git a/client/net/env_mobile_test.go b/client/net/env_mobile_test.go new file mode 100644 index 000000000..4a83488e1 --- /dev/null +++ b/client/net/env_mobile_test.go @@ -0,0 +1,39 @@ +//go:build ios || android + +package net + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestAdvancedRouting_Mobile verifies that AdvancedRouting always returns true +// on mobile platforms. +func TestAdvancedRouting_Mobile(t *testing.T) { + assert.True(t, AdvancedRouting(), "AdvancedRouting must always be true on mobile") +} + +// TestInit_Mobile verifies that Init is a no-op and does not panic. +func TestInit_Mobile(t *testing.T) { + // Should not panic. + Init() + // After Init, AdvancedRouting must still return true. + assert.True(t, AdvancedRouting()) +} + +// TestSetVPNInterfaceName_Mobile verifies that SetVPNInterfaceName is a no-op +// and does not panic. +func TestSetVPNInterfaceName_Mobile(t *testing.T) { + // Should not panic for any input. + SetVPNInterfaceName("utun0") + SetVPNInterfaceName("") +} + +// TestGetVPNInterfaceName_Mobile verifies that GetVPNInterfaceName always +// returns an empty string on mobile. +func TestGetVPNInterfaceName_Mobile(t *testing.T) { + // Even after a SetVPNInterfaceName call (no-op), the getter returns "". + SetVPNInterfaceName("utun0") + assert.Equal(t, "", GetVPNInterfaceName(), "GetVPNInterfaceName must return empty string on mobile") +} \ No newline at end of file diff --git a/client/net/net_darwin_test.go b/client/net/net_darwin_test.go new file mode 100644 index 000000000..069825863 --- /dev/null +++ b/client/net/net_darwin_test.go @@ -0,0 +1,286 @@ +//go:build darwin && !ios + +package net + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +// resetBoundIfaces clears global state before each test that manipulates it. +func resetBoundIfaces(t *testing.T) { + t.Helper() + ClearBoundInterfaces() + t.Cleanup(ClearBoundInterfaces) +} + +// TestIsV6Network verifies the isV6Network helper correctly identifies v6 networks. +func TestIsV6Network(t *testing.T) { + tests := []struct { + network string + want bool + }{ + {"tcp6", true}, + {"udp6", true}, + {"ip6", true}, + {"tcp", false}, + {"udp", false}, + {"tcp4", false}, + {"udp4", false}, + {"ip4", false}, + {"ip", false}, + {"", false}, + // Arbitrary suffix-6 strings + {"unix6", true}, + {"custom6", true}, + } + + for _, tt := range tests { + t.Run(tt.network, func(t *testing.T) { + assert.Equal(t, tt.want, isV6Network(tt.network)) + }) + } +} + +// TestSetBoundInterface_AFINET sets boundIface4 via AF_INET. +func TestSetBoundInterface_AFINET(t *testing.T) { + resetBoundIfaces(t) + + iface := &net.Interface{Index: 5, Name: "en0"} + SetBoundInterface(unix.AF_INET, iface) + + boundIfaceMu.RLock() + got := boundIface4 + boundIfaceMu.RUnlock() + + require.NotNil(t, got) + assert.Equal(t, 5, got.Index) + assert.Equal(t, "en0", got.Name) +} + +// TestSetBoundInterface_AFINET6 sets boundIface6 via AF_INET6. +func TestSetBoundInterface_AFINET6(t *testing.T) { + resetBoundIfaces(t) + + iface := &net.Interface{Index: 7, Name: "utun0"} + SetBoundInterface(unix.AF_INET6, iface) + + boundIfaceMu.RLock() + got := boundIface6 + boundIfaceMu.RUnlock() + + require.NotNil(t, got) + assert.Equal(t, 7, got.Index) + assert.Equal(t, "utun0", got.Name) +} + +// TestSetBoundInterface_Nil verifies nil iface is rejected without panicking. +func TestSetBoundInterface_Nil(t *testing.T) { + resetBoundIfaces(t) + + // Should not panic, just log a warning and leave existing values untouched. + iface := &net.Interface{Index: 1, Name: "en1"} + SetBoundInterface(unix.AF_INET, iface) + + SetBoundInterface(unix.AF_INET, nil) + + boundIfaceMu.RLock() + got := boundIface4 + boundIfaceMu.RUnlock() + + // Value must be unchanged after the rejected nil write. + require.NotNil(t, got) + assert.Equal(t, 1, got.Index) +} + +// TestSetBoundInterface_UnknownAF verifies unknown address families are ignored. +func TestSetBoundInterface_UnknownAF(t *testing.T) { + resetBoundIfaces(t) + + iface := &net.Interface{Index: 3, Name: "en2"} + // Use an address family that is not AF_INET or AF_INET6. + SetBoundInterface(99, iface) + + boundIfaceMu.RLock() + v4, v6 := boundIface4, boundIface6 + boundIfaceMu.RUnlock() + + assert.Nil(t, v4, "unknown AF must not populate boundIface4") + assert.Nil(t, v6, "unknown AF must not populate boundIface6") +} + +// TestClearBoundInterfaces clears both cached interfaces. +func TestClearBoundInterfaces(t *testing.T) { + iface4 := &net.Interface{Index: 1, Name: "en0"} + iface6 := &net.Interface{Index: 2, Name: "en0"} + + SetBoundInterface(unix.AF_INET, iface4) + SetBoundInterface(unix.AF_INET6, iface6) + + ClearBoundInterfaces() + + boundIfaceMu.RLock() + v4, v6 := boundIface4, boundIface6 + boundIfaceMu.RUnlock() + + assert.Nil(t, v4, "boundIface4 must be nil after clear") + assert.Nil(t, v6, "boundIface6 must be nil after clear") +} + +// TestClearBoundInterfaces_Idempotent verifies clearing twice does not panic. +func TestClearBoundInterfaces_Idempotent(t *testing.T) { + ClearBoundInterfaces() + ClearBoundInterfaces() + + boundIfaceMu.RLock() + v4, v6 := boundIface4, boundIface6 + boundIfaceMu.RUnlock() + + assert.Nil(t, v4) + assert.Nil(t, v6) +} + +// TestBoundInterfaceFor_PreferSameFamily verifies v4 iface returned for "tcp" and +// v6 iface returned for "tcp6" when both slots are populated. +func TestBoundInterfaceFor_PreferSameFamily(t *testing.T) { + resetBoundIfaces(t) + + en0 := &net.Interface{Index: 1, Name: "en0"} + en1 := &net.Interface{Index: 2, Name: "en1"} + SetBoundInterface(unix.AF_INET, en0) + SetBoundInterface(unix.AF_INET6, en1) + + got4 := boundInterfaceFor("tcp", "1.2.3.4:80") + require.NotNil(t, got4) + assert.Equal(t, "en0", got4.Name, "tcp should prefer v4 interface") + + got6 := boundInterfaceFor("tcp6", "[::1]:80") + require.NotNil(t, got6) + assert.Equal(t, "en1", got6.Name, "tcp6 should prefer v6 interface") +} + +// TestBoundInterfaceFor_FallbackToOtherFamily returns the other family's iface +// when the preferred slot is empty. +func TestBoundInterfaceFor_FallbackToOtherFamily(t *testing.T) { + resetBoundIfaces(t) + + // Only v4 populated. + en0 := &net.Interface{Index: 1, Name: "en0"} + SetBoundInterface(unix.AF_INET, en0) + + // Asking for v6 should fall back to en0. + got := boundInterfaceFor("tcp6", "[::1]:80") + require.NotNil(t, got) + assert.Equal(t, "en0", got.Name) +} + +// TestBoundInterfaceFor_BothEmpty returns nil when both slots are empty. +func TestBoundInterfaceFor_BothEmpty(t *testing.T) { + resetBoundIfaces(t) + + got := boundInterfaceFor("tcp", "1.2.3.4:80") + assert.Nil(t, got) + + got6 := boundInterfaceFor("tcp6", "[::1]:80") + assert.Nil(t, got6) +} + +// TestZoneInterface_Empty returns nil for empty address. +func TestZoneInterface_Empty(t *testing.T) { + iface := zoneInterface("") + assert.Nil(t, iface) +} + +// TestZoneInterface_NoZone returns nil when address has no zone. +func TestZoneInterface_NoZone(t *testing.T) { + // Regular IPv4 address with port — no zone identifier. + iface := zoneInterface("192.168.1.1:80") + assert.Nil(t, iface) + + // Regular IPv6 address with port — no zone identifier. + iface = zoneInterface("[2001:db8::1]:80") + assert.Nil(t, iface) + + // Plain IPv6 address without port or zone. + iface = zoneInterface("2001:db8::1") + assert.Nil(t, iface) +} + +// TestZoneInterface_InvalidAddress returns nil for completely invalid strings. +func TestZoneInterface_InvalidAddress(t *testing.T) { + iface := zoneInterface("not-an-address") + assert.Nil(t, iface) + + iface = zoneInterface("::::") + assert.Nil(t, iface) +} + +// TestZoneInterface_NonExistentZoneName returns nil for a zone name that does +// not correspond to a real interface on the host. +func TestZoneInterface_NonExistentZoneName(t *testing.T) { + // Use an interface name that is very unlikely to exist. + iface := zoneInterface("fe80::1%nonexistentiface99999") + assert.Nil(t, iface) +} + +// TestZoneInterface_NonExistentZoneIndex returns nil for a zone expressed as an +// integer index that is not in use. +func TestZoneInterface_NonExistentZoneIndex(t *testing.T) { + // Interface index 999999 should not exist on any test machine. + iface := zoneInterface("fe80::1%999999") + assert.Nil(t, iface) +} + +// TestBoundInterfaceFor_SetThenClear verifies that clearing state causes +// boundInterfaceFor to return nil afterwards. +func TestBoundInterfaceFor_SetThenClear(t *testing.T) { + resetBoundIfaces(t) + + en0 := &net.Interface{Index: 1, Name: "en0"} + SetBoundInterface(unix.AF_INET, en0) + + got := boundInterfaceFor("tcp", "1.2.3.4:80") + require.NotNil(t, got, "should return iface while set") + + ClearBoundInterfaces() + + got = boundInterfaceFor("tcp", "1.2.3.4:80") + assert.Nil(t, got, "should return nil after clear") +} + +// TestSetBoundInterface_OverwritesPreviousValue verifies that calling +// SetBoundInterface again updates the stored pointer. +func TestSetBoundInterface_OverwritesPreviousValue(t *testing.T) { + resetBoundIfaces(t) + + first := &net.Interface{Index: 1, Name: "en0"} + second := &net.Interface{Index: 3, Name: "en1"} + + SetBoundInterface(unix.AF_INET, first) + SetBoundInterface(unix.AF_INET, second) + + boundIfaceMu.RLock() + got := boundIface4 + boundIfaceMu.RUnlock() + + require.NotNil(t, got) + assert.Equal(t, "en1", got.Name, "second call should overwrite first") +} + +// TestBoundInterfaceFor_OnlyV6Populated returns v6 iface for v4 network +// when only v6 slot is filled. +func TestBoundInterfaceFor_OnlyV6Populated(t *testing.T) { + resetBoundIfaces(t) + + en1 := &net.Interface{Index: 2, Name: "en1"} + SetBoundInterface(unix.AF_INET6, en1) + + // v4 network, v4 slot empty → should fall back to v6 slot. + got := boundInterfaceFor("tcp", "1.2.3.4:80") + require.NotNil(t, got) + assert.Equal(t, "en1", got.Name) +} \ No newline at end of file