Files
steward/internal/validate/validate_test.go
T
abrown38andClaude Opus 5.5 6ad1bb9a0d
release / release (push) Successful in 5m53s
Disable reservations; subnet enable/disable buttons; page-head CSS fix
- A disabled reservation is kept but ignored: the client gets a pool
  address like any other (steward reservation disable|enable, buttons and
  a checkbox in the web UI). Schema migration 3.
- Subnets get Disable/Enable buttons on their page and in the list.
- A rule left without its selector in style.css broke the page heading's
  layout, so the New ... buttons sat cramped under the description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:02:15 +01:00

146 lines
6.4 KiB
Go

package validate
import (
"net/netip"
"strings"
"testing"
"steward/internal/model"
)
func pool(s string) model.Pool {
p, err := model.ParsePool(s)
if err != nil {
panic(err)
}
return p
}
func good() *model.State {
return &model.State{
Settings: model.DefaultSettings(),
Subnets: []*model.Subnet{
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.199")},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}},
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24")},
},
Reservations: []*model.Reservation{
{ID: 3, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"},
},
}
}
func codes(ps []Problem) string {
var out []string
for _, p := range ps {
out = append(out, p.Severity+":"+p.Code)
}
return strings.Join(out, " ")
}
func TestGoodConfig(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("problems: %v", ps)
}
}
func TestProblems(t *testing.T) {
cases := []struct {
name string
edit func(st *model.State)
want string
}{
{"host bits", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.5/24") }, "error:subnet_prefix"},
{"too small", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.0/31") }, "error:subnet_size"},
{"ipv6", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("2001:db8::/64") }, "error:subnet_prefix"},
{"overlap", func(st *model.State) { st.Subnets[1].Prefix = netip.MustParsePrefix("10.0.0.0/16") }, "error:subnet_overlap"},
{"name taken", func(st *model.State) { st.Subnets[1].Name = "Office" }, "error:subnet_name_taken"},
{"no interface", func(st *model.State) { st.Subnets[0].Interface = "" }, "error:subnet_interface_missing"},
{"bad interface", func(st *model.State) { st.Subnets[0].Interface = "eth 0" }, "error:subnet_interface_name"},
{"no pools", func(st *model.State) {}, "warning:subnet_no_pools"},
{"pool outside", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.11.1-10.0.11.9")} }, "error:pool_outside_subnet"},
{"pool network", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.10.0/24")} }, "error:pool_network_address"},
{"pool overlap", func(st *model.State) {
st.Subnets[0].Pools = append(st.Subnets[0].Pools, pool("10.0.10.150-10.0.10.250"))
}, "error:pool_overlap"},
{"lease time", func(st *model.State) { st.Subnets[0].LeaseTime = 10 }, "error:lease_time_range"},
{"reserved option", func(st *model.State) { st.Settings.Options = []model.OptionValue{{Code: 51, Value: "1"}} }, "error:option_reserved"},
{"bad option", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 6, Value: "dns.example"}} }, "error:option_invalid"},
{"duplicate option", func(st *model.State) {
st.Subnets[0].Options = append(st.Subnets[0].Options, model.OptionValue{Code: 3, Value: "10.0.10.2"})
}, "error:option_duplicate"},
{"router outside", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.11.1"}} }, "warning:router_outside_subnet"},
{"router in pool", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.10.150"}} }, "warning:router_in_pool"},
{"no client", func(st *model.State) { st.Reservations[0].MAC = "" }, "error:reservation_no_client"},
{"no subnet", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("192.168.1.5") }, "error:reservation_no_subnet"},
{"broadcast", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("10.0.10.255") }, "error:reservation_network_address"},
{"ip taken", func(st *model.State) {
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:02", IP: netip.MustParseAddr("10.0.10.20")})
}, "error:reservation_ip_taken"},
{"client taken", func(st *model.State) {
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.21")})
}, "error:reservation_client_taken"},
{"hostname", func(st *model.State) { st.Reservations[0].Hostname = "my_nas" }, "error:reservation_hostname"},
}
for _, c := range cases {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
if c.name == "no pools" {
st.Subnets[1].Pools = nil
}
c.edit(st)
got := codes(Check(st, nil))
if !strings.Contains(" "+got+" ", " "+c.want+" ") {
t.Errorf("%s: got %q, want %s", c.name, got, c.want)
}
}
}
func TestDisabledReservationsDontConflict(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Reservations[0].Disabled = true
// The same client and address again, enabled: fine while the first is
// disabled.
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20")})
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("problems: %v", ps)
}
st.Reservations[0].Disabled = false
if got := codes(Check(st, nil)); !strings.Contains(got, "reservation_ip_taken") {
t.Errorf("enabling it again: %q", got)
}
}
func TestSameMACInTwoSubnets(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.20.20")})
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("a laptop on two VLANs: %v", ps)
}
}
func TestHostInterfaces(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Subnets[0].Pools = []model.Pool{pool("10.0.10.1-10.0.10.199")}
st.Subnets[0].Options = nil
env := &Env{Interfaces: map[string][]netip.Prefix{
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
"wg0": {netip.MustParsePrefix("10.9.0.1/24")},
}}
got := codes(Check(st, env))
for _, want := range []string{"warning:pool_includes_server", "warning:interface_unknown"} {
if !strings.Contains(got, want) {
t.Errorf("got %q, want %s", got, want)
}
}
env.Interfaces["eth0.20"] = []netip.Prefix{netip.MustParsePrefix("192.168.5.1/24")}
if got := codes(Check(st, env)); !strings.Contains(got, "warning:interface_no_address") {
t.Errorf("got %q, want interface_no_address", got)
}
}