Files
steward/internal/server/server_test.go
T
abrown38andClaude Opus 5.5 8a69038ab5 Admin name at init, token rotation, read-only forms, token role presets
- stewardd init asks for the first administrator's name on a terminal.
- Tokens can be rotated (steward token rotate, POST /tokens/{id}/rotate,
  Rotate in the web UI), with a grace period for the old secret, as in
  Jarl. Schema migration 2 adds the token columns.
- Read-only users see the settings and a subnet's settings locked,
  instead of editable fields without a Save button.
- The New token dialog only offers roles within what the user may do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:37 +01:00

383 lines
15 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"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)
}
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)
}
}