Files
nmon/internal/api/alert_down_test.go
jleung ad220eae58 Interface-down alerts are opt-in: per port (bell toggle, bulk) and via groups
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
2026-10-23 05:04:41 +00:00

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)
}
}