Netpulse_SasS/server/internal/store/ncm_compliance_edit_test.go
byrsapty 6de3565bbe
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m9s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m56s
Відповідність: редагування правил, звіт і пʼять знахідок рецензії
Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.

РЕДАГУВАННЯ. Вбудовані правила замкнені на те, що визначає ПИТАННЯ
(name, kind, pattern, config_type) і відкриті на політику кабінету
(enabled, severity, selector, remediation). Причина замка — доказ:
тест читає зразки з міграції й показує для кожного конфіг, де він
мусить спрацювати і де не мусить. Переписаний руками зразок цього
доказу не має, а значок «вбудоване» лишається — у звіті для аудитора
рядок означав би вже не те, що в довіднику. Для правок є копія.

Перевірка зразка на живому конфізі ДО збереження: віддає рядки з
номерами й окремо розрізняє «конфігу немає» від «нічого не знайшов».
Для правил «не має бути» нуль збігів підсвічується: це те саме, що
показало б правило з опискою.

ЗНАХІДКИ РЕЦЕНЗІЇ — всі пʼять підтверджені:

1. Перше збереження будь-якого вбудованого правила стирало результати.
   Селектор порівнювався в базі, але порівнювались різні представлення
   одного значення: міграція кладе {}, Go марширує сім ключів із null.
   Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
   файл відкриває аудитор. Одне місце екранування на всі три звіти:
   дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
   рахувались як чинні. Три заслони: фільтр у списку, прибирання при
   прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
   інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
   число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
   ручка робила протилежне до задуманого в мить найвищої довіри.
2026-08-28 13:40:40 +03:00

