Netpulse_SasS/server/internal/store/alerts_logic_test.go
byrsapty 215bf8c4db Етап 9: алерти й сповіщення
Система вміла малювати мапу, але мовчала, коли щось падало. Схема alr.*
лежала готовою з Етапу 1 і повністю порожньою.

Движок:
- обчислює правила з icmp / interface / metric / no_data і не тримає
  стану між тіками: вікно for_seconds — це запит по часу до TSDB, тож
  перезапуск нічого не збиває
- дві семантики вікна: без agg умова має триматися всі виміри (антифлап),
  з agg порівнюється агрегат
- кореляція за топологією: причина аварії — той, у кого лишився живий
  сусід; хто оточений мертвими, той наслідок. Заради цього й будувалась
  topo.links: інакше падіння маршрутизатора дає сорок сповіщень
- вікна обслуговування, ручне заглушення зі стелею в тиждень
- advisory-блокування на тік: кілька API за балансувальником безпечні

Доставка:
- Telegram, webhook, SMTP; токени в core.secrets тим самим кільцем, що
  й паролі від обладнання
- тенант без маршрутів отримує все в усі придатні канали — підключили
  Telegram, має працювати
- тиха година не глушить disaster
- вебхуки на внутрішні адреси заблоковано; для self-hosted знімається
  прапорцем процесу, бо там ця мережа своя

UI: індикатор у шапці, панель зі списком, ack/mute/close, окремий фільтр
придушених, спільна шина подій замість другого WebSocket.

Знайдено живою роботою й виправлено:
- вимкнене або видалене правило лишало алерти сиротами назавжди
- перехід у suppressed не публікував події — UI дізнавався лише після
  перезавантаження сторінки
- кнопки дій на телефоні були 26 px

20 нових тестів, go vet і tsc чисто. Живий прогін: поріг посередині
розділив два справжні пристрої, 5 доставок на 5 подій без повторів.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:09:19 +03:00

