Асиметрія: аварія о 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>
216 lines
8.6 KiB
Go
216 lines
8.6 KiB
Go
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))
|
||
}
|
||
}
|