1244 lines
60 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 (
"encoding/json"
"errors"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// Редагування правил відповідності та звіт.
//
// НАВІЩО ЦЕЙ ФАЙЛ ІСНУЄ — і чого він НЕ доводить, написано в кінці, у
// TestNothingHereProvesTheWholeChain. Коротко головне:
//
// Урок цього проєкту, дослівно: зелена перевірка доводить рівно те, що
// вона перевіряє. Тест ізоляції RLS був правильний і зелений — і
// пропустив зламаний вхід, бо перевіряв «чи не видно чужого», коли
// зламалось «чи видно своє».
//
// Тут та сама пастка стоїть в іншому місці. Природний тест правила
// відповідності — «зразок збігся, порушення знайшлось». Він зелений і
// нічого не вартий сам по собі: правило зі зразком, що НЕ збігається
// ніколи, теж дає зелений екран — «порушень немає». Тому кожна
// перевірка нижче йде парою: зразок, що ловить, і зразок, що не ловить
// НІКОЛИ, — і другий мусить лишити слід, який видно.
// ---------------------------------------------------------------------
// Взірець конфігу
// ---------------------------------------------------------------------
// Уривок справжнього вигляду `show running-config` — рівно те, на чому
// знайшлись 18 порушень першого прогону на мережі клієнта: типові
// community, telnet на керуванні, пароль зворотним шифром, доступ до
// керування без списку.
const sampleConfig = `!
hostname core-sw-01
!
enable secret 5 $1$abcd$0123456789ABCDEFGH
username admin password 7 070C285F4D06
!
snmp-server community public RO
snmp-server community private RW
!
line vty 0 4
transport input telnet ssh
login local
!
ip http server
!
end`
// ---------------------------------------------------------------------
// 1. Висновок правила: пара «ловить» / «не ловить ніколи»
// ---------------------------------------------------------------------
func TestCheckComplianceVerdict(t *testing.T) {
cases := []struct {
name string
kind string
// pattern — зразок; matches каже, чи він узагалі щось знаходить
// у sampleConfig. Це поле — не дублювання очікування, а
// відповідь на питання «який саме випадок ми зараз перевіряємо»:
// половина рядків нижче існує лише заради matches=false.
pattern string
matches bool
// wantPassed — вислід, який побачить прогін.
wantPassed bool
// wantLine — чи буде що показати людині. Для «має бути» знахідка
// порожня навмисно: проблема саме у відсутності рядка.
wantLine string
wantNum int
}{
{
name: "не має містити: telnet знайдено",
kind: "must_not_contain",
pattern: "transport input telnet",
matches: true,
wantPassed: false,
wantLine: "transport input telnet ssh",
wantNum: 11,
},
{
name: "не має містити: зразок не збігається НІКОЛИ",
// Друкарська помилка на один символ: `imput` замість `input`.
// Правило компілюється, зберігається, проганяється — і
// повідомляє «порушень немає» на конфізі, де telnet
// увімкнений. Оце й є тиха брехня, заради якої існує
// перевірка зразка на живому конфізі.
kind: "must_not_contain",
pattern: "transport imput telnet",
matches: false,
wantPassed: true,
wantLine: "",
wantNum: 0,
},
{
name: "має містити: рядок є",
kind: "must_contain",
pattern: "enable secret",
matches: true,
wantPassed: true,
wantLine: "enable secret 5 $1$abcd$0123456789ABCDEFGH",
wantNum: 4,
},
{
name: "має містити: зразок не збігається НІКОЛИ",
// Тут порожній зразок бʼє в інший бік: правило «має бути»
// з непотрапляючим зразком показує ПОРУШЕННЯ на кожному
// хості. Помилка гучна — її помітять того ж дня. Саме
// тому вона й безпечніша за попередню.
kind: "must_contain",
pattern: "ntp server 10.0.0.1",
matches: false,
wantPassed: false,
wantLine: "",
wantNum: 0,
},
{
name: "збіг за виразом: знайдено",
kind: "regex_match",
pattern: `^\s*login local\s*$`,
matches: true,
wantPassed: true,
wantLine: "login local",
wantNum: 12,
},
{
name: "немає збігу: пароль зворотним шифром",
kind: "regex_absent",
// Тип 7 у Cisco — не шифр, а кодування: розкручується в один
// рядок будь-яким онлайн-декодером.
pattern: `password\s+7\s+[0-9A-Fa-f]+`,
matches: true,
wantPassed: false,
wantLine: "username admin password 7 070C285F4D06",
wantNum: 5,
},
{
name: "немає збігу: вираз, який не збіжиться ніколи",
// `\bpassword\b` після `^` без пробілів: у конфізі рядок
// починається з `username`, тож зразок не наліг НІ НА ЩО.
// І правило показує «пройдено».
kind: "regex_absent",
pattern: `^password\s+7\s`,
matches: false,
wantPassed: true,
wantLine: "",
wantNum: 0,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := ComplianceRule{
Name: c.name, Kind: c.kind, Pattern: c.pattern,
Severity: "high", ConfigType: "running", Enabled: true,
}
if err := validateComplianceRule(r); err != nil {
t.Fatalf("правило мало бути коректним: %v", err)
}
var re *regexp.Regexp
if strings.HasPrefix(c.kind, "regex") {
re = regexp.MustCompile(c.pattern)
}
passed, line, num := checkCompliance(r, re, sampleConfig)
if passed != c.wantPassed {
t.Errorf("вислід = %v, очікували %v", passed, c.wantPassed)
}
if line != c.wantLine {
t.Errorf("рядок = %q, очікували %q", line, c.wantLine)
}
if num != c.wantNum {
t.Errorf("номер рядка = %d, очікували %d", num, c.wantNum)
}
// ГОЛОВНЕ ТУТ. Вислід сам по собі не відрізняє «зразок
// перевірив і не знайшов» від «зразок не має що перевіряти».
// Відрізняє перелік збігів, який показує форма.
got, _ := probeMatches(r, re, sampleConfig)
if c.matches && len(got) == 0 {
t.Errorf("зразок %q не наліг у формі на жоден рядок — людина побачила б "+
"порожній перелік і мусила б здогадуватись", c.pattern)
}
if !c.matches && len(got) != 0 {
t.Errorf("зразок %q мав не знайти нічого, а знайшов %d рядків", c.pattern, len(got))
}
})
}
}
// TestProbeShowsNeverMatchingPattern — та сама пара, але поставлена як
// питання до ФОРМИ, а не до прогону.
//
// Прогін на обох цих правилах дає однакову відповідь: «порушень немає».
// Якби форма показувала лише її, редактор правил був би генератором
// тихої брехні. Тест фіксує, що форма показує РІЗНЕ.
func TestProbeShowsNeverMatchingPattern(t *testing.T) {
good := ComplianceRule{
Kind: "must_not_contain", Pattern: "transport input telnet",
Severity: "critical", ConfigType: "running",
}
typo := good
typo.Pattern = "transport imput telnet" // одна літера
goodPassed, _, _ := checkCompliance(good, nil, sampleConfig)
typoPassed, _, _ := checkCompliance(typo, nil, sampleConfig)
if goodPassed {
t.Fatal("правильне правило мало знайти telnet")
}
if !typoPassed {
t.Fatal("правило з опискою мало показати «пройдено» — саме в цьому й біда")
}
goodHits, _ := probeMatches(good, nil, sampleConfig)
typoHits, _ := probeMatches(typo, nil, sampleConfig)
if len(goodHits) == 0 {
t.Error("справний зразок мусить дати рядки у формі")
}
if len(typoHits) != 0 {
t.Error("зразок з опискою мусить дати ПОРОЖНІЙ перелік — це і є видимий слід помилки")
}
if goodHits[0].LineNumber != 11 {
t.Errorf("номер рядка = %d, очікували 11: без номера знахідка не дія, а натяк",
goodHits[0].LineNumber)
}
}
// TestProbeMatchesAllLines — форма показує ВСІ збіги, а не перший.
//
// Прогін зупиняється на першому, і для висновку цього досить. Але
// людина у формі відповідає на інше питання: «мій зразок ловить те, що
// я думаю». Один рядок на нього не відповідає — обидва community з
// конфігу мають бути видимі.
func TestProbeMatchesAllLines(t *testing.T) {
r := ComplianceRule{
Kind: "regex_absent", Pattern: `snmp-server community (public|private)`,
Severity: "critical", ConfigType: "running",
}
re := regexp.MustCompile(r.Pattern)
hits, truncated := probeMatches(r, re, sampleConfig)
if truncated {
t.Error("двох рядків не буває забагато")
}
if len(hits) != 2 {
t.Fatalf("збігів %d, очікували 2 (public і private)", len(hits))
}
if hits[0].LineNumber != 7 || hits[1].LineNumber != 8 {
t.Errorf("номери рядків = %d, %d; очікували 7 і 8",
hits[0].LineNumber, hits[1].LineNumber)
}
// Стеля видима: обрізаний перелік, поданий як повний, — це та сама
// брехня, лише в інший бік.
big := strings.Repeat("snmp-server community public RO\n", complianceProbeMax+10)
hits, truncated = probeMatches(r, re, big)
if !truncated {
t.Error("перелік понад стелю мусить бути позначений як обрізаний")
}
if len(hits) != complianceProbeMax {
t.Errorf("віддано %d рядків, стеля %d", len(hits), complianceProbeMax)
}
}
// ---------------------------------------------------------------------
// 2. Валідація: що сервер не дасть зберегти
// ---------------------------------------------------------------------
func TestValidateComplianceRule(t *testing.T) {
base := ComplianceRule{
Name: "Telnet вимкнено", Kind: "regex_absent",
Pattern: `transport input.*telnet`, Severity: "critical",
ConfigType: "running", Enabled: true,
}
if err := validateComplianceRule(base); err != nil {
t.Fatalf("коректне правило відхилено: %v", err)
}
cases := []struct {
name string
mut func(r *ComplianceRule)
// why — що саме зламалось би в бойовій мережі, якби це
// збереглось. Не оздоба: без цього рядка наступний читач не
// зрозуміє, чому саме тут потрібна відмова, а не умовчання.
why string
}{
{
name: "невідомий вид",
mut: func(r *ComplianceRule) { r.Kind = "jsonpath" },
why: "jsonpath є в ncm.rule_kind, але checkCompliance його не вміє — правило мовчало б",
},
{
name: "невідома серйозність",
mut: func(r *ComplianceRule) { r.Severity = "urgent" },
why: "приведення до ncm.rule_severity дало б 22P02 і 500 «внутрішня помилка»",
},
{
name: "описка в типі конфігу",
mut: func(r *ComplianceRule) { r.ConfigType = "runnning" },
why: "config_type — звичайний text: описка зберігається мовчки, жоден хост " +
"не підпадає, усі пари йдуть у пропущені, правило показує «порушень немає»",
},
{
name: "порожній зразок",
mut: func(r *ComplianceRule) { r.Pattern = " " },
why: "правило без зразка нічого не перевіряє",
},
{
name: "вираз не компілюється",
mut: func(r *ComplianceRule) { r.Pattern = `transport input (telnet` },
why: "інакше про друкарську помилку людина дізнається не з форми, а з тиші правила",
},
{
name: "перенос рядка у зразку «не має містити»",
mut: func(r *ComplianceRule) {
r.Kind = "must_not_contain"
r.Pattern = "line vty 0 4\n transport input telnet"
},
why: "перевірка йде порядково — зразок із переносом не збігся б НІКОЛИ, " +
"а для «не має містити» це вічне тихе «пройдено»",
},
{
name: "перенос рядка у зразку «має містити»",
mut: func(r *ComplianceRule) {
r.Kind = "must_contain"
r.Pattern = "ntp server 10.0.0.1\n"
},
why: "той самий зразок, що не збіжиться ніколи; тут він сипле порушеннями на " +
"кожному хості — помилка гучна, але однаково неправдива",
},
{
name: "перенос рядка у регулярному виразі",
mut: func(r *ComplianceRule) { r.Pattern = "transport input\n.*telnet" },
why: "вираз компілюється, але жоден поданий рядок переносу вже не містить",
},
{
name: "повернення каретки у зразку",
mut: func(r *ComplianceRule) { r.Pattern = "transport input telnet\r" },
why: "конфіги приходять і з CRLF: strings.Split по \\n лишає \\r у кінці рядка, " +
"тож зразок із \\r збігається то там, то там — залежно від пристрою",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := base
c.mut(&r)
err := validateComplianceRule(r)
if err == nil {
t.Fatalf("збереглося б, а не мало: %s", c.why)
}
if !errors.Is(err, ErrInvalid) {
t.Errorf("помилка мусить бути ErrInvalid (400, а не 500): %v", err)
}
})
}
}
// TestValidateAcceptsEveryConfigType — інший бік попередньої перевірки.
//
// Без нього «жоден тип не проходить» був би так само зеленим: перевірка
// відмов доводить лише те, що відмови бувають. Пара обовʼязкова — це
// рівно урок RLS: «чи не видно чужого» без «чи видно своє» не доводить
// нічого.
func TestValidateAcceptsEveryConfigType(t *testing.T) {
for _, ct := range []string{"running", "startup", "files"} {
r := ComplianceRule{
Name: "x", Kind: "must_contain", Pattern: "ntp",
Severity: "low", ConfigType: ct,
}
if err := validateComplianceRule(r); err != nil {
t.Errorf("тип конфігу %q мусить прийматись: %v", ct, err)
}
}
for _, sev := range complianceSeverityOrder {
r := ComplianceRule{
Name: "x", Kind: "must_contain", Pattern: "ntp",
Severity: sev, ConfigType: "running",
}
if err := validateComplianceRule(r); err != nil {
t.Errorf("серйозність %q мусить прийматись: %v", sev, err)
}
}
}
// ---------------------------------------------------------------------
// 3. Замок на вбудованих правилах
// ---------------------------------------------------------------------
func TestBuiltinImmutable(t *testing.T) {
cur := complianceRuleHead{
BuiltinKey: "snmp-default-community",
Name: "SNMP: типові community",
Kind: "regex_absent",
Pattern: `snmp-server community\s+(public|private)`,
ConfigType: "running",
}
same := ComplianceRule{
Name: cur.Name, Kind: cur.Kind, Pattern: cur.Pattern, ConfigType: cur.ConfigType,
}
t.Run("те саме питання — правка проходить", func(t *testing.T) {
// Політика кабінету змінюється вільно: це і є те, заради чого
// 0071 завела копію правила на кабінет.
r := same
r.Enabled = false
r.Severity = "low"
r.Remediation = "прибрати community з конфігу"
r.Selector = Selector{Vendors: []string{"Cisco"}}
if err := checkBuiltinImmutable(cur, r); err != nil {
t.Errorf("вимкнути, звузити селектор і знизити серйозність мусить бути можна: %v", err)
}
})
locked := []struct {
field string
mut func(r *ComplianceRule)
}{
{"зразок", func(r *ComplianceRule) { r.Pattern = "community" }},
{"назва", func(r *ComplianceRule) { r.Name = "Наше правило" }},
{"вид умови", func(r *ComplianceRule) { r.Kind = "must_not_contain" }},
{"тип конфігу", func(r *ComplianceRule) { r.ConfigType = "files" }},
}
for _, c := range locked {
t.Run("замкнено: "+c.field, func(t *testing.T) {
r := same
c.mut(&r)
err := checkBuiltinImmutable(cur, r)
if err == nil {
t.Fatalf("%s вбудованого правила переписалось би, а значок «вбудоване» "+
"лишився бі звіт посилався б на довідник, якого вже не дотримується",
c.field)
}
if !errors.Is(err, ErrInvalid) {
t.Errorf("мусить бути ErrInvalid: %v", err)
}
if !strings.Contains(err.Error(), "копію") {
t.Errorf("відмова мусить називати вихід («зробіть копію»), а не лише заборону: %q",
err.Error())
}
})
}
t.Run("своє правило не замкнене", func(t *testing.T) {
own := complianceRuleHead{Name: "Наше", Kind: "must_contain",
Pattern: "ntp server", ConfigType: "running"}
r := ComplianceRule{Name: "Інше", Kind: "regex_absent",
Pattern: "telnet", ConfigType: "files"}
if err := checkBuiltinImmutable(own, r); err != nil {
t.Errorf("правило без builtin_key правиться цілком: %v", err)
}
})
}
// TestComplianceSubjectChanged — що стирає картину прогону, а що ні.
//
// Це прямо про головну дію над вбудованим правилом — «вимкнути в
// себе». Якби вимкнення стирало результати, людина платила б за
// один клац усією картиною мережі по цьому правилу — і втрата
// була б тихою: на екрані просто стало б менше рядків.
func TestComplianceSubjectChanged(t *testing.T) {
cur := complianceRuleHead{
BuiltinKey: "telnet-disabled",
Name: "Telnet вимкнено",
Kind: "regex_absent",
Pattern: `transport input.*telnet`,
ConfigType: "running",
}
same := ComplianceRule{
Name: cur.Name, Kind: cur.Kind, Pattern: cur.Pattern,
ConfigType: cur.ConfigType, Severity: "high", Enabled: true,
}
keep := []struct {
name string
mut func(r *ComplianceRule)
}{
{"вимкнення", func(r *ComplianceRule) { r.Enabled = false }},
{"інша серйозність", func(r *ComplianceRule) { r.Severity = "low" }},
{"інша порада", func(r *ComplianceRule) { r.Remediation = "transport input ssh" }},
{"інший опис", func(r *ComplianceRule) { r.Description = "вимога безпеки" }},
}
for _, c := range keep {
t.Run("зберігає: "+c.name, func(t *testing.T) {
r := same
c.mut(&r)
if complianceSubjectChanged(cur, r) {
t.Error("питання не змінилось — результати мають лишитись")
}
})
}
drop := []struct {
name string
mut func(r *ComplianceRule)
}{
{"інший зразок", func(r *ComplianceRule) { r.Pattern = "telnet" }},
{"інший вид", func(r *ComplianceRule) { r.Kind = "must_not_contain" }},
{"інший тип конфігу", func(r *ComplianceRule) { r.ConfigType = "startup" }},
}
for _, c := range drop {
t.Run("стирає: "+c.name, func(t *testing.T) {
r := same
c.mut(&r)
if !complianceSubjectChanged(cur, r) {
t.Error("правило питає вже інше — старі результати більше ні про що не свідчать")
}
})
}
t.Run("звужений селектор стирає", func(t *testing.T) {
c := cur
c.Selector = Selector{} // було «до всіх»
r := same
r.Selector = Selector{GroupIDs: []string{"g-branch"}}
if !complianceSubjectChanged(c, r) {
t.Error("після звуження селектора результати лишились би про хости, " +
"яких правило вже не стосується")
}
})
t.Run("той самий селектор в іншій формі запису НЕ стирає", func(t *testing.T) {
// Головний випадок цього файла: `{}` з міграції 0071 і порожній
// Selector із форми — той самий склад хостів.
c := cur
var err error
if c.Selector, err = parseSelector(`{}`); err != nil {
t.Fatal(err)
}
r := same
r.Enabled = false // єдина справжня зміна: «вимкнути в себе»
if complianceSubjectChanged(c, r) {
t.Error("вимкнення вбудованого правила стерло б картину всієї мережі по ньому")
}
})
}
// TestSelectorComparedByValueNotByForm — найдорожча знахідка рецензії,
// відтворена на самих ПРЕДСТАВЛЕННЯХ.
//
// Тест навмисно не про прапорець «селектор змінився», а про два тексти,
// які насправді зустрічаються: `{}` кладе ncm.builtin_compliance_apply
// (0071), а сім ключів зі значенням null дає json.Marshal(Selector) —
// у store.Selector немає жодного `omitempty`.
//
// Стара перевірка жила в SQL (`selector IS DISTINCT FROM $3::jsonb`) і
// була ПРАВИЛЬНОЮ по суті: jsonb порівнює за змістом, а не за формою.
// Але для jsonb `{}` і `{"device_ids":null,…}` — різні значення. Отже,
// ПЕРШЕ збереження будь-якого вбудованого правила — вимкнути, знизити
// серйозність, дописати пораду — вважалось зміною селектора й стирало
// ncm.compliance_results. Помилка одноразова на правило, тому непомітна;
// а стерта картина мережі виглядає точнісінько як «у вас усе добре».
func TestSelectorComparedByValueNotByForm(t *testing.T) {
// Спершу доводимо, що проблема справді існує, а не вигадана: два
// описи ПОРОЖНЬОГО селектора — різні тексти й різні значення jsonb.
rawGo, err := json.Marshal(Selector{})
if err != nil {
t.Fatal(err)
}
const stored = `{}`
if string(rawGo) == stored {
t.Fatalf("у Selector з'явився omitempty — цей тест треба переписати; marshal дав %s", rawGo)
}
if !jsonbDistinct(t, string(rawGo), stored) {
t.Fatal("jsonb вважав би ці два описи однаковими — тоді помилки не було б")
}
cases := []struct {
name string
// stored — як селектор лежить у базі.
stored string
next Selector
same bool
}{
{"порожній із 0071 проти порожнього з форми", `{}`, Selector{}, true},
{"сім null-ключів проти порожнього з форми",
`{"device_ids":null,"group_ids":null,"site_ids":null,"kinds":null,` +
`"template_ids":null,"vendors":null,"tags":null}`, Selector{}, true},
{"порожній масив — це теж «до всіх»", `{"vendors":[]}`, Selector{}, true},
{"колонка NULL", ``, Selector{}, true},
{"вендори: той самий склад", `{"vendors":["Cisco"]}`,
Selector{Vendors: []string{"Cisco"}}, true},
{"вендори: інший пробіл у тексті", `{"vendors": [ "Cisco" ]}`,
Selector{Vendors: []string{"Cisco"}}, true},
{"вендори: 0071 із вендором проти форми з нулями в решті",
`{"vendors":["Cisco","Huawei"]}`,
Selector{Vendors: []string{"Cisco", "Huawei"}}, true},
// А ось це — справжні зміни складу хостів, і їх пропустити не
// можна: старі результати лишились би про хости, яких правило
// вже не стосується.
{"вендора додали", `{"vendors":["Cisco"]}`,
Selector{Vendors: []string{"Cisco", "Huawei"}}, false},
{"звузили до групи", `{}`,
Selector{GroupIDs: []string{"g-1"}}, false},
{"звузили до окремих хостів", `{}`,
Selector{DeviceIDs: []string{"d-1"}}, false},
{"мітки з'явились", `{}`,
Selector{Tags: map[string]string{"env": "prod"}}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cur, err := parseSelector(c.stored)
if err != nil {
t.Fatalf("розбір збереженого селектора: %v", err)
}
if got := selectorsEqual(cur, c.next); got != c.same {
t.Errorf("selectorsEqual = %v, очікували %v; порівнюватись має ЗНАЧЕННЯ, "+
"а не форма запису — інакше правка правила стирає картину мережі",
got, c.same)
}
})
}
}
// jsonbDistinct — те саме, що `a IS DISTINCT FROM b` для двох jsonb.
//
// Потрібне рівно для одного: показати в тесті, що стара перевірка в SQL
// не була «текстовим порівнянням, яке легко полагодити пробілом». Вона
// порівнювала значення — просто значення були різні, бо описи були
// різні.
func jsonbDistinct(t *testing.T, a, b string) bool {
t.Helper()
var ma, mb map[string]any
if err := json.Unmarshal([]byte(a), &ma); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(b), &mb); err != nil {
t.Fatal(err)
}
return !reflect.DeepEqual(ma, mb)
}
// TestSelectorJSONIsCanonical — те, що їде в базу, залежить лише від
// значення.
//
// ПРИЧИНА: selectorJSON — це не лише порівняння, а й те, що пишеться в
// колонку. Отже, база нормалізується сама, при кожному збереженні, і
// окрема міграція для цього не потрібна. Але тільки якщо форма стала.
func TestSelectorJSONIsCanonical(t *testing.T) {
empty, err := selectorJSON(Selector{})
if err != nil {
t.Fatal(err)
}
if string(empty) != "{}" {
t.Errorf("порожній селектор пишеться як %s, а 0071 кладе {} — знову дві форми того самого",
empty)
}
// Порожні виміри не потрапляють у запис узагалі: «жодної групи» і
// «поля немає» — одне твердження.
withEmpties, err := selectorJSON(Selector{
Vendors: []string{"Cisco"},
GroupIDs: []string{},
Tags: map[string]string{},
})
if err != nil {
t.Fatal(err)
}
if string(withEmpties) != `{"vendors":["Cisco"]}` {
t.Errorf("канонічний запис = %s; порожні виміри мали зникнути", withEmpties)
}
}
// TestApplyComplianceCounts — лічильники списку правил рахуються з тих
// самих рядків, що й «Знахідки».
//
// ПРИЧИНА. Раніше їх давали два count(*) у запиті правил — по ВСІХ
// хостах кабінету, без Scope й без відсіву видалених. Інженер філії
// читав «5 з 12 не пройшли», відкривав знахідки — там дві. Крім
// розбіжності, це ще й витік: агрегат розповідав про хости, яких людині
// бачити не належить.
func TestApplyComplianceCounts(t *testing.T) {
rules := []ComplianceRule{
// Числа, «принесені з SQL по всьому кабінету»: саме їх треба
// перебити тим, що людина бачить.
{ID: "r-1", Name: "SNMP", Failed: 5, Passed: 7},
{ID: "r-2", Name: "Telnet", Failed: 3, Passed: 0},
}
visible := []ComplianceResult{
{RuleID: "r-1", DeviceID: "d-1", Passed: false},
{RuleID: "r-1", DeviceID: "d-2", Passed: false},
{RuleID: "r-1", DeviceID: "d-3", Passed: true},
}
got := ApplyComplianceCounts(rules, visible)
if got[0].Failed != 2 || got[0].Passed != 1 {
t.Errorf("SNMP: %d не пройшли з %d; очікували 2 і 1 — рівно те, що у «Знахідках»",
got[0].Failed, got[0].Failed+got[0].Passed)
}
// Правило, від якого людині не видно жодного хоста, мусить показати
// нулі, а не чуже число. «3 не пройшли» без жодного рядка нижче — це
// повідомлення про чужий парк.
if got[1].Failed != 0 || got[1].Passed != 0 {
t.Errorf("Telnet: %d/%d; очікували нулі — видимих результатів немає",
got[1].Failed, got[1].Passed)
}
}
// ---------------------------------------------------------------------
// 4. Звіт
// ---------------------------------------------------------------------
func reportFixture() ([]ComplianceRule, []ComplianceResult) {
rules := []ComplianceRule{
{
ID: "r-snmp", Name: "SNMP: типові community", Kind: "regex_absent",
Pattern: `community\s+(public|private)`, Severity: "critical",
ConfigType: "running", Enabled: true, BuiltinKey: "snmp-default-community",
Remediation: "no snmp-server community public",
},
{
ID: "r-telnet", Name: "Telnet вимкнено", Kind: "regex_absent",
Pattern: `transport input.*telnet`, Severity: "high",
ConfigType: "running", Enabled: true, BuiltinKey: "telnet-disabled",
Remediation: "line vty 0 4 / transport input ssh",
},
{
// Вимкнене правило: у прогоні його немає, результатів немає,
// і звіт мусить сказати про це вголос.
ID: "r-acl", Name: "Керування обмежене списком", Kind: "must_contain",
Pattern: "access-class", Severity: "high",
ConfigType: "running", Enabled: false, BuiltinKey: "mgmt-acl",
},
{
// Своє правило, увімкнене, але жодного разу не проганялось.
ID: "r-own", Name: "Наш NTP", Kind: "must_contain",
Pattern: "ntp server 10.0.0.1", Severity: "medium",
ConfigType: "running", Enabled: true,
},
}
at := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC)
results := []ComplianceResult{
{RuleID: "r-snmp", RuleName: rules[0].Name, Severity: "critical",
DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false,
Line: "snmp-server community public RO", LineNumber: 7, CheckedAt: at},
{RuleID: "r-snmp", RuleName: rules[0].Name, Severity: "critical",
DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: false,
Line: "snmp-server community private RW", LineNumber: 8, CheckedAt: at},
{RuleID: "r-telnet", RuleName: rules[1].Name, Severity: "high",
DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false,
Line: "transport input telnet ssh", LineNumber: 12,
CheckedAt: at.Add(time.Minute)},
{RuleID: "r-telnet", RuleName: rules[1].Name, Severity: "high",
DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: true, CheckedAt: at},
}
return rules, results
}
func TestComplianceReportOf(t *testing.T) {
rules, results := reportFixture()
at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
rep := ComplianceReportOf(at, "ops", false, rules, results)
if rep.RulesTotal != 4 || rep.RulesEnabled != 3 || rep.RulesBuiltin != 3 {
t.Errorf("склад правил: усього %d, увімкнено %d, вбудованих %d; очікували 4/3/3",
rep.RulesTotal, rep.RulesEnabled, rep.RulesBuiltin)
}
// Два правила без жодної перевірки: вимкнене й те, що ще не
// проганялось. Без цього числа нулі в звіті означали б «усе гаразд».
if rep.RulesNeverRun != 2 {
t.Errorf("правил без перевірки = %d, очікували 2", rep.RulesNeverRun)
}
if rep.Checks != 4 || rep.Failed != 3 {
t.Errorf("перевірок %d, порушень %d; очікували 4 і 3", rep.Checks, rep.Failed)
}
if rep.Devices != 2 || rep.DevicesFailed != 2 {
t.Errorf("хостів %d, з них із порушеннями %d; очікували 2 і 2",
rep.Devices, rep.DevicesFailed)
}
if rep.LastCheckAt == nil || !rep.LastCheckAt.Equal(
time.Date(2026, 8, 28, 9, 31, 0, 0, time.UTC)) {
t.Errorf("остання перевірка = %v, очікували найпізнішу з результатів", rep.LastCheckAt)
}
// Розподіл за серйозністю — з нулями. «Критичних 0» — це відповідь;
// відсутній рядок читається як «не перевіряли».
want := map[string]int{"critical": 2, "high": 1, "medium": 0, "low": 0, "info": 0}
if len(rep.BySeverity) != len(complianceSeverityOrder) {
t.Fatalf("рядків серйозності %d, очікували %d",
len(rep.BySeverity), len(complianceSeverityOrder))
}
for i, s := range rep.BySeverity {
if s.Severity != complianceSeverityOrder[i] {
t.Errorf("порядок серйозностей збився на %d: %q", i, s.Severity)
}
if s.Count != want[s.Severity] {
t.Errorf("%s: %d, очікували %d", s.Severity, s.Count, want[s.Severity])
}
}
// Порада їде разом зі знахідкою: звіт читає той, хто цього конфігу
// ніколи не бачив.
if len(rep.Violations) != 3 {
t.Fatalf("порушень у переліку %d, очікували 3", len(rep.Violations))
}
if rep.Violations[0].Severity != "critical" {
t.Errorf("перше порушення мусить бути найважче, а не %q", rep.Violations[0].Severity)
}
for _, v := range rep.Violations {
if v.Remediation == "" {
t.Errorf("порушення %q/%q без поради — читач не знатиме, що робити",
v.DeviceName, v.RuleName)
}
}
// Стан правил у шапці: увімкнене без жодної перевірки мусить бути
// відрізнюване від увімкненого й перевіреного.
byName := map[string]ComplianceReportRule{}
for _, r := range rep.Rules {
byName[r.Name] = r
}
if r := byName["Наш NTP"]; r.Checked != 0 || !r.Enabled {
t.Errorf("правило «Наш NTP» мало бути ввімкненим і без перевірок: %+v", r)
}
if r := byName["SNMP: типові community"]; r.Checked != 2 || r.Failed != 2 {
t.Errorf("SNMP: перевірок %d, порушень %d; очікували 2 і 2", r.Checked, r.Failed)
}
if r := byName["Керування обмежене списком"]; r.Enabled {
t.Error("вимкнене правило мусить лишатись у складі звіту саме як вимкнене")
}
}
// TestComplianceReportSkipsDisabledRuleResults — у підсумку немає
// відповідей на питання, яких більше не ставлять.
//
// ПРИЧИНА. RunCompliance вимкнене правило пропускає й старих його
// результатів не чіпає — і правильно робить: «вимкнути в себе» головна
// дія над вбудованим правилом, і платити за неї стертою картиною мережі
// немає за що. Але доки ці рядки рахувались нарівні з рештою, вимкнене
// правило давало порушення НАЗАВЖДИ: клієнт свідомо знімає вимогу, а
// звіт для аудитора й далі показує по ній чотири порушення з датою
// піврічної давнини.
//
// ЩО МУСИТЬ ЛИШИТИСЬ ВИДИМИМ: саме правило. Вимкнене правило зникнути зі
// звіту не може — звіт без нього читається як «вимоги немає», і це та
// сама помилка, від якої застерігає шапка ncm_compliance_report.go.
func TestComplianceReportSkipsDisabledRuleResults(t *testing.T) {
rules, results := reportFixture()
at := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC)
// r-acl вимкнене; додаємо йому старі знахідки — рівно те, що лежить
// у базі в клієнта, який колись прогнав перевірку, а тоді вимогу зняв.
stale := []ComplianceResult{
{RuleID: "r-acl", RuleName: "Керування обмежене списком", Severity: "high",
DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false,
Line: "", LineNumber: 0, CheckedAt: at.Add(-90 * 24 * time.Hour)},
{RuleID: "r-acl", RuleName: "Керування обмежене списком", Severity: "high",
DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: false,
CheckedAt: at.Add(-90 * 24 * time.Hour)},
}
base := ComplianceReportOf(at, "ops", false, rules, results)
rep := ComplianceReportOf(at, "ops", false, rules, append(append([]ComplianceResult{}, results...), stale...))
if rep.Failed != base.Failed {
t.Errorf("порушень %d проти %d без старих рядків — у підсумок потрапили "+
"відповіді на зняте питання", rep.Failed, base.Failed)
}
if len(rep.Violations) != len(base.Violations) {
t.Errorf("порушень у переліку %d, очікували %d", len(rep.Violations), len(base.Violations))
}
for _, v := range rep.Violations {
if v.RuleName == "Керування обмежене списком" {
t.Error("знахідка вимкненого правила потрапила в перелік порушень")
}
}
for _, s := range rep.BySeverity {
if s.Severity == "high" && s.Count != 1 {
t.Errorf("високих порушень %d, очікували 1 — старі рядки додались у розподіл", s.Count)
}
}
// А правило зі звіту нікуди не поділось — воно там саме як вимкнене.
var seen bool
for _, r := range rep.Rules {
if r.Name == "Керування обмежене списком" {
seen = true
if r.Enabled {
t.Error("правило мало лишитись вимкненим")
}
if r.Failed != 0 || r.Checked != 0 {
t.Errorf("вимкнене правило показує %d/%d — це числа з питання, якого не ставлять",
r.Failed, r.Checked)
}
}
}
if !seen {
t.Error("вимкнене правило зникло зі складу — звіт без нього читається як «вимоги немає»")
}
}
// TestComplianceReportPartialScope — звіт, зроблений людиною, яка
// бачить не весь парк.
//
// Найдорожча помилка звіту: інженер філії вивантажує правду про свої
// хости, а виглядає це як правда про мережу. Підсумок мусить рахуватись
// ПІСЛЯ відсіву невидимого, інакше шапка суперечитиме переліку.
func TestComplianceReportPartialScope(t *testing.T) {
rules, results := reportFixture()
visible := results[:0:0]
for _, x := range results {
if x.DeviceID == "d-1" {
visible = append(visible, x)
}
}
rep := ComplianceReportOf(time.Now(), "branch-eng", true, rules, visible)
if !rep.PartialScope {
t.Error("обмежена видимість мусить лишити слід у звіті")
}
if rep.Devices != 1 {
t.Errorf("хостів %d, очікували 1: рахувати треба видиме", rep.Devices)
}
if rep.Failed != len(rep.Violations) {
t.Errorf("шапка каже %d порушень, у переліку %d — звіт суперечить сам собі",
rep.Failed, len(rep.Violations))
}
}
// TestComplianceReportNeverRun — звіт, коли перевірки не було жодної.
//
// Найнебезпечніший стан із усіх: нулі скрізь, порожній перелік порушень.
// Читається як бездоганна мережа.
func TestComplianceReportNeverRun(t *testing.T) {
rules, _ := reportFixture()
rep := ComplianceReportOf(time.Now(), "ops", false, rules, nil)
if rep.LastCheckAt != nil {
t.Error("останньої перевірки не було — поле мусить лишитись порожнім")
}
if rep.RulesNeverRun != len(rules) {
t.Errorf("без перевірки %d із %d правил", rep.RulesNeverRun, len(rules))
}
var buf strings.Builder
if err := ComplianceReportCSV(&buf, rep); err != nil {
t.Fatalf("CSV: %v", err)
}
out := buf.String()
if !strings.Contains(out, "не проводилась") {
t.Error("шапка мусить сказати, що перевірки не було")
}
if !strings.Contains(out, "перевірок не було") {
t.Error("порожній розділ порушень мусить пояснити себе, а не мовчати зеленим")
}
}
func TestComplianceReportCSV(t *testing.T) {
rules, results := reportFixture()
at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
rep := ComplianceReportOf(at, "ops", false, rules, results)
var buf strings.Builder
if err := ComplianceReportCSV(&buf, rep); err != nil {
t.Fatalf("CSV: %v", err)
}
out := buf.String()
if !strings.HasPrefix(out, csvBOM) {
t.Error("без BOM український Excel читає UTF-8 як cp1251 — назви хостів стануть кракозябрами")
}
if !strings.Contains(out, "sep=;") {
t.Error("без sep=; Excel бере роздільником кому")
}
if !strings.Contains(out, "\r\n") {
t.Error("Excel чекає CRLF")
}
// Шапка — те, що робить файл документом. Без дати й складу правил
// два роздруки з різними числами нічим пояснити.
for _, must := range []string{
"Сформовано;2026-08-28T10:00:00Z",
"Правил усього;4",
"Порушень;3",
"Порушень, критична;2",
"СКЛАД ПРАВИЛ НА МОМЕНТ ЗВІТУ",
"ПОРУШЕННЯ",
} {
if !strings.Contains(out, must) {
t.Errorf("у звіті немає рядка %q", must)
}
}
// Зразок правила — у складі. Це найважливіше поле шапки: за місяць
// його змінять, і без відбитка в звіті пояснити різницю чисел буде
// нічим.
if !strings.Contains(out, `community\s+(public|private)`) {
t.Error("склад правил мусить нести самі зразки, а не лише назви")
}
// Походження — теж: аудитор має відрізняти правило постачальника від
// написаного клієнтом.
if !strings.Contains(out, "вбудоване (snmp-default-community)") {
t.Error("склад правил мусить називати походження")
}
if !strings.Contains(out, "увімкнено, але жодної перевірки") {
t.Error("правило без жодної перевірки мусить бути видиме окремим станом")
}
if !strings.Contains(out, "вимкнено") {
t.Error("вимкнене правило мусить бути видиме як вимкнене")
}
if !strings.Contains(out, "line vty 0 4 / transport input ssh") {
t.Error("порада мусить бути поруч зі знахідкою")
}
}
// TestComplianceReportCSVFormulaInjection — вивантаження не виконується
// в Excel.
//
// ЦІНА ПОМИЛКИ. У клітинку «Що знайдено» їде СИРИЙ рядок конфігу
// пристрою: текст, який у мережу поклав хтось із доступом до заліза —
// тобто, за задумом самої перевірки відповідності, потенційно не той,
// кому довіряють. Файл відкриває керівник або аудитор, у Excel, зі
// своїми правами. Клітинка `=cmd|'/C calc'!A0` виконується при
// відкритті. Звіт про безпеку конфігів, який виконує код на машині
// аудитора, — це рівно протилежне до того, чим він є.
//
// Перевіряються всі поля, що приходять ззовні, а не одне: захист,
// покладений на одну клітинку, забувають у сусідній.
func TestComplianceReportCSVFormulaInjection(t *testing.T) {
rules := []ComplianceRule{{
ID: "r-1", Name: "=HYPERLINK(\"http://evil\",\"звіт\")", Kind: "must_not_contain",
Pattern: "+telnet", Severity: "critical", ConfigType: "running", Enabled: true,
Remediation: "@SUM(A1:A9)",
}}
results := []ComplianceResult{{
RuleID: "r-1", RuleName: rules[0].Name, Severity: "critical",
DeviceID: "d-1", DeviceName: "-2+3+cmd|'/C calc'!A0", Passed: false,
Line: `=cmd|'/C calc'!A0`, LineNumber: 11,
CheckedAt: time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC),
}}
var buf strings.Builder
if err := ComplianceReportCSV(&buf, ComplianceReportOf(
time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC), "ops", false, rules, results)); err != nil {
t.Fatalf("CSV: %v", err)
}
out := buf.String()
// Небезпечні клітинки мусять бути екрановані апострофом — і не
// втратити при цьому вмісту: рядок конфігу лишається доказом, він
// лише перестає бути формулою.
for _, want := range []string{
`'=cmd|'`, // рядок конфігу з пристрою
`'-2+3+cmd|'`, // імʼя хоста
`'=HYPERLINK(`, // назва правила
`'+telnet`, // зразок правила
`'@SUM(A1:A9)`, // порада «як виправити»
} {
if !strings.Contains(out, want) {
t.Errorf("у файлі немає %q — клітинка поїхала в Excel формулою", want)
}
}
// Зворотний бік: жодна клітинка не сміє ПОЧИНАТИСЬ із небезпечного
// символу. Перелік вище доводить, що ми щось екранували; ця
// перевірка — що не лишилось нічого неекранованого.
for i, line := range strings.Split(out, "\r\n") {
for _, cell := range strings.Split(strings.TrimPrefix(line, csvBOM), ";") {
cell = strings.TrimPrefix(cell, `"`)
if cell == "" || !csvFormulaLead(cell[0]) {
continue
}
if _, err := strconv.ParseFloat(cell, 64); err == nil {
continue // число, а не формула
}
if strings.HasPrefix(line, "sep=") {
continue
}
t.Errorf("рядок %d: клітинка %q починається з символу формули", i+1, cell)
}
}
}
// TestCSVSafeCellKeepsNumbers — екранування не ламає чисел.
//
// ПРИЧИНА окремої перевірки: `-5` починається з небезпечного символу й
// формулою не є. Екранувати його означало б перетворити число на текст —
// і стовпчик простою в SLA-звіті перестав би складатися. Читач звіту
// побачив би не помилку, а порожній підсумок.
func TestCSVSafeCellKeepsNumbers(t *testing.T) {
safe := []string{"-5", "-0.75", "+12", "99.912", "0", "core-sw-01", ""}
for _, s := range safe {
if got := csvSafeCell(s); got != s {
t.Errorf("csvSafeCell(%q) = %q — значення зіпсовано без потреби", s, got)
}
}
danger := []string{"=1+1", "+cmd|'/C calc'!A0", "-2+3+cmd", "@SUM(A1)", "\t=1+1", "\r=1+1"}
for _, s := range danger {
got := csvSafeCell(s)
if !strings.HasPrefix(got, "'") {
t.Errorf("csvSafeCell(%q) = %q — формула поїхала б у таблицю як формула", s, got)
}
if strings.TrimPrefix(got, "'") != s {
t.Errorf("csvSafeCell(%q) = %q — вміст клітинки втрачено", s, got)
}
}
}
// TestComplianceReportCSVEndMarker — обрізаний файл видно як обрізаний.
//
// ПРИЧИНА. Звіт іде в потік ПІСЛЯ статусу 200: помилку запису посеред
// стріму обробник лише журналює, а в людини на диску лишається коротший
// файл. Обрізаний CSV відкривається, читається й виглядає повним — у
// ньому просто менше порушень. Кінцевий маркер робить різницю видимою
// тому, хто відкрив файл, а не тому, хто читає журнал сервера.
func TestComplianceReportCSVEndMarker(t *testing.T) {
rules, results := reportFixture()
rep := ComplianceReportOf(time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC),
"ops", false, rules, results)
var buf strings.Builder
if err := ComplianceReportCSV(&buf, rep); err != nil {
t.Fatalf("CSV: %v", err)
}
out := buf.String()
if !strings.Contains(out, "КІНЕЦЬ ЗВІТУ") {
t.Fatal("без кінцевого маркера обрізаний файл не відрізнити від повного")
}
// Маркер мусить бути саме в КІНЦІ, інакше він нічого не доводить.
if !strings.HasPrefix(strings.TrimSpace(out[strings.LastIndex(out, "\r\n\r\n")+4:]), "КІНЕЦЬ ЗВІТУ") {
t.Error("маркер стоїть не в кінці файла")
}
// І мусить нести число, з яким можна звірити перелік вище.
if !strings.Contains(out, "порушень у файлі: "+strconv.Itoa(len(rep.Violations))) {
t.Error("маркер без числа не дає з чим звірити перелік")
}
}
// TestComplianceReportStableOrder — два вивантаження того самого стану
// дають той самий файл.
//
// Не косметика: звіти порівнюють diff-ом («що змінилось за місяць»), і
// перетасований порядок рядків робить це порівняння нечитабельним —
// тобто скасовує єдиний спосіб побачити рух.
func TestComplianceReportStableOrder(t *testing.T) {
rules, results := reportFixture()
at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
shuffled := []ComplianceResult{results[3], results[1], results[2], results[0]}
var a, b strings.Builder
if err := ComplianceReportCSV(&a, ComplianceReportOf(at, "ops", false, rules, results)); err != nil {
t.Fatal(err)
}
if err := ComplianceReportCSV(&b, ComplianceReportOf(at, "ops", false, rules, shuffled)); err != nil {
t.Fatal(err)
}
if a.String() != b.String() {
t.Error("порядок результатів у вибірці не має впливати на файл звіту")
}
}
func TestComplianceReportFileNameHasDate(t *testing.T) {
rep := ComplianceReport{GeneratedAt: time.Date(2026, 8, 28, 10, 5, 0, 0, time.UTC)}
got := ComplianceReportFileName(rep)
if got != "compliance-2026-08-28-1005.csv" {
t.Errorf("імʼя файла = %q; без дати десяток таких файлів у папці не розрізнити", got)
}
}
// ---------------------------------------------------------------------
// 5. Чого тут НЕМАЄ
// ---------------------------------------------------------------------
// TestNothingHereProvesTheWholeChain нічого не перевіряє. Це список меж,
// поставлений там, де його прочитають, — у файлі з тестами, а не в
// документі, який відкривають раз.
//
// НЕ ПОКРИТО ТУТ:
//
// 1. SaveComplianceRule / DeleteComplianceRule проти справжньої бази.
// Замок вбудованих правил перевірено на checkBuiltinImmutable —
// чистій функції. Що SaveComplianceRule справді читає поточний рядок
// ПІД БЛОКУВАННЯМ у тій самій транзакції й справді кличе цю функцію
// перед UPDATE, не доводить ніщо: ці рядки перевіряються лише
// прогоном проти Postgres (NETPULSE_TEST_DSN), який на машині
// розробника мовчки пропускається.
//
// 2. RLS. Що правило одного кабінету не видно з іншого й що звіт не
// збирає чужих хостів — тут не перевіряється взагалі. Ізоляція
// живе в політиках бази й у InTenantTx.
//
// 3. Відсів за Scope. Часткова видимість подана у ComplianceReportOf
// готовим прапорцем; що httpapi справді відсіює невидимі хости
// ПЕРЕД підрахунком (а не після), перевіряється лише читанням коду.
// Помилка тут дала б рівно ту суперечність, від якої застерігає
// TestComplianceReportPartialScope, — і цей тест її б не спіймав.
//
// 4. Розшифрування конфігу. ProbeComplianceRule ходить у ConfigBody із
// keyring; сервер без ключа відповідає 503. Перевірено вручну,
// тестом — ні.
//
// 5. Стирання результатів при зміні СЕЛЕКТОРА — лише ЧАСТКОВО.
// Порівняння самих представлень тепер перевірене без бази
// (TestSelectorComparedByValueNotByForm: `{}` із 0071 проти семи
// null-ключів із json.Marshal). Це та ланка, де жила помилка, і
// раніше її з тесту було вийнято готовим прапорцем.
// Але НЕ перевірено, що SaveComplianceRule справді читає збережений
// селектор тим самим SELECT ... FOR UPDATE і справді кличе
// complianceSubjectChanged перед DELETE, — це видно лише прогоном
// проти Postgres.
//
// 6. Прибирання результатів по хостах, які випали із селектора
// (pruneComplianceResults), і фільтри `cr.enabled` /
// `d.deleted_at IS NULL` у ListComplianceResults. Це SQL, і без
// NETPULSE_TEST_DSN його не перевіряє ніщо. Пораховано в Go тільки
// другий заслін — що ComplianceReportOf не рахує результатів
// вимкненого правила (TestComplianceReportSkipsDisabledRuleResults).
// Для видаленого хоста другого заслону немає взагалі: результат
// несе device_id, а не ознаку «хост живий», і звіт про це не знає.
//
// 7. Відсів за Scope В ЗАПИТІ вендорів (ListComplianceVendors) —
// той самий випадок: предикат у SQL, тестів без бази немає.
// ApplyComplianceCounts перевірено як чиста функція, але що обробник
// годує її саме видимими результатами, доводить лише читання коду.
//
// 8. CSV у справжньому Excel. Перевіряється наявність BOM, `sep=;`,
// CRLF і апострофа перед формулою — тобто те, що МАЄ змусити Excel
// прочитати файл правильно й не виконати клітинку. Що він справді
// так робить — і що те саме роблять LibreOffice, Numbers і Google
// Sheets, у яких свої переліки небезпечних символів, — може довести
// лише сам табличний процесор.
//
// 9. Форма. Перевірки сторінки живуть у web/src/test/compliance.test.tsx і
// ходять до ПІДСТАВНОГО сервера. Що справжні обробники віддають
// саме ту форму, яку там підставлено, не доводить ні той файл, ні цей.
//
// 10. Головне: жоден тест не доводить, що набір правил ПОВНИЙ. Двадцять
// правил, які всі до одного зелені, і повна відсутність правил про
// цілий клас ризиків дають на екрані однакову картинку. Вигляд
// ncm.builtin_compliance_coverage із 0071 відповідає на це для
// вбудованих правил і не відповідає для написаних руками.
func TestNothingHereProvesTheWholeChain(t *testing.T) {
t.Skip("перелік меж покриття; сам нічого не перевіряє — читайте коментар вище")
}