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>
129 lines
4.4 KiB
Go
129 lines
4.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/netip"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"steward/internal/model"
|
|
)
|
|
|
|
func raw(v any) json.RawMessage {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
func TestStateHistoryAndLeases(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, err := Open(filepath.Join(t.TempDir(), "steward.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
st, err := s.LoadState(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.Settings.EffectiveLeaseTime() != model.DefaultLeaseTime || len(st.Subnets) != 0 || !st.Settings.PingCheck {
|
|
t.Fatalf("new database state: %+v", st)
|
|
}
|
|
ids, _ := s.AllocateIDs(ctx, 2)
|
|
sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
|
|
Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}},
|
|
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}}
|
|
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Disabled: true}
|
|
set := st.Settings
|
|
set.LeaseTime = 3600
|
|
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office",
|
|
Changes: []model.Change{
|
|
{Kind: model.KindSubnet, ID: sn.ID, After: raw(sn)},
|
|
{Kind: model.KindReservation, ID: r.ID, After: raw(r)},
|
|
{Kind: model.KindSettings, Before: raw(st.Settings), After: raw(set)},
|
|
}}
|
|
if err := s.SaveChanges(ctx, e.Changes, e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, _ = s.LoadState(ctx)
|
|
if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" {
|
|
t.Errorf("subnets %+v", st.Subnets)
|
|
}
|
|
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || !st.Reservations[0].Disabled || st.Settings.LeaseTime != 3600 {
|
|
t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings)
|
|
}
|
|
// Undo: the inverse changes, marked as a revert.
|
|
var inv []model.Change
|
|
for _, c := range e.Changes {
|
|
inv = append(inv, c.Inverse())
|
|
}
|
|
u := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "web", Description: "undo", Changes: inv, RevertOf: e.ID}
|
|
if err := s.SaveChanges(ctx, inv, u); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, _ = s.LoadState(ctx)
|
|
if len(st.Subnets) != 0 || len(st.Reservations) != 0 || st.Settings.LeaseTime != model.DefaultLeaseTime {
|
|
t.Errorf("after undo: %+v", st)
|
|
}
|
|
h, err := s.GetHistory(ctx, e.ID)
|
|
if err != nil || h.RevertedBy != u.ID || len(h.Changes) != 3 {
|
|
t.Errorf("history %+v %v", h, err)
|
|
}
|
|
list, _ := s.ListHistory(ctx, 10, 0)
|
|
if len(list) != 2 || list[0].ID != u.ID {
|
|
t.Errorf("history list %+v", list)
|
|
}
|
|
|
|
now := time.Now().Truncate(time.Millisecond)
|
|
l := &model.Lease{IP: netip.MustParseAddr("10.0.10.100"), SubnetID: sn.ID, MAC: "aa:bb:cc:dd:ee:02", Hostname: "laptop", State: model.LeaseActive,
|
|
Start: now, End: now.Add(time.Hour), Interface: "eth0", Updated: now}
|
|
if err := s.PutLease(ctx, l); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l.End = now.Add(2 * time.Hour)
|
|
if err := s.PutLease(ctx, l); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ls, _ := s.LoadLeases(ctx)
|
|
if len(ls) != 1 || !ls[0].End.Equal(l.End) || ls[0].Hostname != "laptop" {
|
|
t.Errorf("leases %+v", ls)
|
|
}
|
|
if n, _ := s.PruneLeases(ctx, now.Add(3*time.Hour)); n != 1 {
|
|
t.Errorf("pruned %d", n)
|
|
}
|
|
if err := s.DeleteLease(ctx, l.IP); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("delete of a pruned lease: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUsersAndAudit(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, err := Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u := &model.User{Username: "admin", PasswordHash: "x", Admin: true}
|
|
if err := s.CreateUser(ctx, u); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CreateUser(ctx, &model.User{Username: "ADMIN"}); !errors.Is(err, ErrConflict) {
|
|
t.Errorf("duplicate user: %v", err)
|
|
}
|
|
tok := &model.Token{UserID: u.ID, Name: "ci", PublicID: "p1", SecretHash: "h"}
|
|
if err := s.CreateToken(ctx, tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := s.GetTokenByPublicID(ctx, "p1")
|
|
if err != nil || got.Username != "admin" || got.Grants != nil {
|
|
t.Errorf("token %+v %v", got, err)
|
|
}
|
|
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "auth.login", Result: "success"})
|
|
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "lease.delete", Object: "10.0.10.5", Result: "success"})
|
|
evs, _ := s.ListAudit(ctx, AuditFilter{Action: "lease."})
|
|
if len(evs) != 1 || evs[0].Object != "10.0.10.5" {
|
|
t.Errorf("audit %+v", evs)
|
|
}
|
|
}
|