Files
nmon/internal/api/alerts_test.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)
}
}