two/internal/api/dhcp/digest_test.go
GnomeZworc 5b0d1bfa60
f-46: dhcpapi: always send served on the wire #46
Served portait json:",omitempty" : sur un probe dont la MAC n'est pas réservée,
false — qui est toute la réponse — disparaissait du fil, et un opérateur lisait
un champ absent plutôt qu'une réponse négative.

Trouvé en rejouant contre un vrai serveur les exemples de diagnostic écrits pour
la documentation.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-09 22:38:14 +02:00

185 lines
4.7 KiB
Go

package dhcpapi
import (
"encoding/json"
"strings"
"testing"
)
func subnet() Subnet {
return Subnet{
Network: "10.0.5.0/24",
InterfaceIP: "10.0.5.1",
VPCRoute: "10.0.0.0/16",
DefaultGateway: "10.0.5.254",
}
}
func hosts() []Host {
return []Host{
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-a", DefaultRoute: true},
{MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", VM: "vm-b"},
}
}
func digestOf(t *testing.T, s State) string {
t.Helper()
d, err := Digest(s)
if err != nil {
t.Fatalf("Digest: %v", err)
}
return d
}
func TestDigest_IsStableAcrossHostOrder(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
reversed := hosts()
reversed[0], reversed[1] = reversed[1], reversed[0]
b := State{Subnet: &sub, Hosts: reversed}
if digestOf(t, a) != digestOf(t, b) {
t.Error("host order must not change the digest: the watchdog would report a phantom drift")
}
}
func TestDigest_IsStableAcrossMACCase(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
upper := hosts()
upper[0].MAC = "00:22:33:00:00:0A"
b := State{Subnet: &sub, Hosts: upper}
if digestOf(t, a) != digestOf(t, b) {
t.Error("mac case must not change the digest")
}
}
func TestDigest_IsStableAcrossIPv4InIPv6Notation(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
mapped := subnet()
mapped.InterfaceIP = "::ffff:10.0.5.1"
b := State{Subnet: &mapped, Hosts: hosts()}
if digestOf(t, a) != digestOf(t, b) {
t.Error("the same address written in ipv4-mapped form must hash alike")
}
}
func TestDigest_ChangesWhenAHostIPChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
moved := hosts()
moved[0].IP = "10.0.5.99"
b := State{Subnet: &sub, Hosts: moved}
if digestOf(t, a) == digestOf(t, b) {
t.Error("a changed reservation must change the digest")
}
}
func TestDigest_ChangesWhenTheDefaultRouteFlagChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
flipped := hosts()
flipped[0].DefaultRoute = false
b := State{Subnet: &sub, Hosts: flipped}
if digestOf(t, a) == digestOf(t, b) {
t.Error("the default route flag is part of the served state")
}
}
func TestDigest_ChangesWhenTheSubnetChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
other := subnet()
other.DefaultGateway = "10.0.5.253"
b := State{Subnet: &other, Hosts: hosts()}
if digestOf(t, a) == digestOf(t, b) {
t.Error("the subnet configuration is part of the served state")
}
}
func TestDigest_DistinguishesNoSubnetFromAConfiguredOne(t *testing.T) {
sub := subnet()
configured := State{Subnet: &sub}
bare := State{}
if digestOf(t, configured) == digestOf(t, bare) {
t.Error("an unconfigured subnet must not hash like a configured one")
}
}
func TestDigest_EmptyAndNilHostsHashAlike(t *testing.T) {
if digestOf(t, State{Hosts: nil}) != digestOf(t, State{Hosts: []Host{}}) {
t.Error("nil and empty host lists describe the same state")
}
}
func TestDigest_RejectsAnInvalidMAC(t *testing.T) {
if _, err := Digest(State{Hosts: []Host{{MAC: "nope", IP: "10.0.5.10"}}}); err == nil {
t.Fatal("an invalid mac must be reported, not hashed")
}
}
func TestDigest_RejectsAnInvalidIP(t *testing.T) {
if _, err := Digest(State{Hosts: []Host{{MAC: "00:22:33:00:00:0a", IP: "10.0.5.300"}}}); err == nil {
t.Fatal("an invalid ip must be reported, not hashed")
}
}
func TestCanonical_RejectsADuplicateMAC(t *testing.T) {
dup := []Host{
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"},
{MAC: "00:22:33:00:00:0A", IP: "10.0.5.11"},
}
if _, err := Canonical(State{Hosts: dup}); err == nil {
t.Fatal("the same mac twice is an inconsistent state, not something to hash")
}
}
func TestCanonical_NormalizesTheNetworkToItsBaseAddress(t *testing.T) {
sub := subnet()
sub.Network = "10.0.5.42/24"
got, err := CanonicalSubnet(sub)
if err != nil {
t.Fatalf("CanonicalSubnet: %v", err)
}
if got.Network != "10.0.5.0/24" {
t.Errorf("network = %s, want 10.0.5.0/24", got.Network)
}
}
func TestCanonical_SortsHostsByMAC(t *testing.T) {
unsorted := []Host{
{MAC: "00:22:33:00:00:0c", IP: "10.0.5.12"},
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"},
}
got, err := Canonical(State{Hosts: unsorted})
if err != nil {
t.Fatalf("Canonical: %v", err)
}
if got.Hosts[0].MAC != "00:22:33:00:00:0a" {
t.Errorf("hosts = %v, want sorted by mac", got.Hosts)
}
}
func TestResponse_ServedIsAlwaysOnTheWire(t *testing.T) {
raw, err := json.Marshal(Response{OK: true, Served: false})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if !strings.Contains(string(raw), `"served":false`) {
t.Errorf("response = %s, want an explicit served:false — omitting it makes \"not served\" indistinguishable from a missing field when probing by hand", raw)
}
}