Files
akvorado/common/helpers/subnetmap_test.go
Vincent Bernat 6118bb7aac common/helpers: convert SubnetMap to github.com/gaissmai/bart
I did not benchmark it myself, but it was benchmarked here:
 https://github.com/osrg/gobgp/issues/1414#issuecomment-3067255941

Of course, no guarantee that this benchmark matches our use cases.
Moreover, SubnetMap have been optimized to avoid parsing keys all
the time.

Also, the interface is a bit nicer and it uses netip.Prefix directly.

The next step is to convert outlet/routing/provider/bmp.
2025-08-16 09:38:44 +02:00

720 lines
18 KiB
Go

// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
package helpers_test
import (
"net/netip"
"slices"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2"
"akvorado/common/helpers/yaml"
"akvorado/common/helpers"
)
func TestSubnetMapUnmarshalHook(t *testing.T) {
var nilMap map[string]string
cases := []struct {
Description string
Input any
Tests map[string]string
Error bool
YAML any
}{
{
Description: "nil",
Input: nilMap,
Tests: map[string]string{
"::ffff:203.0.113.1": "",
},
}, {
Description: "empty",
Input: gin.H{},
Tests: map[string]string{
"::ffff:203.0.113.1": "",
},
}, {
Description: "IPv4 subnet",
Input: gin.H{"203.0.113.0/24": "customer1"},
Tests: map[string]string{
"::ffff:203.0.113.18": "customer1",
"::ffff:203.0.113.16": "customer1",
"203.0.113.16": "",
"::ffff:203.0.1.1": "",
"203.0.1.1": "",
"2001:db8:1::12": "",
},
}, {
Description: "IPv4 IP",
Input: gin.H{"203.0.113.1": "customer1"},
Tests: map[string]string{
"::ffff:203.0.113.1": "customer1",
"2001:db8:1::12": "",
},
YAML: gin.H{"203.0.113.1/32": "customer1"},
}, {
Description: "IPv6 subnet",
Input: gin.H{"2001:db8:1::/64": "customer2"},
Tests: map[string]string{
"2001:db8:1::1": "customer2",
"2001:db8:1::2": "customer2",
"2001:db8:2::2": "",
},
}, {
Description: "IPv6-mapped-IPv4 subnet",
Input: gin.H{"::ffff:203.0.113.0/120": "customer2"},
Tests: map[string]string{
"::ffff:203.0.113.10": "customer2",
"::ffff:203.0.112.10": "",
},
YAML: gin.H{"203.0.113.0/24": "customer2"},
}, {
Description: "IPv6 IP",
Input: gin.H{"2001:db8:1::1": "customer2"},
Tests: map[string]string{
"2001:db8:1::1": "customer2",
"2001:db8:1::2": "",
"2001:db8:2::2": "",
},
YAML: gin.H{"2001:db8:1::1/128": "customer2"},
}, {
Description: "Invalid subnet (1)",
Input: gin.H{"192.0.2.1/38": "customer"},
Error: true,
}, {
Description: "Invalid subnet (2)",
Input: gin.H{"192.0.2.1/255.0.255.0": "customer"},
Error: true,
}, {
Description: "Invalid subnet (3)",
Input: gin.H{"2001:db8::/1000": "customer"},
Error: true,
}, {
Description: "Invalid IP",
Input: gin.H{"200.33.300.1": "customer"},
Error: true,
}, {
Description: "Random key",
Input: gin.H{"kfgdjgkfj": "customer"},
Error: true,
}, {
Description: "Single value",
Input: "customer",
Tests: map[string]string{
"::ffff:203.0.113.4": "customer",
"2001:db8::1": "customer",
},
YAML: map[string]string{
"::/0": "customer",
},
},
}
for _, tc := range cases {
if tc.YAML == nil {
if tc.Error {
tc.YAML = map[string]string{}
} else {
tc.YAML = tc.Input
}
}
t.Run(tc.Description, func(t *testing.T) {
var tree helpers.SubnetMap[string]
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &tree,
ErrorUnused: true,
Metadata: nil,
DecodeHook: helpers.SubnetMapUnmarshallerHook[string](),
})
if err != nil {
t.Fatalf("NewDecoder() error:\n%+v", err)
}
err = decoder.Decode(tc.Input)
if err != nil && !tc.Error {
t.Fatalf("Decode() error:\n%+v", err)
} else if err == nil && tc.Error {
t.Fatal("Decode() did not return an error")
}
got := map[string]string{}
for k := range tc.Tests {
v, _ := tree.Lookup(netip.MustParseAddr(k))
got[k] = v
}
if diff := helpers.Diff(got, tc.Tests); diff != "" {
t.Fatalf("Decode() (-got, +want):\n%s", diff)
}
// Try to unmarshal with YAML
buf, err := yaml.Marshal(tree)
if err != nil {
t.Fatalf("yaml.Marshal() error:\n%+v", err)
}
got = map[string]string{}
if err := yaml.Unmarshal(buf, &got); err != nil {
t.Fatalf("yaml.Unmarshal() error:\n%+v", err)
}
if diff := helpers.Diff(got, tc.YAML); diff != "" {
t.Fatalf("MarshalYAML() (-got, +want):\n%s", diff)
}
})
}
}
func TestSubnetMapUnmarshalHookWithMapValue(t *testing.T) {
type SomeStruct struct {
Blip string
Blop string
}
cases := []struct {
Pos helpers.Pos
Input gin.H
Expected gin.H
}{
{
Pos: helpers.Mark(),
Input: gin.H{
"blip": "some",
"blop": "thing",
},
Expected: gin.H{
"::/0": gin.H{
"Blip": "some",
"Blop": "thing",
},
},
}, {
Pos: helpers.Mark(),
Input: gin.H{
"::/0": gin.H{
"blip": "some",
"blop": "thing",
},
"203.0.113.14": gin.H{
"blip": "other",
"blop": "stuff",
},
},
Expected: gin.H{
"::/0": gin.H{
"Blip": "some",
"Blop": "thing",
},
"203.0.113.14/32": gin.H{
"Blip": "other",
"Blop": "stuff",
},
},
},
}
for _, tc := range cases {
t.Run("sub", func(t *testing.T) {
var tree helpers.SubnetMap[SomeStruct]
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &tree,
ErrorUnused: true,
Metadata: nil,
DecodeHook: helpers.SubnetMapUnmarshallerHook[SomeStruct](),
})
if err != nil {
t.Fatalf("%sNewDecoder() error:\n%+v", tc.Pos, err)
}
err = decoder.Decode(tc.Input)
if err != nil {
t.Fatalf("%sDecode() error:\n%+v", tc.Pos, err)
}
if diff := helpers.Diff(tree.ToMap(), tc.Expected); diff != "" {
t.Fatalf("%sDecode() (-got, +want):\n%s", tc.Pos, diff)
}
})
}
}
func TestSubnetMapParseKey(t *testing.T) {
cases := []struct {
Description string
Input string
Expected string
Error bool
}{
{
Description: "valid ipv4 address",
Input: "10.0.0.1",
Expected: "::ffff:10.0.0.1/128",
},
{
Description: "valid ipv4 subnet",
Input: "10.0.0.0/28",
Expected: "::ffff:10.0.0.0/124",
},
{
Description: "invalid ipv4 address",
Input: "10.0.0",
Error: true,
},
{
Description: "valid ipv6 address",
Input: "2001:db8:2::a",
Expected: "2001:db8:2::a/128",
},
{
Description: "valid ipv6 subnet",
Input: "2001:db8:2::/48",
Expected: "2001:db8:2::/48",
},
{
Description: "invalid ipv6 address",
Input: "2001:",
Error: true,
},
{
Description: "invalid string",
Input: "foo-bar",
Error: true,
},
}
for _, tc := range cases {
t.Run(tc.Description, func(t *testing.T) {
res, err := helpers.SubnetMapParseKey(tc.Input)
if err != nil && !tc.Error {
t.Fatalf("SubnetMapParseKey() error:\n%+v", err)
} else if err == nil && tc.Error {
t.Fatal("SubnetMapParseKey() did not return an error")
}
if diff := helpers.Diff(res.String(), tc.Expected); err == nil && diff != "" {
t.Fatalf("Decode() (-got, +want):\n%s", diff)
}
})
}
}
func TestToMap(t *testing.T) {
input := helpers.MustNewSubnetMap(map[string]string{
"2001:db8::/64": "hello",
"::ffff:192.0.2.0/120": "bye",
})
got := input.ToMap()
expected := map[string]string{
"2001:db8::/64": "hello",
"192.0.2.0/24": "bye",
}
if diff := helpers.Diff(got, expected); diff != "" {
t.Fatalf("ToMap() (-got, +want):\n%s", diff)
}
}
func TestSubnetMapLookup(t *testing.T) {
t.Run("nil", func(t *testing.T) {
var sm *helpers.SubnetMap[string]
value, ok := sm.Lookup(netip.MustParseAddr("::ffff:192.0.2.1"))
if ok || value != "" {
t.Fatalf("Lookup() == value=%q, ok=%v", value, ok)
}
})
t.Run("empty", func(t *testing.T) {
sm := &helpers.SubnetMap[string]{}
value, ok := sm.Lookup(netip.MustParseAddr("::ffff:192.0.2.1"))
if ok || value != "" {
t.Fatalf("Lookup() == value=%q, ok=%v", value, ok)
}
})
t.Run("populated", func(t *testing.T) {
sm := helpers.MustNewSubnetMap(map[string]string{
"192.0.2.0/24": "customer1",
"2001:db8::/64": "customer2",
"10.0.0.1": "specific",
})
cases := []struct {
ip string
expected string
found bool
}{
{"::ffff:192.0.2.1", "customer1", true},
{"::ffff:192.0.2.255", "customer1", true},
{"::ffff:192.0.3.1", "", false},
{"::ffff:10.0.0.1", "specific", true},
{"::ffff:10.0.0.2", "", false},
{"2001:db8::1", "customer2", true},
{"2001:db8:1::1", "", false},
}
for _, tc := range cases {
t.Run(tc.ip, func(t *testing.T) {
value, ok := sm.Lookup(netip.MustParseAddr(tc.ip))
if ok != tc.found || value != tc.expected {
t.Fatalf("Lookup(%s) = (%q, %v), want (%q, %v)", tc.ip, value, ok, tc.expected, tc.found)
}
})
}
})
}
func TestSubnetMapLookupOrDefault(t *testing.T) {
sm := helpers.MustNewSubnetMap(map[string]string{
"192.0.2.0/24": "customer1",
})
t.Run("found", func(t *testing.T) {
value := sm.LookupOrDefault(netip.MustParseAddr("::ffff:192.0.2.1"), "default")
if value != "customer1" {
t.Fatalf("LookupOrDefault() = %q, want %q", value, "customer1")
}
})
t.Run("not found", func(t *testing.T) {
value := sm.LookupOrDefault(netip.MustParseAddr("::ffff:192.0.3.1"), "default")
if value != "default" {
t.Fatalf("LookupOrDefault() = %q, want %q", value, "default")
}
})
t.Run("nil", func(t *testing.T) {
var sm *helpers.SubnetMap[string]
value := sm.LookupOrDefault(netip.MustParseAddr("::ffff:192.0.2.1"), "default")
if value != "default" {
t.Fatalf("LookupOrDefault() = %q, want %q", value, "default")
}
})
}
func TestSubnetMapSet(t *testing.T) {
sm := &helpers.SubnetMap[string]{}
t.Run("set IPv6 subnet", func(t *testing.T) {
prefix := netip.MustParsePrefix("2001:db8::/64")
sm.Set(prefix, "test-value")
value, ok := sm.Lookup(netip.MustParseAddr("2001:db8::1"))
if !ok || value != "test-value" {
t.Fatalf("Lookup() = (%q, %v), want (%q, %v)", value, ok, "test-value", true)
}
})
t.Run("set IPv4-mapped IPv6 subnet", func(t *testing.T) {
prefix := netip.MustParsePrefix("::ffff:192.0.2.0/120")
sm.Set(prefix, "ipv4-mapped")
value, ok := sm.Lookup(netip.MustParseAddr("::ffff:192.0.2.1"))
if !ok || value != "ipv4-mapped" {
t.Fatalf("Lookup() = (%q, %v), want (%q, %v)", value, ok, "ipv4-mapped", true)
}
})
t.Run("overwrite existing value", func(t *testing.T) {
prefix := netip.MustParsePrefix("2001:db8::/64")
sm.Set(prefix, "new-value")
value, ok := sm.Lookup(netip.MustParseAddr("2001:db8::1"))
if !ok || value != "new-value" {
t.Fatalf("Lookup() = (%q, %v), want (%q, %v)", value, ok, "new-value", true)
}
})
}
func TestSubnetMapSetPanic(t *testing.T) {
sm := &helpers.SubnetMap[string]{}
t.Run("panic on IPv4 subnet", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("Set() should panic with IPv4")
}
}()
prefix := netip.MustParsePrefix("192.0.2.0/24")
sm.Set(prefix, "should-panic")
})
}
func TestSubnetMapUpdate(t *testing.T) {
sm := &helpers.SubnetMap[int]{}
t.Run("update new value", func(t *testing.T) {
prefix := netip.MustParsePrefix("2001:db8::/64")
sm.Update(prefix, func(old int, exists bool) int {
if !exists {
return 42
}
return old + 1
})
value, ok := sm.Lookup(netip.MustParseAddr("2001:db8::1"))
if !ok || value != 42 {
t.Fatalf("Lookup() = (%d, %v), want (%d, %v)", value, ok, 42, true)
}
})
t.Run("update existing value", func(t *testing.T) {
prefix := netip.MustParsePrefix("2001:db8::/64")
sm.Update(prefix, func(old int, exists bool) int {
if exists {
return old + 10
}
return 0
})
value, ok := sm.Lookup(netip.MustParseAddr("2001:db8::1"))
if !ok || value != 52 {
t.Fatalf("Lookup() = (%d, %v), want (%d, %v)", value, ok, 52, true)
}
})
}
func TestSubnetMapUpdatePanic(t *testing.T) {
sm := &helpers.SubnetMap[int]{}
t.Run("panic on IPv4 subnet", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("Update() should panic with IPv4")
}
}()
prefix := netip.MustParsePrefix("192.0.2.0/24")
sm.Update(prefix, func(old int, exists bool) int { return 1 })
})
}
func TestSubnetMapAll(t *testing.T) {
t.Run("nil", func(t *testing.T) {
var sm *helpers.SubnetMap[string]
count := 0
for range sm.All() {
count++
}
if count != 0 {
t.Fatalf("All() count = %d, want %d", count, 0)
}
})
t.Run("empty", func(t *testing.T) {
sm := &helpers.SubnetMap[string]{}
count := 0
for range sm.All() {
count++
}
if count != 0 {
t.Fatalf("All() count = %d, want %d", count, 0)
}
})
t.Run("populated", func(t *testing.T) {
sm := helpers.MustNewSubnetMap(map[string]string{
"2001:db8::/64": "ipv6",
"::ffff:192.0.2.0/120": "ipv4-mapped",
})
items := make(map[string]string)
for prefix, value := range sm.All() {
items[prefix.String()] = value
}
expected := map[string]string{
"2001:db8::/64": "ipv6",
"::ffff:192.0.2.0/120": "ipv4-mapped",
}
if diff := helpers.Diff(items, expected); diff != "" {
t.Fatalf("All() (-got, +want):\n%s", diff)
}
})
}
func TestSubnetMapString(t *testing.T) {
t.Run("empty", func(t *testing.T) {
sm := &helpers.SubnetMap[string]{}
str := sm.String()
expected := "map[]"
if str != expected {
t.Fatalf("String() = %q, want %q", str, expected)
}
})
t.Run("populated", func(t *testing.T) {
sm := helpers.MustNewSubnetMap(map[string]string{
"192.0.2.0/24": "customer",
})
str := sm.String()
if diff := helpers.Diff(str, "map[192.0.2.0/24:customer]"); diff != "" {
t.Fatalf("String() (-got, +want):\n%s", diff)
}
})
}
func TestPrefixTo16(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "IPv4 prefix",
input: "192.0.2.0/24",
expected: "::ffff:192.0.2.0/120",
},
{
name: "IPv4 host",
input: "192.0.2.1/32",
expected: "::ffff:192.0.2.1/128",
},
{
name: "IPv6 prefix unchanged",
input: "2001:db8::/64",
expected: "2001:db8::/64",
},
{
name: "IPv6 host unchanged",
input: "2001:db8::1/128",
expected: "2001:db8::1/128",
},
{
name: "IPv4-mapped IPv6 unchanged",
input: "::ffff:192.0.2.0/120",
expected: "::ffff:192.0.2.0/120",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
prefix := netip.MustParsePrefix(tc.input)
result := helpers.PrefixTo16(prefix)
if result.String() != tc.expected {
t.Fatalf("PrefixTo16(%s) = %s, want %s", tc.input, result, tc.expected)
}
})
}
}
func TestSubnetMapSupernets(t *testing.T) {
t.Run("nil", func(t *testing.T) {
var sm *helpers.SubnetMap[string]
count := 0
for range sm.Supernets(netip.MustParsePrefix("::ffff:192.0.2.1/128")) {
count++
}
if count != 0 {
t.Fatalf("Supernets() count = %d, want %d", count, 0)
}
})
t.Run("empty", func(t *testing.T) {
sm := &helpers.SubnetMap[string]{}
count := 0
for range sm.Supernets(netip.MustParsePrefix("::ffff:192.0.2.1/128")) {
count++
}
if count != 0 {
t.Fatalf("Supernets() count = %d, want %d", count, 0)
}
})
t.Run("hierarchical supernets", func(t *testing.T) {
sm := helpers.MustNewSubnetMap(map[string]string{
"192.0.0.0/16": "region",
"192.0.2.0/24": "site",
"192.0.2.0/28": "rack",
})
// Query for 192.0.2.1/32 should find supernets in reverse-CIDR order
var results []string
var prefixes []string
for prefix, value := range sm.Supernets(netip.MustParsePrefix("::ffff:192.0.2.1/128")) {
results = append(results, value)
prefixes = append(prefixes, prefix.String())
}
expectedValues := []string{"rack", "site", "region"}
expectedPrefixes := []string{"::ffff:192.0.2.0/124", "::ffff:192.0.2.0/120", "::ffff:192.0.0.0/112"}
if diff := helpers.Diff(results, expectedValues); diff != "" {
t.Errorf("Supernets() values (-got, +want):\n%s", diff)
}
if diff := helpers.Diff(prefixes, expectedPrefixes); diff != "" {
t.Errorf("Supernets() prefixes (-got, +want):\n%s", diff)
}
count := 0
for range sm.Supernets(netip.MustParsePrefix("::ffff:10.0.0.1/128")) {
count++
}
if count != 0 {
t.Fatalf("Supernets() count = %d, want %d", count, 0)
}
})
}
func TestSubnetMapAllMaybeSorted(t *testing.T) {
t.Run("nil", func(t *testing.T) {
var sm *helpers.SubnetMap[string]
count := 0
for range sm.AllMaybeSorted() {
count++
}
if count != 0 {
t.Fatalf("AllMaybeSorted() count = %d, want %d", count, 0)
}
})
t.Run("empty", func(t *testing.T) {
sm := &helpers.SubnetMap[string]{}
count := 0
for range sm.AllMaybeSorted() {
count++
}
if count != 0 {
t.Fatalf("AllMaybeSorted() count = %d, want %d", count, 0)
}
})
t.Run("sorted vs unsorted", func(t *testing.T) {
sm := helpers.MustNewSubnetMap(map[string]string{
"192.0.2.0/28": "rack",
"192.0.0.0/16": "region",
"192.0.2.0/24": "site",
"2001:db8:1::/64": "ipv6-site1",
"2001:db8::/32": "ipv6-region",
"2001:db8:2::/64": "ipv6-site2",
})
// Collect results from All() (potentially unsorted)
var allPrefixes []string
for prefix := range sm.All() {
allPrefixes = append(allPrefixes, prefix.String())
}
// Collect results from AllMaybeSorted() (sorted during tests)
var sortedPrefixes []string
for prefix := range sm.AllMaybeSorted() {
sortedPrefixes = append(sortedPrefixes, prefix.String())
}
// Expected sorted order: IPv6 addresses first (sorted), then IPv4-mapped IPv6 (sorted)
expectedPrefixes := []string{
"::ffff:192.0.0.0/112",
"::ffff:192.0.2.0/120",
"::ffff:192.0.2.0/124",
"2001:db8::/32",
"2001:db8:1::/64",
"2001:db8:2::/64",
}
// AllMaybeSorted() should be sorted during tests
if diff := helpers.Diff(sortedPrefixes, expectedPrefixes); diff != "" {
t.Errorf("AllMaybeSorted() prefixes (-got, +want):\n%s", diff)
}
// All() and AllMaybeSorted() should contain the same elements (but potentially different order)
if len(allPrefixes) != len(sortedPrefixes) {
t.Errorf("All() returned %d prefixes, AllMaybeSorted() returned %d", len(allPrefixes), len(sortedPrefixes))
}
// Verify that All() and AllMaybeSorted() return different orders (otherwise test is meaningless)
if slices.Equal(allPrefixes, sortedPrefixes) {
t.Skip("All() and AllMaybeSorted() returned identical order")
}
})
}