211 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"testing"
"time"
)
// graph будує двонаправлений граф зі списку ребер.
func graph(down []string, edges ...[2]string) TopologyGraph {
g := TopologyGraph{Neighbors: map[string][]string{}, Down: map[string]bool{}}
mark := func(id string) {
if _, ok := g.Down[id]; !ok {
g.Down[id] = false
}
if _, ok := g.Neighbors[id]; !ok {
g.Neighbors[id] = nil
}
}
for _, e := range edges {
mark(e[0])
mark(e[1])
g.Neighbors[e[0]] = append(g.Neighbors[e[0]], e[1])
g.Neighbors[e[1]] = append(g.Neighbors[e[1]], e[0])
}
for _, d := range down {
mark(d)
g.Down[d] = true
}
return g
}
// Головний сценарій, заради якого будувалася topo.links: упав
// маршрутизатор — сорок пристроїв за ним не мають дати сорок алертів.
func TestRootCauseCollapsesOutageBehindRouter(t *testing.T) {
g := graph(
[]string{"router", "sw1", "sw2", "host1", "host2"},
[2]string{"core", "router"},
[2]string{"router", "sw1"},
[2]string{"router", "sw2"},
[2]string{"sw1", "host1"},
[2]string{"sw2", "host2"},
)
roots := g.RootCause()
if _, ok := roots["router"]; ok {
t.Fatal("маршрутизатор має бути причиною, а не наслідком")
}
for _, id := range []string{"sw1", "sw2", "host1", "host2"} {
if roots[id] != "router" {
t.Errorf("%s: корінь %q, очікували router", id, roots[id])
}
}
}
// Пристрій, у якого лишився живий сусід, впав сам.
func TestRootCauseKeepsDeviceWithLiveNeighbour(t *testing.T) {
g := graph(
[]string{"host1"},
[2]string{"sw1", "host1"},
)
if roots := g.RootCause(); len(roots) != 0 {
t.Fatalf("очікували жодного наслідку, отримали %v", roots)
}
}
// Пристрій без жодного відомого зв'язку не можна списати на чужу
// аварію: топологія про нього нічого не знає.
func TestRootCauseIsolatedDeviceStaysRoot(t *testing.T) {
g := graph([]string{"lonely"})
g.Neighbors["lonely"] = nil
if roots := g.RootCause(); len(roots) != 0 {
t.Fatalf("одинокий пристрій має лишитись причиною, отримали %v", roots)
}
}
// Повністю мертвий острів: причини не видно, тож алертують усі
// краще зайвий шум, ніж мовчазне ковтання цілого сегмента.
func TestRootCauseFullyDownIslandAlertsAll(t *testing.T) {
g := graph(
[]string{"a", "b"},
[2]string{"a", "b"},
)
if roots := g.RootCause(); len(roots) != 0 {
t.Fatalf("острів без живих сусідів має алертувати весь, отримали %v", roots)
}
}
// Ланцюг: корінь передається вглиб, а не переписується сусідом.
func TestRootCausePropagatesAlongChain(t *testing.T) {
g := graph(
[]string{"r", "a", "b", "c"},
[2]string{"up", "r"},
[2]string{"r", "a"},
[2]string{"a", "b"},
[2]string{"b", "c"},
)
roots := g.RootCause()
for _, id := range []string{"a", "b", "c"} {
if roots[id] != "r" {
t.Errorf("%s: корінь %q, очікували r", id, roots[id])
}
}
}
// ---------------------------------------------------------------------
func atTime(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatal(err)
}
return v
}
// Тихі години через північ — норма для чергувань, а не окремий випадок.
func TestQuietHoursSpanMidnight(t *testing.T) {
sc := &RouteSchedule{TZ: "UTC"}
sc.Quiet = append(sc.Quiet, struct {
Days []int `json:"days"`
From string `json:"from"`
To string `json:"to"`
}{From: "22:00", To: "08:00"})
cases := []struct {
ts string
want bool
}{
{"2026-08-15T23:30:00Z", true},
{"2026-08-15T03:00:00Z", true},
{"2026-08-15T07:59:00Z", true},
{"2026-08-15T08:00:00Z", false},
{"2026-08-15T12:00:00Z", false},
{"2026-08-15T21:59:00Z", false},
}
for _, c := range cases {
if got := sc.IsQuiet(atTime(t, c.ts)); got != c.want {
t.Errorf("%s: отримали %v, очікували %v", c.ts, got, c.want)
}
}
}
func TestQuietHoursRespectDays(t *testing.T) {
sc := &RouteSchedule{TZ: "UTC"}
// 2026-08-15 — субота (6), 2026-08-17 — понеділок (1).
sc.Quiet = append(sc.Quiet, struct {
Days []int `json:"days"`
From string `json:"from"`
To string `json:"to"`
}{Days: []int{6, 0}, From: "00:00", To: "23:59"})
if !sc.IsQuiet(atTime(t, "2026-08-15T12:00:00Z")) {
t.Error("субота має бути тихою")
}
if sc.IsQuiet(atTime(t, "2026-08-17T12:00:00Z")) {
t.Error("понеділок тихим не є")
}
}
func TestNilScheduleIsNeverQuiet(t *testing.T) {
var sc *RouteSchedule
if sc.IsQuiet(time.Now()) {
t.Error("відсутній розклад не має глушити нічого")
}
}
// ---------------------------------------------------------------------
func TestRouteMatchesSeverityFloor(t *testing.T) {
r := Route{Matcher: RouteMatcher{SeverityGte: "high"}}
if r.Matches(Alert{Severity: "warning"}) {
t.Error("warning не має проходити поріг high")
}
if !r.Matches(Alert{Severity: "high"}) {
t.Error("high має проходити поріг high")
}
if !r.Matches(Alert{Severity: "disaster"}) {
t.Error("disaster має проходити поріг high")
}
}
func TestRouteMatchesDeviceList(t *testing.T) {
r := Route{Matcher: RouteMatcher{DeviceIDs: []string{"d1", "d2"}}}
if !r.Matches(Alert{Severity: "warning", DeviceID: "d2"}) {
t.Error("пристрій зі списку має проходити")
}
if r.Matches(Alert{Severity: "warning", DeviceID: "d9"}) {
t.Error("пристрій поза списком не має проходити")
}
}
// Ключ дедуплікації має бути стабільним і розрізняти рівні об'єкта:
// інакше алерт про інтерфейс затирав би алерт про сам пристрій.
func TestDedupKeySeparatesDeviceAndInterface(t *testing.T) {
dev := Candidate{DeviceID: "d1"}
iface := Candidate{DeviceID: "d1", InterfaceID: "i1"}
if dev.DedupKey("r1") == iface.DedupKey("r1") {
t.Fatal("ключі пристрою й інтерфейсу мають відрізнятися")
}
if dev.DedupKey("r1") != dev.DedupKey("r1") {
t.Fatal("ключ має бути стабільним")
}
if dev.DedupKey("r1") == dev.DedupKey("r2") {
t.Fatal("різні правила — різні ключі")
}
}