Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
207 lines
7.8 KiB
Go
207 lines
7.8 KiB
Go
package api_test
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
func TestDeviceListShapeFiltersAndPaging(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
a := e.device(t, "Core A", "yyz", "core")
|
|
b := e.device(t, "Edge B", "yul", "edge")
|
|
e.device(t, "Edge C", "yul", "edge", func(d *store.Device) { d.Kind = store.KindVM })
|
|
e.iface(t, a, 1, "ge-0/0/0", "uplink")
|
|
e.iface(t, a, 2, "ge-0/0/1", "", func(i *store.Interface) { i.OperStatus = 2 }) // admin up, oper down
|
|
e.iface(t, a, 3, "ge-0/0/2", "", func(i *store.Interface) { i.AdminStatus, i.OperStatus = 2, 2 })
|
|
e.iface(t, a, 4, "mgmt", "", func(i *store.Interface) { i.Excluded = true })
|
|
if err := e.st.TouchDevice(t.Context(), a.ID, e.clk.Now().Add(-10*time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.st.SetPollState(t.Context(), b.ID, store.PollDegraded, []string{"slow"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var l api.DeviceList
|
|
rec := e.getJSON(t, "/api/v1/devices", &l)
|
|
if l.Total != 3 || l.Page != 1 || l.PerPage != 100 || len(l.Items) != 3 {
|
|
t.Fatalf("envelope: %+v", l)
|
|
}
|
|
assertNoLeaks(t, "device list", rec.Body.Bytes())
|
|
got := map[string]api.DeviceSummary{}
|
|
for _, d := range l.Items {
|
|
got[d.Name] = d
|
|
}
|
|
core := got["Core A"]
|
|
if core.InterfacesTotal != 3 || core.InterfacesDown != 1 || core.Reachability != api.ReachUp || core.PollState != "ok" ||
|
|
core.LastSeen == nil || core.Site != "yyz" || core.Warnings == nil || core.Profile != "generic" || core.MgmtIP == "" {
|
|
t.Errorf("core summary: %+v", core)
|
|
}
|
|
if edge := got["Edge B"]; edge.PollState != "degraded" || len(edge.Warnings) != 1 || edge.Reachability != api.ReachUnknown {
|
|
t.Errorf("edge summary: %+v", edge)
|
|
}
|
|
|
|
for q, want := range map[string]int{"site=yul": 2, "role=core": 1, "kind=vm": 1, "q=edge": 2, "q=EDGE+b": 1, "site=yul&role=core": 0} {
|
|
var f api.DeviceList
|
|
e.getJSON(t, "/api/v1/devices?"+q, &f)
|
|
if len(f.Items) != want || f.Total != int64(want) {
|
|
t.Errorf("%s: %d items, total %d, want %d", q, len(f.Items), f.Total, want)
|
|
}
|
|
}
|
|
var p api.DeviceList
|
|
e.getJSON(t, "/api/v1/devices?per_page=2&page=2", &p)
|
|
if len(p.Items) != 1 || p.Total != 3 || p.Page != 2 || p.PerPage != 2 {
|
|
t.Errorf("page 2: %+v", p)
|
|
}
|
|
e.getJSON(t, "/api/v1/devices?state=up", &p)
|
|
if p.Total != 1 || p.Items[0].Name != "Core A" {
|
|
t.Errorf("state=up: %+v", p)
|
|
}
|
|
e.getJSON(t, "/api/v1/devices?state=unknown&per_page=1&page=2", &p)
|
|
if p.Total != 2 || len(p.Items) != 1 {
|
|
t.Errorf("state=unknown page 2: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestListLimitsAreProblems(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
e.getJSON(t, "/api/v1/devices?per_page=1000", new(api.DeviceList))
|
|
for _, path := range []string{
|
|
"/api/v1/devices?per_page=1001", "/api/v1/devices?per_page=0", "/api/v1/devices?page=0",
|
|
"/api/v1/devices?kind=toaster", "/api/v1/devices?state=sideways", "/api/v1/interfaces?per_page=5000",
|
|
"/api/v1/identity-reviews?per_page=1001",
|
|
} {
|
|
isProblem(t, e.do("GET", path, ""), 422)
|
|
}
|
|
}
|
|
|
|
func TestDeviceFacets(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
e.device(t, "A", "yyz", "core")
|
|
e.device(t, "B", "yul", "edge")
|
|
e.device(t, "C", "", "", func(d *store.Device) { d.Kind = store.KindVM })
|
|
gone := e.device(t, "D", "sea", "lab")
|
|
if err := e.st.RetireDevice(t.Context(), gone.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var f api.DeviceFacets
|
|
e.getJSON(t, "/api/v1/devices/facets", &f)
|
|
if strings.Join(f.Sites, ",") != "yul,yyz" || strings.Join(f.Roles, ",") != "core,edge" || strings.Join(f.Kinds, ",") != "network,vm" {
|
|
t.Errorf("facets: %+v", f)
|
|
}
|
|
var empty api.DeviceFacets
|
|
newEnv(t, 0).getJSON(t, "/api/v1/devices/facets", &empty)
|
|
if empty.Sites == nil || empty.Roles == nil || empty.Kinds == nil {
|
|
t.Errorf("empty facets must be [] not null: %+v", empty)
|
|
}
|
|
}
|
|
|
|
func TestReachabilityFromUpSeriesAndLastSeen(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
up := e.device(t, "Up", "", "")
|
|
half := e.device(t, "Half", "", "")
|
|
down := e.device(t, "Down", "", "")
|
|
stale := e.device(t, "Stale", "", "")
|
|
maint := e.device(t, "Maint", "", "", func(d *store.Device) { d.PollState = store.PollMaintenance })
|
|
off := e.device(t, "Off", "", "", func(d *store.Device) { d.Enabled = false })
|
|
for _, d := range []*store.Device{up, half, down, maint, off} {
|
|
_ = e.st.TouchDevice(t.Context(), d.ID, e.clk.Now())
|
|
}
|
|
_ = e.st.TouchDevice(t.Context(), stale.ID, e.clk.Now().Add(-time.Hour))
|
|
e.q.fn = func(_, expr string, _, _ time.Time, _ time.Duration) ([]byte, error) {
|
|
switch {
|
|
case strings.Contains(expr, "nmon_icmp_up"):
|
|
return vector(
|
|
vrow{map[string]string{"device": up.Slug}, 1}, vrow{map[string]string{"device": half.Slug}, 1},
|
|
vrow{map[string]string{"device": down.Slug}, 0}), nil
|
|
case strings.Contains(expr, "nmon_snmp_up"):
|
|
return vector(
|
|
vrow{map[string]string{"device": up.Slug}, 1}, vrow{map[string]string{"device": half.Slug}, 0},
|
|
vrow{map[string]string{"device": down.Slug}, 0}), nil
|
|
}
|
|
return emptyMatrix, nil
|
|
}
|
|
var l api.DeviceList
|
|
e.getJSON(t, "/api/v1/devices", &l)
|
|
got := map[string]string{}
|
|
for _, d := range l.Items {
|
|
got[d.Name] = d.Reachability
|
|
}
|
|
want := map[string]string{"Up": "up", "Half": "warning", "Down": "down", "Stale": "down", "Maint": "maintenance", "Off": "unknown"}
|
|
for n, w := range want {
|
|
if got[n] != w {
|
|
t.Errorf("%s: %q, want %q (all: %v)", n, got[n], w, got)
|
|
}
|
|
}
|
|
// a store that is down falls back to last_seen
|
|
e.q.fn = func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
|
|
return nil, fmt.Errorf("down")
|
|
}
|
|
e.getJSON(t, "/api/v1/devices", &l)
|
|
for _, d := range l.Items {
|
|
if d.Name == "Up" && d.Reachability != "up" || d.Name == "Stale" && d.Reachability != "down" {
|
|
t.Errorf("fallback: %s is %s", d.Name, d.Reachability)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInterfaceSearch(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
a := e.device(t, "Core A", "", "")
|
|
b := e.device(t, "Edge B", "", "")
|
|
up := e.iface(t, a, 1, "xe-0/0/1", "Uplink to ISP")
|
|
e.iface(t, a, 2, "xe-0/0/2", "customer")
|
|
e.iface(t, b, 1, "Gi1", "UPLINK backup")
|
|
hc32 := e.iface(t, b, 2, "Gi2", "uplink-32", func(i *store.Interface) { i.HasHCCounters = false })
|
|
e.iface(t, b, 3, "Gi3", "uplink-bad", func(i *store.Interface) { i.Unreliable = true })
|
|
gone := e.iface(t, b, 4, "Gi4", "uplink-gone")
|
|
if err := e.st.RetireInterface(t.Context(), gone.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.q.fn = func(_, expr string, _, _ time.Time, _ time.Duration) ([]byte, error) {
|
|
if !strings.Contains(expr, "ifHCInOctets") {
|
|
return emptyMatrix, nil
|
|
}
|
|
return vector(
|
|
vrow{map[string]string{"iface": up.ID, "k": "in_hc"}, 1000}, vrow{map[string]string{"iface": up.ID, "k": "out_hc"}, 2000},
|
|
vrow{map[string]string{"iface": hc32.ID, "k": "in_32"}, 31}, vrow{map[string]string{"iface": hc32.ID, "k": "out_32"}, 32},
|
|
), nil
|
|
}
|
|
var l api.InterfaceList
|
|
rec := e.getJSON(t, "/api/v1/interfaces?q=UPLINK", &l)
|
|
assertNoLeaks(t, "interface search", rec.Body.Bytes())
|
|
if l.Total != 4 || len(l.Items) != 4 {
|
|
t.Fatalf("hits: %+v", l)
|
|
}
|
|
byAlias := map[string]api.InterfaceHit{}
|
|
for _, h := range l.Items {
|
|
byAlias[h.IfAlias] = h
|
|
}
|
|
h := byAlias["Uplink to ISP"]
|
|
if h.ID != up.ID || h.Device.Slug != a.Slug || h.Device.Name != "Core A" || h.SpeedBPS != 1_000_000_000 ||
|
|
h.InBPS == nil || *h.InBPS != 1000 || *h.OutBPS != 2000 || h.OperStatus != 1 || h.AdminStatus != 1 {
|
|
t.Errorf("hit: %+v", h)
|
|
}
|
|
if x := byAlias["uplink-32"]; x.InBPS == nil || *x.InBPS != 31 {
|
|
t.Errorf("32-bit interface should use ifInOctets rates: %+v", x)
|
|
}
|
|
if x := byAlias["uplink-bad"]; x.InBPS != nil || x.OutBPS != nil {
|
|
t.Errorf("unreliable interface must have null rates: %+v", x)
|
|
}
|
|
if x := byAlias["UPLINK backup"]; x.InBPS != nil {
|
|
t.Errorf("no data means null: %+v", x)
|
|
}
|
|
e.getJSON(t, "/api/v1/interfaces?q=xe-0&per_page=1&page=2", &l)
|
|
if l.Total != 2 || len(l.Items) != 1 || l.Page != 2 {
|
|
t.Errorf("paging: %+v", l)
|
|
}
|
|
e.getJSON(t, "/api/v1/interfaces?q=zzz", &l)
|
|
if l.Total != 0 || l.Items == nil {
|
|
t.Errorf("no hits: %+v", l)
|
|
}
|
|
}
|