interfaces.alert_down / groups.alert_down (migration 9); interface_down rule OptIn; non-opted ports' open alerts close silently as retired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
122 lines
4.8 KiB
Go
122 lines
4.8 KiB
Go
package api_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
func alertDownEnv(t *testing.T) (*env, *store.Device, store.Interface, store.Interface, *int) {
|
|
changes := new(int)
|
|
e := newEnvDeps(t, 0, func(d *api.Deps) { d.OnAlertDownChange = func() { *changes++ } })
|
|
d := e.device(t, "sw1", "yyz", "access")
|
|
return e, d, e.iface(t, d, 1, "e1", ""), e.iface(t, d, 2, "e2", ""), changes
|
|
}
|
|
|
|
func TestInterfaceAlertDownIsOffByDefaultAndTogglesPerPort(t *testing.T) {
|
|
e, d, a, b, changes := alertDownEnv(t)
|
|
var dv api.DeviceView
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug, &dv)
|
|
for _, i := range dv.Interfaces {
|
|
if i.AlertDown || i.AlertDownEffective || i.AlertDownVia != "" {
|
|
t.Fatalf("%s alerts by default: %+v", i.IfName, i.AlertDownState)
|
|
}
|
|
}
|
|
rec := e.do("PUT", "/api/v1/interfaces/"+a.ID, `{"alert_down":true}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var iv api.InterfaceView
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &iv); err != nil || !iv.AlertDown || !iv.AlertDownEffective || iv.AlertDownVia != "port" {
|
|
t.Fatalf("PUT body %s (%v)", rec.Body, err)
|
|
}
|
|
if *changes != 1 {
|
|
t.Errorf("evaluator told %d times, want 1", *changes)
|
|
}
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug, &dv)
|
|
if !dv.Interfaces[0].AlertDown || dv.Interfaces[1].AlertDown {
|
|
t.Fatalf("device view flags: %+v %+v", dv.Interfaces[0].AlertDownState, dv.Interfaces[1].AlertDownState)
|
|
}
|
|
var hits api.InterfaceList
|
|
e.getJSON(t, "/api/v1/interfaces?q=e", &hits)
|
|
got := map[string]string{}
|
|
for _, h := range hits.Items {
|
|
got[h.ID] = h.AlertDownVia
|
|
}
|
|
if got[a.ID] != "port" || got[b.ID] != "" {
|
|
t.Fatalf("search hits via = %v", got)
|
|
}
|
|
isProblem(t, e.do("PUT", "/api/v1/interfaces/nope", `{"alert_down":true}`), http.StatusNotFound)
|
|
audit, _, _ := e.st.ListAudit(t.Context(), store.Page{})
|
|
if len(audit) == 0 || audit[0].Action != "interface.alert_down" {
|
|
t.Fatalf("audit = %+v", audit)
|
|
}
|
|
}
|
|
|
|
func TestDeviceBulkAlertDownAllOrListedPorts(t *testing.T) {
|
|
e, d, a, _, _ := alertDownEnv(t)
|
|
url := "/api/v1/devices/" + d.Slug + "/interfaces/alert-down"
|
|
var res api.AlertDownResult
|
|
rec := e.do("POST", url, `{"on":true}`)
|
|
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &res) != nil || res.Changed != 2 {
|
|
t.Fatalf("all on: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec = e.do("POST", url, fmt.Sprintf(`{"on":false,"ifaces":[%q]}`, a.ID))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("one off: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var dv api.DeviceView
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug, &dv)
|
|
if dv.Interfaces[0].AlertDown || !dv.Interfaces[1].AlertDown {
|
|
t.Fatalf("flags after partial off: %+v %+v", dv.Interfaces[0].AlertDownState, dv.Interfaces[1].AlertDownState)
|
|
}
|
|
other := e.device(t, "sw2", "yyz", "access")
|
|
foreign := e.iface(t, other, 1, "x1", "")
|
|
isProblem(t, e.do("POST", url, fmt.Sprintf(`{"on":true,"ifaces":[%q]}`, foreign.ID)), http.StatusUnprocessableEntity)
|
|
isProblem(t, e.do("POST", "/api/v1/devices/nope/interfaces/alert-down", `{"on":true}`), http.StatusNotFound)
|
|
rec = e.do("POST", url, `{"on":false}`)
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug, &dv)
|
|
if rec.Code != http.StatusOK || dv.Interfaces[1].AlertDown {
|
|
t.Fatalf("all off: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestGroupAlertDownMakesMemberPortsEffectiveVia(t *testing.T) {
|
|
e, d, a, _, changes := alertDownEnv(t)
|
|
rec := e.do("POST", "/api/v1/groups", `{"name":"Uplinks","kind":"role","alert_down":true}`)
|
|
var g api.GroupSummary
|
|
if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &g) != nil || !g.AlertDown {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec = e.do("POST", "/api/v1/groups/"+g.ID+"/members", fmt.Sprintf(`{"interface_id":%q,"direction":"toward"}`, a.ID))
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("member: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var dv api.DeviceView
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug, &dv)
|
|
if i := dv.Interfaces[0]; i.AlertDown || !i.AlertDownEffective || i.AlertDownVia != "group:Uplinks" {
|
|
t.Fatalf("member port: %+v", i.AlertDownState)
|
|
}
|
|
// A PUT without alert_down leaves the flag; an explicit false clears it.
|
|
rec = e.do("PUT", "/api/v1/groups/"+g.ID, `{"name":"Uplinks 2","kind":"role"}`)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"alert_down":true`) {
|
|
t.Fatalf("rename: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec = e.do("PUT", "/api/v1/groups/"+g.ID, `{"name":"Uplinks 2","kind":"role","alert_down":false}`)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"alert_down":false`) {
|
|
t.Fatalf("clear: %d %s", rec.Code, rec.Body)
|
|
}
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug, &dv)
|
|
if dv.Interfaces[0].AlertDownEffective {
|
|
t.Fatal("still effective after the group flag cleared")
|
|
}
|
|
if *changes < 4 {
|
|
t.Errorf("evaluator told %d times; group writes must tell it", *changes)
|
|
}
|
|
}
|