Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
205 lines
8.0 KiB
Go
205 lines
8.0 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
type alertsPage struct {
|
|
Total int64 `json:"total"`
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Rule string `json:"rule"`
|
|
State string `json:"state"`
|
|
SuppressedBy []string `json:"suppressed_by"`
|
|
IncidentID string `json:"incident_id"`
|
|
Device struct{ Slug, Name string }
|
|
Ack *struct{ By, Reason string } `json:"ack"`
|
|
} `json:"items"`
|
|
}
|
|
|
|
// seedAlerts stores a firing device_down (suppressed by two parents), a resolved one and a pending one.
|
|
func seedAlerts(t *testing.T, e *env) (firing, resolved *store.Alert) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
d := e.device(t, "core-1", "", "core")
|
|
fired := e.clk.Now().Add(-time.Hour)
|
|
mk := func(key, state string) *store.Alert {
|
|
a := &store.Alert{Rule: "device_down", SeriesKey: key, DeviceSlug: d.Slug, State: state, BaseState: state, Severity: "critical",
|
|
Summary: "down", FiredAt: &fired, FlapChanges: "[]"}
|
|
if err := e.st.UpsertAlertState(ctx, a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a
|
|
}
|
|
firing = mk("a|1", store.AlertFiring)
|
|
firing.SuppressedBy = "core-a, core-b"
|
|
if err := e.st.UpsertAlertState(ctx, firing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleared := e.clk.Now()
|
|
resolved = mk("a|2", store.AlertResolved)
|
|
resolved.ClearedAt = &cleared
|
|
if err := e.st.UpsertAlertState(ctx, resolved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mk("a|3", store.AlertPending)
|
|
return firing, resolved
|
|
}
|
|
|
|
func TestAlertsListFiltersByStateAndNeverShowsPendingOrNumericIDs(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
firing, resolved := seedAlerts(t, e)
|
|
var all, active, history alertsPage
|
|
rec := e.getJSON(t, "/api/v1/alerts", &all)
|
|
e.getJSON(t, "/api/v1/alerts?state=active", &active)
|
|
e.getJSON(t, "/api/v1/alerts?state=history", &history)
|
|
if all.Total != 2 || active.Total != 1 || history.Total != 1 {
|
|
t.Fatalf("totals all=%d active=%d history=%d", all.Total, active.Total, history.Total)
|
|
}
|
|
a := active.Items[0]
|
|
if a.ID != fmt.Sprint(firing.ID) || a.Device.Name != "core-1" || strings.Join(a.SuppressedBy, "|") != "core-a|core-b" ||
|
|
a.IncidentID != fmt.Sprintf("%d-%d", firing.ID, firing.FiredAt.Unix()) {
|
|
t.Fatalf("active alert = %+v", a)
|
|
}
|
|
if history.Items[0].ID != fmt.Sprint(resolved.ID) {
|
|
t.Fatalf("history = %+v", history.Items)
|
|
}
|
|
assertNoLeaks(t, "alerts", rec.Body.Bytes())
|
|
}
|
|
|
|
func TestAckMovesAnAlertToAckedAndRejectsPastClosedAndUnknown(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
firing, resolved := seedAlerts(t, e)
|
|
until := e.clk.Now().Add(time.Hour).Format(time.RFC3339)
|
|
past := e.clk.Now().Add(-time.Hour).Format(time.RFC3339)
|
|
body := func(u string) string { return `{"reason":"carrier","until":"` + u + `"}` }
|
|
isProblem(t, e.do("POST", "/api/v1/alerts/"+fmt.Sprint(firing.ID)+"/ack", body(past)), 422)
|
|
isProblem(t, e.do("POST", "/api/v1/alerts/"+fmt.Sprint(resolved.ID)+"/ack", body(until)), 409)
|
|
isProblem(t, e.do("POST", "/api/v1/alerts/9999/ack", body(until)), 404)
|
|
isProblem(t, e.do("POST", "/api/v1/alerts/x/ack", body(until)), 404)
|
|
if rec := e.do("POST", "/api/v1/alerts/"+fmt.Sprint(firing.ID)+"/ack", body(until)); rec.Code != 200 {
|
|
t.Fatalf("ack: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var active, acked alertsPage
|
|
e.getJSON(t, "/api/v1/alerts?state=active", &active)
|
|
e.getJSON(t, "/api/v1/alerts?state=acked", &acked)
|
|
if active.Total != 0 || acked.Total != 1 || acked.Items[0].Ack == nil || acked.Items[0].Ack.Reason != "carrier" {
|
|
t.Fatalf("active %+v acked %+v", active, acked)
|
|
}
|
|
}
|
|
|
|
func TestDeliveriesShowEveryAttemptWithRedactedPayload(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
firing, _ := seedAlerts(t, e)
|
|
ctx := context.Background()
|
|
now := e.clk.Now()
|
|
exp := now.Add(time.Hour)
|
|
d := &store.Delivery{AlertID: firing.ID, Receiver: "ops", IncidentID: "i-1", NextAttemptAt: &now, ExpiresAt: &exp,
|
|
Payload: `{"summary":"down","password":"hunter2"}`}
|
|
if err := e.st.EnqueueDelivery(ctx, d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.st.RecordDeliveryAttempt(ctx, d.ID, store.DeliveryAttempt{Status: store.AttemptOK, At: now}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out struct {
|
|
Items []struct {
|
|
Status, Payload string
|
|
Attempts []struct {
|
|
N int64
|
|
Status string
|
|
}
|
|
}
|
|
}
|
|
rec := e.getJSON(t, "/api/v1/alerts/"+fmt.Sprint(firing.ID)+"/deliveries", &out)
|
|
if len(out.Items) != 1 || out.Items[0].Status != "sent" || len(out.Items[0].Attempts) != 1 || out.Items[0].Attempts[0].Status != "ok" {
|
|
t.Fatalf("deliveries = %s", rec.Body)
|
|
}
|
|
if strings.Contains(rec.Body.String(), "hunter2") {
|
|
t.Fatalf("payload not redacted: %s", rec.Body)
|
|
}
|
|
isProblem(t, e.do("GET", "/api/v1/alerts/9999/deliveries", ""), 404)
|
|
}
|
|
|
|
func TestMaintenanceCreateListDeleteAndValidation(t *testing.T) {
|
|
e := newEnvDeps(t, 0, nil)
|
|
d := e.device(t, "core-1", "", "core")
|
|
end := e.clk.Now().Add(time.Hour).Format(time.RFC3339)
|
|
past := e.clk.Now().Add(-time.Hour).Format(time.RFC3339)
|
|
for name, body := range map[string]string{
|
|
"both": `{"device":"` + d.Slug + `","group_id":"1","ends_at":"` + end + `","reason":"x"}`,
|
|
"neither": `{"ends_at":"` + end + `","reason":"x"}`,
|
|
"past": `{"device":"` + d.Slug + `","ends_at":"` + past + `","reason":"x"}`,
|
|
"unknown dev": `{"device":"nope","ends_at":"` + end + `","reason":"x"}`,
|
|
"unknown grp": `{"group_id":"77","ends_at":"` + end + `","reason":"x"}`,
|
|
"ends<starts": `{"device":"` + d.Slug + `","starts_at":"` + end + `","ends_at":"` + e.clk.Now().Add(30*time.Minute).Format(time.RFC3339) + `"}`,
|
|
"bad group id": `{"group_id":"abc","ends_at":"` + end + `","reason":"x"}`,
|
|
} {
|
|
if rec := e.do("POST", "/api/v1/maintenance", body); rec.Code != 422 {
|
|
t.Errorf("%s: %d %s", name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
rec := e.do("POST", "/api/v1/maintenance", `{"device":"`+d.Slug+`","ends_at":"`+end+`","reason":"swap"}`)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var w struct{ ID, State string }
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
|
if w.State != "active" {
|
|
t.Fatalf("window = %s", rec.Body)
|
|
}
|
|
var list struct{ Total int64 }
|
|
e.getJSON(t, "/api/v1/maintenance", &list)
|
|
if list.Total != 1 {
|
|
t.Fatalf("list total %d", list.Total)
|
|
}
|
|
if rec := e.do("DELETE", "/api/v1/maintenance/"+w.ID, ""); rec.Code != 204 {
|
|
t.Fatalf("delete: %d %s", rec.Code, rec.Body)
|
|
}
|
|
isProblem(t, e.do("DELETE", "/api/v1/maintenance/"+w.ID, ""), 404)
|
|
}
|
|
|
|
func TestAlertsFilterByDeviceAndInterface(t *testing.T) {
|
|
e := newEnv(t, 0)
|
|
seedAlerts(t, e)
|
|
other := e.device(t, "other", "", "access")
|
|
i := e.iface(t, other, 1, "ge-0/0/1", "")
|
|
ctx := context.Background()
|
|
fired := e.clk.Now()
|
|
a := &store.Alert{Rule: "interface_down", SeriesKey: "if|1", DeviceSlug: other.Slug, Iface: i.ID, State: store.AlertFiring,
|
|
BaseState: store.AlertFiring, Severity: "warning", FiredAt: &fired, FlapChanges: "[]"}
|
|
if err := e.st.UpsertAlertState(ctx, a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var byDev, byIface, none alertsPage
|
|
e.getJSON(t, "/api/v1/alerts?device="+other.Slug, &byDev)
|
|
e.getJSON(t, "/api/v1/alerts?iface="+i.ID+"&state=active", &byIface)
|
|
e.getJSON(t, "/api/v1/alerts?device=nobody", &none)
|
|
if byDev.Total != 1 || byIface.Total != 1 || none.Total != 0 || byIface.Items[0].Rule != "interface_down" {
|
|
t.Fatalf("by device %d, by iface %d, unknown %d", byDev.Total, byIface.Total, none.Total)
|
|
}
|
|
}
|
|
|
|
func TestDeviceWarningsIncludeLiveOnesFromTheEvaluator(t *testing.T) {
|
|
e := newEnvDeps(t, 0, func(d *api.Deps) { d.DeviceWarnings = func(string) []string { return []string{"icmp_unsupported"} } })
|
|
d := e.device(t, "sw", "", "access", func(d *store.Device) { d.Warnings = []string{"counter_32bit_fast_link"} })
|
|
var v struct{ Warnings []string }
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug, &v)
|
|
if strings.Join(v.Warnings, ",") != "counter_32bit_fast_link,icmp_unsupported" {
|
|
t.Fatalf("warnings = %v", v.Warnings)
|
|
}
|
|
var list struct{ Items []struct{ Warnings []string } }
|
|
e.getJSON(t, "/api/v1/devices", &list)
|
|
if len(list.Items) != 1 || len(list.Items[0].Warnings) != 2 {
|
|
t.Fatalf("list warnings = %+v", list.Items)
|
|
}
|
|
}
|