two/internal/dhcpd/state_test.go
GnomeZworc e44f3aca96
f-46: dhcpd: add the decision engine and the persisted state #46
Store porte la configuration du subnet et les réservations par MAC, et les
persiste dans un .state écrit de façon atomique (temp + chmod 0600 + rename).
Chaque écriture remplace le fichier, donc un chmod externe est effacé.

Les snapshots sont à la fois la forme sérialisée et la forme d'entrée des
ordres : validation au moment où l'agent pousse, pas quand un guest demande une
adresse. Les hôtes sont triés par MAC, base du digest de L3.

Handle rend (nil, nil) — le silence est un retour normal — pour un type de
message non traité, un subnet non configuré ou une MAC inconnue. La MAC est
normalisée à l'écriture comme au chargement.

58 tests, -race propre, dix mutations toutes détectées.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:17:58 +02:00

346 lines
8.7 KiB
Go

package dhcpd
import (
"encoding/json"
"errors"
"net"
"os"
"path/filepath"
"testing"
)
func statePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "vp-admin_br-000001.state")
}
func testSubnetSnapshot() SubnetSnapshot {
return SubnetSnapshot{
Network: "10.0.5.0/24",
InterfaceIP: "10.0.5.1",
VPCRoute: "10.0.0.0/16",
DefaultGateway: "10.0.5.254",
}
}
func testHostSnapshot() HostSnapshot {
return HostSnapshot{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true}
}
func loadedStore(t *testing.T) (*Store, string) {
t.Helper()
path := statePath(t)
s := NewStore(path)
if err := s.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
return s, path
}
func TestStore_LoadCreatesTheStateFileWhenAbsent(t *testing.T) {
_, path := loadedStore(t)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("the state file must be created on load: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600: the file exposes tenant mac and ip", got)
}
}
func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) {
s, path := loadedStore(t)
if err := os.Chmod(path, 0o644); err != nil {
t.Fatalf("Chmod: %v", err)
}
if err := s.SetHost(testHostSnapshot()); err != nil {
t.Fatalf("SetHost: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got)
}
}
func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) {
path := statePath(t)
if err := os.WriteFile(path, nil, stateFileMode); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewStore(path)
if err := s.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if _, configured := s.Subnet(); configured {
t.Error("an empty state file must not report a configured subnet")
}
}
func TestStore_LoadRejectsCorruptedState(t *testing.T) {
path := statePath(t)
if err := os.WriteFile(path, []byte("{not json"), stateFileMode); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := NewStore(path).Load(); err == nil {
t.Fatal("a corrupted state file must be reported, not silently ignored")
}
}
func TestStore_SetSubnetIsPersisted(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetSubnet(testSubnetSnapshot()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
reloaded := NewStore(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
c, configured := reloaded.Subnet()
if !configured {
t.Fatal("subnet lost across a restart")
}
if got := c.Network.String(); got != "10.0.5.0/24" {
t.Errorf("network = %s, want 10.0.5.0/24", got)
}
if !c.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) {
t.Errorf("interface ip = %s, want 10.0.5.1", c.InterfaceIP)
}
if got := c.VPCRoute.String(); got != "10.0.0.0/16" {
t.Errorf("vpc route = %s, want 10.0.0.0/16", got)
}
if !c.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) {
t.Errorf("default gateway = %s, want 10.0.5.254", c.DefaultGateway)
}
}
func TestStore_SetSubnetRejectsAMissingInterfaceIP(t *testing.T) {
s, _ := loadedStore(t)
snap := testSubnetSnapshot()
snap.InterfaceIP = ""
if err := s.SetSubnet(snap); !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestStore_SetSubnetRejectsAnInvalidNetwork(t *testing.T) {
s, _ := loadedStore(t)
snap := testSubnetSnapshot()
snap.Network = "10.0.5.0"
if err := s.SetSubnet(snap); err == nil {
t.Fatal("a network without a prefix length must be rejected")
}
}
func TestStore_SetHostIsPersistedAndFound(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetHost(testHostSnapshot()); err != nil {
t.Fatalf("SetHost: %v", err)
}
reloaded := NewStore(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
mac, err := net.ParseMAC("00:22:33:00:00:0a")
if err != nil {
t.Fatalf("ParseMAC: %v", err)
}
h, known := reloaded.Lookup(mac)
if !known {
t.Fatal("host lost across a restart")
}
if !h.IP.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("ip = %s, want 10.0.5.10", h.IP)
}
if h.VM != "vm-test" {
t.Errorf("vm = %q, want vm-test", h.VM)
}
if !h.DefaultRoute {
t.Error("default route flag lost across a restart")
}
}
func TestStore_SetHostIsIdempotentOnTheSameMAC(t *testing.T) {
s, _ := loadedStore(t)
snap := testHostSnapshot()
if err := s.SetHost(snap); err != nil {
t.Fatalf("SetHost: %v", err)
}
snap.IP = "10.0.5.11"
snap.DefaultRoute = false
if err := s.SetHost(snap); err != nil {
t.Fatalf("SetHost: %v", err)
}
got := s.Snapshot()
if len(got.Hosts) != 1 {
t.Fatalf("hosts = %d, want 1: the mac is the key", len(got.Hosts))
}
if got.Hosts[0].IP != "10.0.5.11" || got.Hosts[0].DefaultRoute {
t.Errorf("entry = %+v, want the second order to have replaced the first", got.Hosts[0])
}
}
func TestStore_SetHostNormalizesTheMACCase(t *testing.T) {
s, _ := loadedStore(t)
snap := testHostSnapshot()
snap.MAC = "00:22:33:AA:BB:CC"
if err := s.SetHost(snap); err != nil {
t.Fatalf("SetHost: %v", err)
}
mac, err := net.ParseMAC("00:22:33:aa:bb:cc")
if err != nil {
t.Fatalf("ParseMAC: %v", err)
}
if _, known := s.Lookup(mac); !known {
t.Error("an uppercase mac must be found in lowercase: the key would diverge")
}
}
func TestStore_LoadNormalizesTheMACCase(t *testing.T) {
path := statePath(t)
raw, err := json.Marshal(Snapshot{Hosts: []HostSnapshot{{
MAC: "00:22:33:AA:BB:CC", IP: "10.0.5.12", VM: "vm-test", DefaultRoute: true,
}}})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(path, raw, stateFileMode); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewStore(path)
if err := s.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
m, err := net.ParseMAC("00:22:33:aa:bb:cc")
if err != nil {
t.Fatalf("ParseMAC: %v", err)
}
if _, known := s.Lookup(m); !known {
t.Error("a reloaded uppercase mac must be keyed in lowercase: the host would silently stop being served")
}
}
func TestStore_SetHostRejectsAMissingMAC(t *testing.T) {
s, _ := loadedStore(t)
snap := testHostSnapshot()
snap.MAC = ""
if err := s.SetHost(snap); !errors.Is(err, ErrNoMAC) {
t.Fatalf("error = %v, want ErrNoMAC", err)
}
}
func TestStore_SetHostRejectsAnInvalidIP(t *testing.T) {
s, _ := loadedStore(t)
snap := testHostSnapshot()
snap.IP = "10.0.5.300"
if err := s.SetHost(snap); err == nil {
t.Fatal("an invalid host ip must be rejected")
}
}
func TestStore_DelHostRemovesTheEntry(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetHost(testHostSnapshot()); err != nil {
t.Fatalf("SetHost: %v", err)
}
if err := s.DelHost("00:22:33:00:00:0A"); err != nil {
t.Fatalf("DelHost: %v", err)
}
reloaded := NewStore(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := len(reloaded.Snapshot().Hosts); got != 0 {
t.Errorf("hosts = %d, want 0 after deletion", got)
}
}
func TestStore_DelHostOnAnUnknownMACIsNotAnError(t *testing.T) {
s, _ := loadedStore(t)
if err := s.DelHost("00:22:33:ff:ff:ff"); err != nil {
t.Errorf("deleting an absent entry must be idempotent, got %v", err)
}
}
func TestStore_DelHostRejectsAnInvalidMAC(t *testing.T) {
s, _ := loadedStore(t)
if err := s.DelHost("not-a-mac"); err == nil {
t.Fatal("an invalid mac must be reported")
}
}
func TestStore_SnapshotSortsHostsByMAC(t *testing.T) {
s, _ := loadedStore(t)
for _, mac := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} {
snap := testHostSnapshot()
snap.MAC = mac
if err := s.SetHost(snap); err != nil {
t.Fatalf("SetHost: %v", err)
}
}
hosts := s.Snapshot().Hosts
for i := 1; i < len(hosts); i++ {
if hosts[i-1].MAC >= hosts[i].MAC {
t.Fatalf("hosts are not sorted: %v", hosts)
}
}
}
func TestStore_PersistLeavesNoTemporaryFileBehind(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetHost(testHostSnapshot()); err != nil {
t.Fatalf("SetHost: %v", err)
}
entries, err := os.ReadDir(filepath.Dir(path))
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
if len(entries) != 1 {
t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries)
}
}
func TestStore_PersistedStateIsValidJSON(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetSubnet(testSubnetSnapshot()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if err := s.SetHost(testHostSnapshot()); err != nil {
t.Fatalf("SetHost: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var snap Snapshot
if err := json.Unmarshal(raw, &snap); err != nil {
t.Fatalf("the state file must stay parseable: %v", err)
}
if snap.Subnet == nil || len(snap.Hosts) != 1 {
t.Errorf("snapshot = %+v, want one subnet and one host", snap)
}
}