release / release (push) Successful in 5m53s
- 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>
146 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|