За цілий день активної роботи в журналі не з'явилось нічого: цих доменів у ньому просто не було. Проєкт це визнавав сам, у AuditBlindSpots(). Тепер пишуться: правила алертів (створення/зміна/видалення й окремо вимкнення-увімкнення), канали, драбини ескалації, правила відповідності; додавання людини в кабінет, зміна ролі, вилучення, правка профілю й скидання пароля. Два рішення про зміст запису: * вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці подробиць — питання «хто вимкнув правило, за яким приходив алерт» має відповідатись переліком, а не порівнянням; * config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й адреса вебхука (доступ на запис у чужий чат) і заголовок Authorization. Замість нього прапорець secret_changed. Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери від шаблонів пишуться повз обробник, і пробне повідомлення в канал. Прогнано проти справжньої бази: секрет каналу й пароль учасника в подробиці не потрапляють. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
13 KiB
Go
273 lines
13 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Що саме потрапляє в подробиці журналу — БЕЗ бази навмисно.
|
||
//
|
||
// Решта тестів цього пакета йде проти справжнього Postgres і сама себе
|
||
// пропускає без NETPULSE_TEST_DSN. Для головного правила цієї роботи —
|
||
// «секрет каналу не потрапляє в журнал» — така перевірка не годиться:
|
||
// пропущений тест у підсумку виглядає точно як пройдений (див. шапку
|
||
// scripts/dbtest.sh), а ціна помилки тут не «сторінка показала не те», а
|
||
// копія токена бота в таблиці, яку читає ширше коло людей, ніж сторінку
|
||
// каналів. Тому подробиці збираються чистими функціями, і перевіряються
|
||
// вони тут — на кожній машині й на кожному прогоні.
|
||
|
||
// marker — рядки, яких у журналі бути НЕ МОЖЕ. Кожен упізнаваний
|
||
// настільки, що випадковий збіг виключений.
|
||
const (
|
||
botToken = "1234567:AAH-СЕКРЕТНИЙ-ТОКЕН-БОТА-не-в-журнал"
|
||
hookURL = "https://hooks.slack.com/services/T00/B11/СЕКРЕТНИЙ-ШЛЯХ"
|
||
authHeader = "Bearer СЕКРЕТНИЙ-ЗАГОЛОВОК-АВТОРИЗАЦІЇ"
|
||
userPass = "СЕКРЕТНИЙ-ПАРОЛЬ-УЧАСНИКА-42"
|
||
)
|
||
|
||
// metaJSON серіалізує подробиці рівно так, як це зробить WriteAudit.
|
||
//
|
||
// Саме серіалізований вигляд і треба обшукувати: секрет може приїхати
|
||
// не полем, а всередині вкладеної структури або json.RawMessage, і
|
||
// перевірка окремих ключів мапи його б не побачила.
|
||
func metaJSON(t *testing.T, meta map[string]any) string {
|
||
t.Helper()
|
||
b, err := json.Marshal(meta)
|
||
if err != nil {
|
||
t.Fatalf("подробиці не серіалізуються — запис у журнал не відбувся б: %v", err)
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// Головний тест роботи.
|
||
func TestChannelAuditMetaNeverCarriesSecret(t *testing.T) {
|
||
in := store.ChannelInput{
|
||
Kind: "telegram",
|
||
Name: "Чергова зміна",
|
||
// config несе адресу вебхука й заголовки — це такий самий
|
||
// доступ, як токен: хто знає адресу, той пише в чужий чат.
|
||
Config: `{"url":"` + hookURL + `","headers":{"Authorization":"` + authHeader + `"},` +
|
||
`"chat_id":-1001234567890}`,
|
||
Template: "{{.Title}}",
|
||
MinSeverity: "high",
|
||
Secret: botToken,
|
||
Enabled: true,
|
||
}
|
||
|
||
got := metaJSON(t, channelAuditMeta(in))
|
||
|
||
for _, secret := range []string{botToken, hookURL, authHeader} {
|
||
if strings.Contains(got, secret) {
|
||
t.Fatalf("секрет потрапив у подробиці журналу: %q\nподробиці: %s", secret, got)
|
||
}
|
||
}
|
||
// Окремо: жодного шматка config, навіть безневинного на вигляд.
|
||
// Правило тут не «сховати відомі секрети», а «не пускати config
|
||
// узагалі» — інакше наступний вид каналу принесе новий секрет під
|
||
// новим ім'ям поля, і його ніхто не помітить.
|
||
if strings.Contains(got, "hooks.slack.com") || strings.Contains(got, "chat_id") {
|
||
t.Fatalf("у подробиці просочився config каналу: %s", got)
|
||
}
|
||
|
||
if !strings.Contains(got, "Чергова зміна") || !strings.Contains(got, "telegram") {
|
||
t.Fatalf("запис не відповідає на «що саме за канал»: %s", got)
|
||
}
|
||
if !strings.Contains(got, `"secret_changed":true`) {
|
||
t.Fatalf("факт заміни токена загубився: %s", got)
|
||
}
|
||
}
|
||
|
||
// Правка, у якій секрет не чіпали, має відрізнятись від правки, у якій
|
||
// приїхав новий токен: порожній секрет означає «лишити наявний», і
|
||
// журнал не має вдавати, ніби токен стерли.
|
||
func TestChannelAuditMetaMarksUntouchedSecret(t *testing.T) {
|
||
got := metaJSON(t, channelAuditMeta(store.ChannelInput{
|
||
Kind: "webhook", Name: "Черговий вебхук", MinSeverity: "warning",
|
||
}))
|
||
if !strings.Contains(got, `"secret_changed":false`) {
|
||
t.Fatalf("незмінений секрет позначено як змінений: %s", got)
|
||
}
|
||
}
|
||
|
||
func TestAlertRuleAuditMetaKeepsSubject(t *testing.T) {
|
||
in := store.RuleInput{
|
||
Name: "Втрати на магістралі",
|
||
Source: "icmp",
|
||
Severity: "disaster",
|
||
Selector: `{"site_ids":["kyiv-1"]}`,
|
||
Condition: `{"metric":"loss_pct","op":">","value":5}`,
|
||
ForSeconds: 300,
|
||
ChannelIDs: []string{"11111111-1111-4111-8111-111111111111"},
|
||
EscalationPolicyID: "22222222-2222-4222-8222-222222222222",
|
||
NotifyOnResolve: true,
|
||
}
|
||
|
||
got := metaJSON(t, alertRuleAuditMeta(in, false))
|
||
|
||
// Предмет правила — те, заради чого запис і читають: правило з тією
|
||
// ж назвою може ловити вже зовсім не те.
|
||
if !strings.Contains(got, `"value":5`) || !strings.Contains(got, "loss_pct") {
|
||
t.Fatalf("умова правила не потрапила в подробиці: %s", got)
|
||
}
|
||
if !strings.Contains(got, "kyiv-1") {
|
||
t.Fatalf("селектор не потрапив у подробиці: %s", got)
|
||
}
|
||
// Умова має лежати JSON-об'єктом, а не рядком у лапках: інакше
|
||
// пошук по журналу шукає по екранованому тексту.
|
||
if strings.Contains(got, `"condition":"`) {
|
||
t.Fatalf("умова записана рядком, а не JSON: %s", got)
|
||
}
|
||
if !strings.Contains(got, `"enabled":false`) {
|
||
t.Fatalf("стан «вимкнено» не видно з подробиць: %s", got)
|
||
}
|
||
}
|
||
|
||
// Порожня умова не має валити ВЕСЬ запис: подія без подробиць гірша за
|
||
// подробиці без одного поля, але подія, якої немає, — найгірша.
|
||
func TestAlertRuleAuditMetaSurvivesBrokenJSON(t *testing.T) {
|
||
got := metaJSON(t, alertRuleAuditMeta(store.RuleInput{
|
||
Name: "Криве", Condition: "{не json", Selector: "",
|
||
}, true))
|
||
if !strings.Contains(got, "не json") {
|
||
t.Fatalf("невалідну умову треба зберегти хоч рядком: %s", got)
|
||
}
|
||
}
|
||
|
||
// Найважливіше правило зони А: вимкнення видно з НАЗВИ дії, а не з
|
||
// порівняння подробиць.
|
||
func TestRuleSaveOutcome(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
ref store.RuleRef
|
||
want ruleSaveKind
|
||
}{
|
||
{"вимкнули збереженням форми",
|
||
store.RuleRef{WasEnabled: true, Enabled: false}, ruleSavedAsDisable},
|
||
{"увімкнули збереженням форми",
|
||
store.RuleRef{WasEnabled: false, Enabled: true}, ruleSavedAsEnable},
|
||
{"правка ввімкненого правила",
|
||
store.RuleRef{WasEnabled: true, Enabled: true}, ruleSavedAsUpdate},
|
||
// Головний із чотирьох: збереження вимкненого правила нічого не
|
||
// гасить (гасіння вже сталось на переході), тож називати це
|
||
// вимкненням означало б показувати в журналі подію, якої не
|
||
// було.
|
||
{"правка вимкненого правила",
|
||
store.RuleRef{WasEnabled: false, Enabled: false}, ruleSavedAsUpdate},
|
||
}
|
||
for _, c := range cases {
|
||
if got := ruleSaveOutcome(c.ref); got != c.want {
|
||
t.Errorf("%s: отримано %d, очікувалось %d", c.name, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEscalationAuditMetaKeepsSteps(t *testing.T) {
|
||
got := metaJSON(t, escalationAuditMeta(store.EscalationPolicy{
|
||
Name: "Нічна",
|
||
Steps: []store.EscalationStep{
|
||
{AfterMin: 15, ChannelIDs: []string{"33333333-3333-4333-8333-333333333333"}},
|
||
},
|
||
RepeatAfterMin: 30, MaxRepeats: 3, RespectQuietHours: true,
|
||
}))
|
||
|
||
if !strings.Contains(got, `"after_min":15`) {
|
||
t.Fatalf("сходинки драбини не потрапили в подробиці: %s", got)
|
||
}
|
||
// Тиха година — те саме, що вимкнення правила, тільки для драбини:
|
||
// вночі вона мовчатиме, тобто резерв не спрацює саме тоді, коли
|
||
// потрібен.
|
||
if !strings.Contains(got, `"respect_quiet_hours":true`) {
|
||
t.Fatalf("тиха година не видна з подробиць: %s", got)
|
||
}
|
||
}
|
||
|
||
// Драбина без сходинок має дати порожній масив, а не null: null у
|
||
// журналі читається як «сходинки невідомі», хоч насправді їх немає.
|
||
func TestEscalationAuditMetaEmptySteps(t *testing.T) {
|
||
got := metaJSON(t, escalationAuditMeta(store.EscalationPolicy{Name: "Порожня"}))
|
||
if !strings.Contains(got, `"steps":[]`) {
|
||
t.Fatalf("порожня драбина записана як null: %s", got)
|
||
}
|
||
}
|
||
|
||
func TestComplianceRuleAuditMetaKeepsPattern(t *testing.T) {
|
||
got := metaJSON(t, complianceRuleAuditMeta(store.ComplianceRule{
|
||
Name: "Telnet вимкнено", Kind: "forbidden", Pattern: `(?m)^transport input telnet`,
|
||
Severity: "high", ConfigType: "running", Enabled: false,
|
||
BuiltinKey: "no-telnet",
|
||
}))
|
||
|
||
if !strings.Contains(got, "transport input telnet") {
|
||
t.Fatalf("зразок правила не потрапив у подробиці: %s", got)
|
||
}
|
||
if !strings.Contains(got, `"enabled":false`) {
|
||
t.Fatalf("вимкнення правила не видно з подробиць: %s", got)
|
||
}
|
||
if !strings.Contains(got, "no-telnet") {
|
||
t.Fatalf("ознака вбудованого правила загубилась: %s", got)
|
||
}
|
||
}
|
||
|
||
// Друге правило роботи: пароля в журналі немає ні в якому вигляді.
|
||
func TestMemberProfileAuditMetaNeverCarriesPassword(t *testing.T) {
|
||
in := store.UserProfileInput{
|
||
Username: "petrenko",
|
||
Email: "petrenko@example.com",
|
||
Password: userPass,
|
||
}
|
||
got := metaJSON(t, memberProfileAuditMeta(in, "petrenko"))
|
||
|
||
if strings.Contains(got, userPass) {
|
||
t.Fatalf("пароль потрапив у подробиці журналу: %s", got)
|
||
}
|
||
// Але сам факт має лишитись: без нього скидання пароля не
|
||
// відрізнити від перейменування.
|
||
if !strings.Contains(got, `"password"`) {
|
||
t.Fatalf("факт зміни пароля загубився: %s", got)
|
||
}
|
||
if !strings.Contains(got, `"username"`) || !strings.Contains(got, `"email"`) {
|
||
t.Fatalf("перелік змінених полів неповний: %s", got)
|
||
}
|
||
}
|
||
|
||
// Порожній рядок у UserProfileInput означає «не чіпати» — і в переліку
|
||
// змінених полів такого поля бути не повинно.
|
||
func TestMemberProfileAuditMetaListsOnlyChangedFields(t *testing.T) {
|
||
got := metaJSON(t, memberProfileAuditMeta(
|
||
store.UserProfileInput{Password: userPass}, "petrenko"))
|
||
if strings.Contains(got, "full_name") || strings.Contains(got, `"email"`) {
|
||
t.Fatalf("у перелік потрапили поля, яких не міняли: %s", got)
|
||
}
|
||
}
|
||
|
||
// Зона Б цілком: «звідки в цієї людини такий доступ» має відповідь з
|
||
// обома ролями.
|
||
func TestMemberRoleAuditMetaHasBothRoles(t *testing.T) {
|
||
got := metaJSON(t, memberRoleAuditMeta(
|
||
store.MemberRef{UserID: "u1", Username: "petrenko",
|
||
RoleID: "r1", RoleKey: "viewer", RoleName: "Спостерігач"},
|
||
store.MemberRef{UserID: "u1", Username: "petrenko",
|
||
RoleID: "r2", RoleKey: "admin", RoleName: "Адміністратор"},
|
||
))
|
||
|
||
for _, want := range []string{"petrenko", "viewer", "Спостерігач", "admin", "Адміністратор"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Fatalf("у записі про зміну ролі немає %q: %s", want, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestJSONMetaKeepsBrokenJSONAsText(t *testing.T) {
|
||
if got := jsonMeta(""); got != nil {
|
||
t.Errorf("порожній JSON має давати nil, а не %v", got)
|
||
}
|
||
if _, ok := jsonMeta(`{"a":1}`).(json.RawMessage); !ok {
|
||
t.Error("валідний JSON має лягати в подробиці як JSON")
|
||
}
|
||
if got, ok := jsonMeta("{зламано").(string); !ok || got != "{зламано" {
|
||
t.Error("зламаний JSON має лягти рядком, а не завалити весь запис")
|
||
}
|
||
}
|