package server import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/http/cookiejar" "net/http/httptest" "net/netip" "net/url" "strings" "testing" "steward/internal/api" "steward/internal/auth" "steward/internal/dhcp4" "steward/internal/dhcpd" "steward/internal/engine" "steward/internal/model" "steward/internal/store" ) type hostIfaces map[string][]netip.Prefix func (h hostIfaces) Addrs(name string) ([]netip.Prefix, error) { a, ok := h[name] if !ok { return nil, errors.New("no interface " + name) } return a, nil } func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil } type env struct { t *testing.T ts *httptest.Server srv *Server dhcp *dhcpd.Server eng *engine.Engine token string // the admin's API token } func newEnv(t *testing.T) *env { t.Helper() ctx := context.Background() st, err := store.Open(":memory:") if err != nil { t.Fatal(err) } hash, _ := auth.HashPassword("secret-pass-1") admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true} if err := st.CreateUser(ctx, admin); err != nil { t.Fatal(err) } tok, pid, sh := auth.NewToken() st.CreateToken(ctx, &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh}) eng, err := engine.New(ctx, st, nil) if err != nil { t.Fatal(err) } ifs := hostIfaces{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}, "eth0.20": {netip.MustParsePrefix("10.0.20.1/24")}} d := dhcpd.New(nil, st, ifs, nil) eng.OnChange = d.SetConfig srv := New(Config{}, eng, d, ifs, nil, nil) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return &env{t: t, ts: ts, srv: srv, dhcp: d, eng: eng, token: tok} } // call makes a request with a token; it returns the status and decodes // the body into out (if given). func (e *env) call(token, method, path string, body any, out any) int { e.t.Helper() var rd io.Reader if body != nil { b, _ := json.Marshal(body) rd = bytes.NewReader(b) } req, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { e.t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) if out != nil && len(b) > 0 { if err := json.Unmarshal(b, out); err != nil { e.t.Fatalf("%s %s: %v: %s", method, path, err, b) } } return resp.StatusCode } func ptr[T any](v T) *T { return &v } func TestLoginSessionAndCSRF(t *testing.T) { e := newEnv(t) jar, _ := cookiejar.New(nil) c := &http.Client{Jar: jar} post := func(path, body, csrf string) *http.Response { req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if csrf != "" { req.Header.Set("X-CSRF-Token", csrf) } resp, err := c.Do(req) if err != nil { t.Fatal(err) } return resp } if r := post("/auth/login", `{"username":"admin","password":"wrong"}`, ""); r.StatusCode != 401 { t.Errorf("wrong password: %d", r.StatusCode) } r := post("/auth/login", `{"username":"admin","password":"secret-pass-1"}`, "") var me api.Whoami json.NewDecoder(r.Body).Decode(&me) if r.StatusCode != 200 || me.CSRFToken == "" || !me.Admin { t.Fatalf("login: %d %+v", r.StatusCode, me) } body := `{"prefix":"10.0.10.0/24","interface":"eth0","pools":["10.0.10.100-10.0.10.199"]}` if r := post("/subnets", body, ""); r.StatusCode != 403 { t.Errorf("change without CSRF token: %d", r.StatusCode) } if r := post("/subnets", body, me.CSRFToken); r.StatusCode != 201 { b, _ := io.ReadAll(r.Body) t.Errorf("change with CSRF token: %d %s", r.StatusCode, b) } h, _ := e.srv.store.ListHistory(context.Background(), 1, 0) if len(h) != 1 || h[0].Via != "web" || h[0].Username != "admin" { t.Errorf("history %+v", h) } } func TestConfigFlow(t *testing.T) { e := newEnv(t) var mr api.MutationResponse // A dry run changes nothing. sub := api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0"), Pools: &[]string{"10.0.10.100-10.0.10.199"}, Options: &[]api.OptionInput{{Option: "routers", Value: "10.0.10.1"}, {Option: "domain-name-servers", Value: "10.0.10.2,10.0.10.3"}}} if code := e.call(e.token, "POST", "/subnets?dry_run=1", sub, &mr); code != 200 || mr.Applied || len(mr.Changes) != 1 { t.Fatalf("dry run: %d %+v", code, mr) } if code := e.call(e.token, "POST", "/subnets", sub, &mr); code != 201 || !mr.Applied { t.Fatalf("create: %d %+v", code, mr) } var sn model.Subnet json.Unmarshal(mr.Object, &sn) if sn.ID == 0 || sn.Options[1].Value != "10.0.10.2, 10.0.10.3" { t.Errorf("created %+v", sn) } // An invalid change is refused with the problem. var eb api.ErrorBody bad := api.SubnetInput{Prefix: ptr("10.0.0.0/16"), Interface: ptr("eth1")} if code := e.call(e.token, "POST", "/subnets", bad, &eb); code != 422 || eb.Error.Problems[0].Code != "subnet_overlap" { t.Errorf("overlap: %d %+v", code, eb) } if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "dhcp-lease-time", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "unknown option") { t.Errorf("unknown option: %d %+v", code, eb) } if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "51", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "set the lease time instead") { t.Errorf("reserved option: %d %+v", code, eb) } // Reservations; by name, address or MAC. res := api.ReservationInput{MAC: ptr("AA-BB-CC-00-00-01"), IP: ptr("10.0.10.20"), Hostname: ptr("nas")} if code := e.call(e.token, "POST", "/reservations", res, &mr); code != 201 { t.Fatalf("reservation: %d %+v", code, mr) } var rv api.Reservation if code := e.call(e.token, "GET", "/reservations/aa:bb:cc:00:00:01", nil, &rv); code != 200 || rv.IP.String() != "10.0.10.20" || rv.SubnetName != "office" { t.Errorf("get reservation: %d %+v", code, rv) } if code := e.call(e.token, "PATCH", "/subnets/office", api.SubnetInput{LeaseTime: ptr(3600)}, &mr); code != 200 || !mr.Applied { t.Errorf("update by name: %d %+v", code, mr) } var subs []api.Subnet e.call(e.token, "GET", "/subnets", nil, &subs) if len(subs) != 1 || subs[0].LeaseTime != 3600 || subs[0].Usage.PoolSize != 100 || subs[0].Usage.Reservations != 1 { t.Errorf("subnets %+v", subs[0]) } // Undo the lease time change. var hs []model.HistoryEntry e.call(e.token, "GET", "/history", nil, &hs) var rr api.MutationResponse if code := e.call(e.token, "POST", "/history/"+itoa(hs[0].ID)+"/revert", nil, &rr); code != 200 || !rr.Applied || rr.DryRun { t.Fatalf("revert: %d %+v", code, rr) } if e.eng.State().Subnets[0].LeaseTime != 0 { t.Error("revert didn't undo the lease time") } // Deleting the subnet takes its reservation along. var dr api.MutationResponse if code := e.call(e.token, "DELETE", "/subnets/10.0.10.0_24", nil, &dr); code != 200 || len(dr.Changes) != 2 { t.Errorf("delete: %d %+v", code, dr) } } func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) } func TestLeasesThroughDHCP(t *testing.T) { e := newEnv(t) var mr api.MutationResponse e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("iot"), Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr) // A client gets an address. mac := net.HardwareAddr{0xaa, 0, 0, 0, 0, 1} disc := dhcp4.NewRequest(dhcp4.Discover, mac, 1) off := e.dhcp.Handle(context.Background(), "eth0.20", disc) if off == nil { t.Fatal("no offer") } req := dhcp4.NewRequest(dhcp4.Request, mac, 2) req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1"))) req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr)) req.Set(dhcp4.OptHostName, []byte("sensor")) if ack := e.dhcp.Handle(context.Background(), "eth0.20", req); ack == nil || ack.Packet.Type() != dhcp4.Ack { t.Fatal("no ack") } var leases []api.Lease if code := e.call(e.token, "GET", "/leases?q=sensor", nil, &leases); code != 200 || len(leases) != 1 || leases[0].SubnetName != "iot" || leases[0].State != "active" { t.Fatalf("leases: %d %+v", code, leases) } ip := leases[0].IP.String() // Make it a reservation. if code := e.call(e.token, "POST", "/leases/"+ip+"/reserve", nil, &mr); code != 201 || !mr.Applied { t.Fatalf("reserve: %d %+v", code, mr) } var rv api.Reservation json.Unmarshal(mr.Object, &rv) if rv.MAC != mac.String() || rv.Hostname != "sensor" || rv.Lease == nil { t.Errorf("reservation from lease %+v", rv) } var st api.Status if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Leases["active"] != 1 || st.Subnets[0].Usage.ReservationsLeased != 1 || st.Messages["DHCPACK"] != 1 { t.Errorf("status: %d %+v", code, st) } var evs []api.Event e.call(e.token, "GET", "/events?mac="+mac.String(), nil, &evs) if len(evs) != 4 || evs[0].Type != "DHCPACK" { t.Errorf("events %+v", evs) } // Details: on their own, or with ?details=1. if len(evs[0].Options) != 0 { t.Errorf("list with options: %+v", evs[0]) } var ev api.Event if code := e.call(e.token, "GET", "/events/"+fmt.Sprint(evs[0].ID), nil, &ev); code != 200 || ev.Direction != "out" || len(ev.Options) == 0 { t.Errorf("event: %d %+v", code, ev) } found := false for _, o := range ev.Options { found = found || o.Name == "lease-time" && o.Value == "43200 s (12h)" } if !found { t.Errorf("no lease time in %+v", ev.Options) } e.call(e.token, "GET", "/events?details=1&mac="+mac.String(), nil, &evs) if len(evs[3].Options) == 0 || evs[3].Direction != "in" { t.Errorf("details=1: %+v", evs[3]) } if code := e.call(e.token, "GET", "/events/99999", nil, nil); code != 404 { t.Errorf("missing event: %d", code) } // By subnet: its addresses, and DISCOVERs on its interface. e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, nil) for ref, want := range map[string]int{"iot": 4, "10.0.20.0/24": 4, "office": 0} { if code := e.call(e.token, "GET", "/events?subnet="+url.QueryEscape(ref), nil, &evs); code != 200 || len(evs) != want { t.Errorf("events of subnet %s: %d %+v", ref, code, evs) } } if code := e.call(e.token, "GET", "/events?subnet=nope", nil, nil); code != 404 && code != 400 { t.Errorf("unknown subnet: %d", code) } if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 { t.Errorf("delete lease: %d", code) } if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 404 { t.Errorf("delete again: %d", code) } var audit []model.AuditEvent e.call(e.token, "GET", "/audit?action=lease.", nil, &audit) if len(audit) != 1 || audit[0].Object != "lease "+ip { t.Errorf("audit %+v", audit) } } func TestPermissions(t *testing.T) { e := newEnv(t) var tok api.Token if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "leases"}, &tok); code != 201 || tok.Secret == "" { t.Fatalf("token: %d %+v", code, tok) } if code := e.call(tok.Secret, "GET", "/leases", nil, nil); code != 200 { t.Errorf("leases with a leases:read token: %d", code) } var eb api.ErrorBody if code := e.call(tok.Secret, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, &eb); code != 403 || !strings.Contains(eb.Error.Message, "config:write") { t.Errorf("change with a read token: %d %+v", code, eb) } if code := e.call(tok.Secret, "GET", "/users", nil, nil); code != 403 { t.Errorf("users with a restricted token: %d", code) } // A restricted token can't make a wider one. if code := e.call(tok.Secret, "POST", "/me/tokens", api.TokenInput{Name: "wider", Role: "operator"}, nil); code != 403 { t.Errorf("wider token: %d", code) } if code := e.call("", "GET", "/status", nil, nil); code != 401 { t.Errorf("no token: %d", code) } if code := e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil); code != 401 { t.Errorf("bad token: %d", code) } // The last administrator stays. var users []api.User e.call(e.token, "GET", "/users", nil, &users) if code := e.call(e.token, "PATCH", "/users/"+itoa(users[0].ID), api.UserInput{Admin: ptr(false)}, &eb); code != 409 { t.Errorf("demoting the last admin: %d", code) } } func TestImportKea(t *testing.T) { e := newEnv(t) cfg := `{"Dhcp4": { "interfaces-config": {"interfaces": ["eth0", "eth0.20"]}, "subnet4": [{"subnet": "10.0.10.0/24", "pools": [{"pool": "10.0.10.100 - 10.0.10.199"}], "option-data": [{"name": "routers", "data": "10.0.10.1"}], "reservations": [{"hw-address": "aa:bb:cc:00:00:01", "ip-address": "10.0.10.20", "hostname": "nas"}]}, {"subnet": "172.16.0.0/24", "pools": [{"pool": "172.16.0.10-172.16.0.20"}]}], "hooks-libraries": []}}` leases := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context\n" + "10.0.10.150,aa:bb:cc:00:00:09,,43200,4000000000,1,0,0,tv,0,\n10.9.9.9,aa:bb:cc:00:00:0a,,43200,4000000000,9,0,0,x,0,\n" var eb api.ErrorBody if code := e.call(e.token, "POST", "/import/kea", KeaImport{Config: cfg, Leases: []string{leases}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "172.16.0.0/24") { t.Fatalf("subnet without an interface: %d %+v", code, eb) } in := KeaImport{Config: cfg, Leases: []string{leases}, Interfaces: map[string]string{"172.16.0.0/24": "eth0.20"}} var ir api.ImportResult if code := e.call(e.token, "POST", "/import/kea", in, &ir); code != 200 || !ir.Applied || ir.Leases != 1 || len(ir.Changes) != 3 { t.Fatalf("import: %d %+v", code, ir) } if !strings.Contains(strings.Join(ir.Skipped, " "), "hooks-libraries") || !strings.Contains(strings.Join(ir.Notes, " "), "outside the imported subnets") { t.Errorf("notes %v skipped %v", ir.Notes, ir.Skipped) } if l := e.dhcp.Lease(netip.MustParseAddr("10.0.10.150")); l == nil || l.Hostname != "tv" || l.Interface != "eth0" { t.Errorf("imported lease %+v", l) } // Importing again needs replace. if code := e.call(e.token, "POST", "/import/kea", in, &eb); code != 409 { t.Errorf("second import: %d", code) } in.Replace = true var again api.ImportResult if code := e.call(e.token, "POST", "/import/kea?dry_run=1", in, &again); code != 200 || again.Applied || len(again.Changes) != 0 { t.Errorf("the same import again changes something: %d %+v", code, again) } // A name given in Steward survives a new import. e.call(e.token, "PATCH", "/subnets/10.0.10.0_24", api.SubnetInput{Name: ptr("office")}, nil) var third api.ImportResult if code := e.call(e.token, "POST", "/import/kea", in, &third); code != 200 || len(third.Changes) != 0 || e.eng.State().FindSubnet("office") == nil { t.Errorf("import over a named subnet: %d %+v", code, third) } } func TestTokenRotation(t *testing.T) { e := newEnv(t) var tok api.Token e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok) var rot api.Token if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil { t.Fatalf("rotate: %d %+v", code, rot) } if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 { t.Errorf("rotated token %+v", rot) } if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { t.Errorf("new secret: %d", code) } if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { t.Errorf("old secret in its grace period: %d", code) } var me api.Whoami e.call(rot.Secret, "GET", "/auth/whoami", nil, &me) if me.TokenID != tok.ID { t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID) } // Without a grace period the old secret stops at once. var rot2 api.Token e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2) if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 { t.Errorf("old secret without grace: %d", code) } if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { t.Errorf("newest secret: %d", code) } if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 { t.Errorf("a grace over 7 days: %d", code) } // A restricted token can't rotate a wider one. var narrow api.Token e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "leases"}, &narrow) if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 { t.Errorf("narrow token rotating a wider one: %d", code) } if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 { t.Errorf("a token rotating itself: %d", code) } } func TestInterfacesExclude(t *testing.T) { e := newEnv(t) e.srv.cfg.ExcludeInterfaces = []string{"eth0.*", "docker*"} // A subnet on an excluded interface the host doesn't have. if c := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("172.17.0.0/16"), Interface: ptr("docker0")}, nil); c != 201 { t.Fatalf("create: %d", c) } var ifs []api.HostInterface e.call(e.token, "GET", "/interfaces", nil, &ifs) if len(ifs) != 1 || ifs[0].Name != "eth0" { t.Errorf("interfaces: %+v", ifs) } } func TestAddressOrderAPI(t *testing.T) { e := newEnv(t) var mr api.MutationResponse in := api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), PoolOrder: ptr("random"), AddressOrder: ptr("Reverse"), Pools: &[]string{"10.0.20.100-10.0.20.110:random", "10.0.20.10-10.0.20.20"}} if code := e.call(e.token, "POST", "/subnets", in, &mr); code != 201 { t.Fatalf("create: %d %+v", code, mr) } sn := e.eng.State().Subnets[0] // The pools keep the order given. if sn.PoolOrder != "random" || sn.AddressOrder != "reverse" || sn.Pools[0].String() != "10.0.20.100-10.0.20.110:random" || sn.Pools[1].Order != "" { t.Errorf("subnet %+v", sn) } // "listed" and "default" are the defaults, stored as "". if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{PoolOrder: ptr("listed"), AddressOrder: ptr("default")}, nil); code != 200 { t.Errorf("patch: %d", code) } if sn = e.eng.State().Subnets[0]; sn.PoolOrder != "" || sn.AddressOrder != "" { t.Errorf("defaults %+v", sn) } var eb api.ErrorBody if code := e.call(e.token, "PATCH", "/settings", api.SettingsInput{AddressOrder: ptr("lowest")}, &eb); code != 422 || eb.Error.Problems[0].Code != "address_order" { t.Errorf("bad order: %d %+v", code, eb) } if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{Pools: &[]string{"10.0.20.10-10.0.20.20:lowest"}}, nil); code != 400 { t.Errorf("bad pool order: %d", code) } } func TestReserveByClientID(t *testing.T) { e := newEnv(t) e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, nil) get := func(id byte) netip.Addr { m := net.HardwareAddr{0xaa, 0, 0, 0, 0, id} disc := dhcp4.NewRequest(dhcp4.Discover, m, 1) disc.Set(dhcp4.OptClientID, []byte{0xff, id}) off := e.dhcp.Handle(context.Background(), "eth0.20", disc) req := dhcp4.NewRequest(dhcp4.Request, m, 2) req.Set(dhcp4.OptClientID, []byte{0xff, id}) req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1"))) req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr)) e.dhcp.Handle(context.Background(), "eth0.20", req) return off.Packet.YIAddr } a, b := get(1), get(2) var mr api.MutationResponse if code := e.call(e.token, "POST", "/leases/"+a.String()+"/reserve", map[string]any{}, &mr); code != 201 { t.Fatalf("reserve: %d", code) } var rv api.Reservation json.Unmarshal(mr.Object, &rv) if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" { t.Errorf("both: %+v", rv) } var eb0 api.ErrorBody if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.10.30"}, &eb0); code != 400 { t.Errorf("reserve outside the subnet: %d %+v", code, eb0) } if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.20.200", "by": "client_id"}, &mr); code != 201 { t.Fatalf("reserve by id: %d", code) } rv = api.Reservation{} json.Unmarshal(mr.Object, &rv) if rv.MAC != "" || rv.ClientID != "ff02" || rv.IP.String() != "10.0.20.200" { t.Errorf("by client id: %+v", rv) } var eb api.ErrorBody if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(10)}, &eb); code != 422 || eb.Error.Problems[0].Code != "lease_time_range" { t.Errorf("short lease time: %d %+v", code, eb) } if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(7 * 86400)}, nil); code != 200 || e.eng.State().ReservationByID(rv.ID).LeaseTime != 7*86400 { t.Errorf("lease time: %d", code) } } func TestAccessAPI(t *testing.T) { e := newEnv(t) var mr api.MutationResponse if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Access: ptr("allow-list"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr); code != 201 || len(mr.Warnings) == 0 || mr.Warnings[0].Code != "allow_list_empty" { t.Fatalf("create: %d %+v", code, mr.Warnings) } in := api.SubnetInput{Allow: &[]model.ClientMatch{{Match: "AA-BB-CC-*", Comment: " sonos "}, {Match: "id 01:AA:BB"}}, Deny: &[]model.ClientMatch{{Match: "aabbcc000001"}}} if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", in, nil); code != 200 { t.Fatalf("patch: %d", code) } sn := e.eng.State().Subnets[0] if sn.Access != "allow-list" || sn.Allow[0] != (model.ClientMatch{Match: "aa:bb:cc:*", Comment: "sonos"}) || sn.Allow[1].Match != "id 01aabb" || sn.Deny[0].Match != "aa:bb:cc:00:00:01" { t.Errorf("subnet %+v %+v %+v", sn.Access, sn.Allow, sn.Deny) } if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Deny: &[]model.ClientMatch{{Match: "nonsense"}}}, nil); code != 400 { t.Errorf("bad entry: %d", code) } var eb api.ErrorBody if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("whitelist")}, &eb); code != 422 || eb.Error.Problems[0].Code != "access_mode" { t.Errorf("bad mode: %d %+v", code, eb) } // "deny-list" is the default, stored as "". e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("deny-list")}, nil) if sn = e.eng.State().Subnets[0]; sn.Access != "" || len(sn.Allow) != 2 { t.Errorf("deny-list keeps the allow list: %+v", sn) } }