Netpulse_SasS/server/internal/alerting/notify_test.go
byrsapty ec4b2cd54b
All checks were successful
CI / hygiene (push) Successful in 10s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m54s
CI / agent (push) Successful in 1m1s
Тиха година й драбина: вада окремо, вибір окремо
Асиметрія: аварія о 21:59 ескалювала всю ніч, о 22:01 не ескалювала
ніколи. Дві хвилини різниці — протилежні наслідки, причому гірший
(повна тиша) виглядав як тиша справна.

ВАДА. targets() повертав порожньо в тиху годину, а взведення читало це
як «немає куди слати». Взводять лише новий алерт, тож драбина не
з'являлась уже ніколи: тиха година вимикала механізм саме тоді, коли
перше сповіщення не спрацювало. Тепер targets() розрізняє «каналів
немає» і «канали є, просто зараз ніч».

ВИБІР. 0072 додає respect_quiet_hours на драбину:
  false (типово, як діяло) — драбина пробивається;
  true  — сходинка відкладається до ранку і НЕ витрачається.
Залежить від того, чи є в кабінету нічна зміна — це вирішує кабінет.
disaster пробивається за будь-якого значення, як і в targets().

Відлік драбини — від першого сповіщення, а не від started_at: інакше
для розглушеного алерту вона протухла б ще у вікні.

І сам прогін проти бази брехав: dbtest.sh котив схему готовим образом
(старі міграції), а тести брав із нового дерева. Тепер міграції з того
ж дерева. 64 міграції, усе зелене.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:08:27 +03:00

