go-network-configurator/utils_unit_test.go
James Coleman fae43247de Switch unit tests to testify
Bring an administratively down interface up in AddAddress before writing
its addresses and routes. The kernel installs an address's connected
route only while its link is up, so adding a gateway to a down interface
was rejected as unreachable. A link raised this way is part of the
pre-change state AddAddress restores, so it is returned to down on any
path that does not complete.

Also fix the rollback masking its own error: deleting the address it
added takes the default route with it, since that route resolved its
nexthop through the address's connected subnet. The following RouteDel
then answered ESRCH and was reported in place of "aborted operation due
to loss of internet".
2026-07-10 08:52:52 -05:00

275 lines
8.6 KiB
Go

package netconfig
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
// Round-tripping an IPv4 address through ip2Uint/uint2IP must return the
// original four-byte value.
func TestIP2UintRoundTrip(t *testing.T) {
cases := []string{"0.0.0.0", "1.2.3.4", "192.168.1.255", "255.255.255.255"}
for _, c := range cases {
ip := net.ParseIP(c)
require.NotNil(t, ip, "could not parse %q", c)
got := uint2IP(ip2Uint(ip))
assert.True(t, got.Equal(ip), "round trip of %s: got %s", c, got)
// uint2IP should produce the canonical four-byte representation.
assert.Equal(t, net.IPv4len, len(got), "uint2IP(%s) length", c)
}
}
// ip2Uint must accept both the 16-byte and 4-byte representations of the same
// IPv4 address and yield an identical result.
func TestIP2UintAcceptsBothRepresentations(t *testing.T) {
ip16 := net.ParseIP("10.20.30.40")
ip4 := ip16.To4()
require.NotNil(t, ip4, "expected an IPv4 address")
assert.Equal(t, ip2Uint(ip16), ip2Uint(ip4),
"ip2Uint disagrees for 16-byte and 4-byte forms")
}
func TestAllFF(t *testing.T) {
cases := []struct {
in []byte
want bool
}{
{[]byte{}, true},
{[]byte{0xff}, true},
{[]byte{0xff, 0xff, 0xff, 0xff}, true},
{[]byte{0xff, 0x00}, false},
{[]byte{0x00}, false},
{[]byte{0xff, 0xff, 0xfe}, false},
}
for _, c := range cases {
assert.Equal(t, c.want, allFF(c.in), "allFF(%v)", c.in)
}
}
func TestGetBroadcast(t *testing.T) {
cases := []struct {
cidr string
want string
}{
{"192.168.1.0/24", "192.168.1.255"},
{"10.0.0.0/8", "10.255.255.255"},
{"172.16.5.0/30", "172.16.5.3"},
{"203.0.113.128/25", "203.0.113.255"},
{"fc00::/64", "fc00::ffff:ffff:ffff:ffff"},
}
for _, c := range cases {
_, network, err := net.ParseCIDR(c.cidr)
require.NoError(t, err, "ParseCIDR(%q)", c.cidr)
got := getBroadcast(network)
want := net.ParseIP(c.want)
assert.True(t, got.Equal(want), "getBroadcast(%s) = %s, want %s", c.cidr, got, c.want)
}
}
// getBroadcast must cope with an IPv4 address paired with an IPv4-in-IPv6 mask,
// exercising the family-normalisation branch.
func TestGetBroadcastMixedFamily(t *testing.T) {
network := &net.IPNet{
IP: net.ParseIP("192.168.1.0").To4(),
Mask: net.CIDRMask(96+24, 128), // /24 expressed as a 16-byte mask.
}
got := getBroadcast(network)
want := net.ParseIP("192.168.1.255")
assert.True(t, got.Equal(want), "getBroadcast mixed family = %s, want %s", got, want)
}
func TestSortableDirEntries(t *testing.T) {
dir := t.TempDir()
names := []string{"zebra", "alpha", "mike", "bravo"}
for _, n := range names {
require.NoError(t, os.WriteFile(filepath.Join(dir, n), nil, 0o644))
}
entries, err := os.ReadDir(dir)
require.NoError(t, err)
// Shuffle into a known unsorted order before sorting.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() > entries[j].Name()
})
sortable := sortableDirEntries(entries)
assert.Equal(t, len(names), sortable.Len(), "Len()")
sort.Sort(sortable)
want := []string{"alpha", "bravo", "mike", "zebra"}
for i, w := range want {
assert.Equal(t, w, entries[i].Name(), "sorted[%d]", i)
}
}
func TestIntStringYAML(t *testing.T) {
t.Run("int", func(t *testing.T) {
var v intString
require.NoError(t, yaml.Unmarshal([]byte("42\n"), &v))
require.NotNil(t, v.Int, "Int = %v, want 42", v.Int)
assert.Equal(t, 42, *v.Int)
assert.Nil(t, v.String, "String should be nil")
out, err := yaml.Marshal(v)
require.NoError(t, err)
assert.Equal(t, "42\n", string(out), "marshal")
})
t.Run("string", func(t *testing.T) {
var v intString
require.NoError(t, yaml.Unmarshal([]byte("auto\n"), &v))
require.NotNil(t, v.String, "String = %v, want auto", v.String)
assert.Equal(t, "auto", *v.String)
assert.Nil(t, v.Int, "Int should be nil")
out, err := yaml.Marshal(v)
require.NoError(t, err)
assert.Equal(t, "auto\n", string(out), "marshal")
})
t.Run("empty marshals to null", func(t *testing.T) {
out, err := yaml.Marshal(intString{})
require.NoError(t, err)
assert.Equal(t, "null\n", string(out), "marshal of empty")
})
}
func TestIntStringJSON(t *testing.T) {
t.Run("int", func(t *testing.T) {
var v intString
require.NoError(t, json.Unmarshal([]byte("42"), &v))
require.NotNil(t, v.Int, "Int = %v, want 42", v.Int)
assert.Equal(t, 42, *v.Int)
out, err := json.Marshal(v)
require.NoError(t, err)
assert.Equal(t, "42", string(out), "marshal")
})
t.Run("string", func(t *testing.T) {
var v intString
require.NoError(t, json.Unmarshal([]byte(`"auto"`), &v))
require.NotNil(t, v.String, "String = %v, want auto", v.String)
assert.Equal(t, "auto", *v.String)
out, err := json.Marshal(v)
require.NoError(t, err)
assert.Equal(t, `"auto"`, string(out), "marshal")
})
t.Run("invalid", func(t *testing.T) {
var v intString
err := json.Unmarshal([]byte("{}"), &v)
assert.Error(t, err, "expected error for object input, got nil")
})
}
func TestRunCommand(t *testing.T) {
t.Run("stdout", func(t *testing.T) {
out, err := runCommand(context.Background(), "printf", "line1\nline2\n")
require.NoError(t, err, "unexpected error")
want := []string{"line1", "line2"}
assert.Equal(t, want, out, "out")
})
t.Run("nonzero exit", func(t *testing.T) {
_, err := runCommand(context.Background(), "false")
assert.Error(t, err, "expected error for failing command, got nil")
})
t.Run("missing binary", func(t *testing.T) {
_, err := runCommand(context.Background(), "this-binary-should-not-exist-12345")
assert.Error(t, err, "expected error for missing binary, got nil")
})
}
func TestTestInternet(t *testing.T) {
assert.True(t, testInternet(context.Background(), "test_success", 0),
"test_success sentinel should report success")
assert.False(t, testInternet(context.Background(), "test_fail", 0),
"test_fail sentinel should report failure")
}
func TestFileCopyAndMove(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.txt")
content := []byte("hello world\n")
require.NoError(t, os.WriteFile(src, content, 0o640))
t.Run("fileCopy preserves contents and mode", func(t *testing.T) {
dst := filepath.Join(dir, "copy.txt")
require.NoError(t, fileCopy(src, dst))
got, err := os.ReadFile(dst)
require.NoError(t, err)
assert.Equal(t, content, got, "copied contents")
fi, err := os.Stat(dst)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o640), fi.Mode().Perm(), "copied mode")
})
t.Run("fileCopy missing source errors", func(t *testing.T) {
err := fileCopy(filepath.Join(dir, "nope"), filepath.Join(dir, "out"))
assert.Error(t, err, "expected error for missing source")
})
t.Run("fileMoveCopy removes source", func(t *testing.T) {
moveSrc := filepath.Join(dir, "movesrc.txt")
require.NoError(t, os.WriteFile(moveSrc, content, 0o644))
dst := filepath.Join(dir, "moved.txt")
require.NoError(t, fileMoveCopy(moveSrc, dst))
_, err := os.Stat(moveSrc)
assert.True(t, os.IsNotExist(err), "source should be gone, stat err = %v", err)
got, err := os.ReadFile(dst)
require.NoError(t, err)
assert.Equal(t, content, got, "moved contents")
})
t.Run("fileMove same device renames", func(t *testing.T) {
moveSrc := filepath.Join(dir, "rename-src.txt")
require.NoError(t, os.WriteFile(moveSrc, content, 0o644))
dst := filepath.Join(dir, "renamed.txt")
require.NoError(t, fileMove(moveSrc, dst))
_, err := os.Stat(moveSrc)
assert.True(t, os.IsNotExist(err), "source should be gone after move, stat err = %v", err)
_, err = os.Stat(dst)
assert.NoError(t, err, "destination missing after move")
})
}
func TestNaturalLess(t *testing.T) {
cases := []struct {
a, b string
want bool
why string
}{
{"eth2", "eth10", true, "digit runs compare by value, not character"},
{"eth10", "eth2", false, "reversed"},
{"eth0", "eth1", true, "same width digits"},
{"enp0s3", "enp0s10", true, "embedded digit runs"},
{"eth0", "eth0", false, "equal strings are not less"},
{"eth", "eth0", true, "a prefix sorts before what extends it"},
{"eth0", "eth", false, "reversed prefix"},
{"eth007", "eth7", false, "leading zeros do not change the value"},
{"eth7", "eth007", false, "reversed equal values"},
{"eth1", "wlan0", true, "letters compare by byte"},
{"9", "10", true, "bare numbers"},
{"eth99999999999999999999", "eth999999999999999999999", true, "digit runs too long to parse"},
}
for _, c := range cases {
assert.Equal(t, c.want, naturalLess(c.a, c.b), "naturalLess(%q, %q): %s", c.a, c.b, c.why)
}
}
func TestLeadingDigits(t *testing.T) {
digits, rest := leadingDigits("10s3")
assert.Equal(t, "10", digits)
assert.Equal(t, "s3", rest)
digits, rest = leadingDigits("eth0")
assert.Empty(t, digits)
assert.Equal(t, "eth0", rest)
}