Netpulse_SasS/server/internal/httpapi/audit_meta_test.go
byrsapty f6df538020 Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди
За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().

Тепер пишуться: правила алертів (створення/зміна/видалення й окремо
вимкнення-увімкнення), канали, драбини ескалації, правила
відповідності; додавання людини в кабінет, зміна ролі, вилучення,
правка профілю й скидання пароля.

Два рішення про зміст запису:
* вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці
  подробиць — питання «хто вимкнув правило, за яким приходив алерт»
  має відповідатись переліком, а не порівнянням;
* config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й
  адреса вебхука (доступ на запис у чужий чат) і заголовок
  Authorization. Замість нього прапорець secret_changed.

Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка
видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери
від шаблонів пишуться повз обробник, і пробне повідомлення в канал.

Прогнано проти справжньої бази: секрет каналу й пароль учасника в
подробиці не потрапляють.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:21 +03:00

273 lines
13 KiB
Go
Raw 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 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 має лягти рядком, а не завалити весь запис")
}
}