216 lines
8.6 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 alerting
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// SaaS-режим: адреса вебхука приходить від користувача тенанта, а запит
// іде з сервера — внутрішні адреси мають відхилятися.
func TestWebhookRejectsInternalTargets(t *testing.T) {
bad := []string{
"http://127.0.0.1:8080/hook",
"http://localhost/hook",
"http://10.1.2.3/hook",
"http://192.168.1.1/hook",
"http://169.254.169.254/latest/meta-data/", // метадані хмари
"http://[::1]/hook",
}
for _, u := range bad {
if err := validateHookURL(u, false); err == nil {
t.Errorf("%s: очікували відмову, отримали дозвіл", u)
}
}
}
func TestWebhookRejectsNonHTTPSchemes(t *testing.T) {
for _, u := range []string{"file:///etc/passwd", "gopher://x/", "ftp://x/"} {
// Схема перевіряється до прапорця: навіть self-hosted не має
// приводу слати сповіщення у file://.
if err := validateHookURL(u, true); err == nil {
t.Errorf("%s: очікували відмову", u)
}
}
if err := validateHookURL("", false); err == nil {
t.Error("порожній url має відхилятися")
}
}
// Self-hosted: внутрішня мережа належить тому ж, хто налаштовує вебхук.
func TestWebhookAllowsInternalWhenExplicitlyPermitted(t *testing.T) {
if err := validateHookURL("http://192.168.1.50/hook", true); err != nil {
t.Errorf("із дозволом внутрішні адреси мають проходити: %v", err)
}
}
// ---------------------------------------------------------------------
func TestRenderMessageUsesDefaultTemplate(t *testing.T) {
v := 98.5
a := store.Alert{
Severity: "high",
Title: "sw1: Завантаження порту",
Message: "util_out_pct усі виміри > 90",
DeviceName: "sw1",
Value: &v,
StartedAt: time.Date(2026, 8, 15, 10, 30, 0, 0, time.UTC),
}
got := renderMessage(a, store.Channel{})
for _, want := range []string{"HIGH", "sw1: Завантаження порту", "2026-08-15 10:30:00"} {
if !strings.Contains(got, want) {
t.Errorf("у повідомленні немає %q:\n%s", want, got)
}
}
}
// Крива шаблонка каналу не має ковтати сповіщення — це рівно той
// випадок, коли мовчання найдорожче.
func TestRenderMessageFallsBackOnBrokenTemplate(t *testing.T) {
a := store.Alert{Severity: "warning", Title: "тест", StartedAt: time.Now()}
broken := store.Channel{Template: "{{.Nope"}
if got := renderMessage(a, broken); !strings.Contains(got, "тест") {
t.Errorf("очікували стандартний текст, отримали %q", got)
}
// Компілюється, але звертається до неіснуючого поля — падає вже
// при виконанні.
runtimeBroken := store.Channel{Template: "{{.Missing.Deep}}"}
if got := renderMessage(a, runtimeBroken); !strings.Contains(got, "тест") {
t.Errorf("очікували стандартний текст, отримали %q", got)
}
}
func TestRenderMessageHonoursCustomTemplate(t *testing.T) {
a := store.Alert{Severity: "info", Title: "заголовок", StartedAt: time.Now()}
c := store.Channel{Template: "[{{.Severity}}] {{.Title}}"}
if got := renderMessage(a, c); got != "[INFO] заголовок" {
t.Errorf("отримали %q", got)
}
}
// Токен бота лежить у шляху URL, тому http.Client вкладає його в текст
// помилки. У журнал він потрапити не має.
func TestScrubTokenRemovesSecretFromError(t *testing.T) {
token := "123456:AAF-secret-token"
err := scrubToken(
&urlError{msg: `Post "https://api.telegram.org/bot` + token + `/sendMessage": timeout`},
token)
if strings.Contains(err.Error(), token) {
t.Fatalf("токен лишився в помилці: %s", err)
}
if !strings.Contains(err.Error(), "«токен»") {
t.Errorf("очікували заміну на «токен», отримали %s", err)
}
}
type urlError struct{ msg string }
func (e *urlError) Error() string { return e.msg }
// Тенант без маршрутів має отримувати сповіщення в усі придатні канали:
// підключили Telegram — воно має працювати без додаткових налаштувань.
func TestTargetsFallBackToAllChannelsWithoutRoutes(t *testing.T) {
n := &Notifier{}
channels := []store.Channel{
{ID: "c1", Enabled: true, MinSeverity: "warning"},
{ID: "c2", Enabled: true, MinSeverity: "disaster"},
{ID: "c3", Enabled: false, MinSeverity: "info"},
}
byID := map[string]store.Channel{}
for _, c := range channels {
byID[c.ID] = c
}
got, _ := n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, false,
nil, channels, byID, time.Now(), "t1")
if len(got) != 1 || got[0].ID != "c1" {
t.Fatalf("очікували лише c1, отримали %v", ids(got))
}
}
func TestTargetsRespectChannelSeverityFloor(t *testing.T) {
n := &Notifier{}
channels := []store.Channel{{ID: "c1", Enabled: true, MinSeverity: "high"}}
byID := map[string]store.Channel{"c1": channels[0]}
if got, _ := n.targets(store.Alert{Severity: "warning"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 0 {
t.Fatalf("warning не має проходити поріг high, отримали %v", ids(got))
}
if got, _ := n.targets(store.Alert{Severity: "disaster"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 1 {
t.Fatal("disaster має проходити поріг high")
}
}
func ids(cs []store.Channel) []string {
out := make([]string, 0, len(cs))
for _, c := range cs {
out = append(out, c.ID)
}
return out
}
// Конфіг Telegram приймає chat_id і числом, і рядком: у групах він
// від'ємний і великий, і людина копіює його з різних джерел по-різному.
func TestTelegramChatIDAcceptsBothForms(t *testing.T) {
for _, raw := range []string{`{"chat_id":-1001234567890}`, `{"chat_id":"-1001234567890"}`} {
var cfg telegramConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("%s: %v", raw, err)
}
if cfg.ChatID.String() != "-1001234567890" {
t.Errorf("%s: отримали %q", raw, cfg.ChatID.String())
}
}
}
// Канали, задані самим правилом, перекривають загальні маршрути.
//
// Інакше «шліть це черговому» перетворювалося б на «шліть це черговому
// і ще туди, куди вирішить спільна політика» — тобто на щось, чого
// людина не просила.
func TestRuleChannelsOverrideRoutes(t *testing.T) {
n := &Notifier{}
channels := []store.Channel{
{ID: "c1", Enabled: true, MinSeverity: "info"},
{ID: "c2", Enabled: true, MinSeverity: "info"},
}
byID := map[string]store.Channel{"c1": channels[0], "c2": channels[1]}
routes := []store.Route{{ID: "r1", Name: "усі", ChannelIDs: []string{"c1", "c2"}}}
act := store.RuleAction{ChannelIDs: []string{"c2"}}
got, _ := n.targets(store.Alert{Severity: "high"}, act, true, routes, channels, byID, time.Now(), "t1")
if len(got) != 1 || got[0].ID != "c2" {
t.Fatalf("очікували лише c2, отримали %v", ids(got))
}
// Правило без власних каналів лишає рішення маршрутам.
got, _ = n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, true,
routes, channels, byID, time.Now(), "t1")
if len(got) != 2 {
t.Fatalf("очікували обидва канали з маршруту, отримали %v", ids(got))
}
}
// Вимкнений канал не отримує алерт навіть тоді, коли правило назвало
// його явно: вимкнення — це рішення про канал, а не про правило.
func TestRuleChannelsRespectDisabled(t *testing.T) {
n := &Notifier{}
channels := []store.Channel{{ID: "c1", Enabled: false, MinSeverity: "info"}}
byID := map[string]store.Channel{"c1": channels[0]}
got, _ := n.targets(store.Alert{Severity: "disaster"},
store.RuleAction{ChannelIDs: []string{"c1"}}, true, nil, channels, byID, time.Now(), "t1")
if len(got) != 0 {
t.Fatalf("вимкнений канал отримав алерт: %v", ids(got))
}
}