Netpulse_SasS/server/internal/store/ncm_compliance_builtin_test.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

729 lines
22 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 (
"regexp"
"sort"
"strings"
"testing"
schema "github.com/netpulse/netpulse/server/migrations"
)
// Перевірка вбудованих правил відповідності з міграції 0071.
//
// НАВІЩО ЦЕЙ ФАЙЛ ІСНУЄ. Правило «не має бути», чий зразок не збігається
// НІКОЛИ, повідомляє «порушень немає» — тобто виглядає ідеально й
// нічого не перевіряє. Відрізнити його від справного правила за
// результатом прогону неможливо в принципі: обидва дають нуль
// порушень. Єдиний спосіб — показати конфіг, на якому зразок мусить
// спрацювати, і конфіг, на якому не мусить.
//
// ЗВІДКИ БЕРУТЬСЯ ЗРАЗКИ. З самої міграції, через schema.Files. Копії
// виразів у тесті немає навмисно: два описи одного й того самого
// розійдуться на першій же правці, і тест почав би підтверджувати
// власну копію замість того, що поїде клієнтові. Та сама причина, з
// якої db/profiles/build.py породжує SQL, а не дублює його.
//
// ЩО ТЕСТ ГАРАНТУЄ, а що ні. Гарантує: кожен зразок хоч на одному
// справжньому конфізі спрацював і хоч на одному не спрацював, і для
// КОЖНОГО вендора з переліку правила є конфіг цього вендора з
// очікуваним висновком. Не гарантує: що зразок покриває всі прошивки
// цієї родини — конфіги збирались із документації й типових
// вивантажень, а не з усього парку.
// ---------------------------------------------------------------------
// Читання правил із міграції
// ---------------------------------------------------------------------
const builtinMigration = "0071_builtin_compliance_prototypes.sql"
// Рядок довідника має жорстку форму саме заради цього розбору:
//
// ('ключ', 'серйозність', 'вид', 'тип конфігу', NULL | string_to_array('Вендор,Вендор', ','),
// $rx$зразок$rx$,
//
// Доларові лапки навколо зразка — не оздоба: у виразі живуть зворотні
// скісні риски й апострофи, і саме вони роблять межі зразка
// однозначними для цього виразу.
var builtinRuleRe = regexp.MustCompile(
`(?s)\('([a-z0-9-]+)',\s*'([a-z]+)',\s*'([a-z_]+)',\s*'([a-z]+)',\s*` +
`(?:NULL|string_to_array\('([^']*)', ','\)),\s*\$rx\$(.*?)\$rx\$`)
type builtinRule struct {
Key string
Severity string
Kind string
ConfigType string
Vendors []string
Pattern string
}
func loadBuiltinRules(t *testing.T) map[string]builtinRule {
t.Helper()
body, err := schema.Files.ReadFile(builtinMigration)
if err != nil {
t.Fatalf("міграція %s не читається: %v", builtinMigration, err)
}
out := map[string]builtinRule{}
for _, m := range builtinRuleRe.FindAllStringSubmatch(string(body), -1) {
r := builtinRule{
Key: m[1], Severity: m[2], Kind: m[3], ConfigType: m[4], Pattern: m[6],
}
if m[5] != "" {
r.Vendors = strings.Split(m[5], ",")
}
if _, dup := out[r.Key]; dup {
t.Fatalf("ключ %q у довіднику двічі", r.Key)
}
out[r.Key] = r
}
if len(out) == 0 {
t.Fatalf("з %s не вийнято жодного правила — розбір розійшовся з форматом файлу",
builtinMigration)
}
return out
}
// ---------------------------------------------------------------------
// Зразки конфігів
// ---------------------------------------------------------------------
// Конфіг і вендор, під яким цей хост стоїть в inv.devices.
//
// Вендор тут не прикраса: селектор вбудованого правила — це
// {"vendors":[...]}, який selectorSQL перетворює на `d.vendor = ANY(...)`,
// тобто рівно на порівняння з цим рядком. Пари «правило + вендор», для
// якої немає конфігу, тест не пропускає — інакше вендора можна було б
// дописати в правило, не перевіривши на ньому нічого.
type cfgSample struct {
Vendor string
Body string
}
var samples = map[string]cfgSample{
// --- Cisco IOS ---------------------------------------------------
"cisco-bad": {"Cisco", `Building configuration...
Current configuration : 4021 bytes
!
version 15.2
service timestamps debug datetime msec
no service password-encryption
!
hostname kyiv-acc-sw01
!
enable password cisco123
!
username admin privilege 15 password 0 admin123
!
interface Vlan1
ip address 10.10.0.11 255.255.255.0
!
snmp-server community public RO
snmp-server community private RW
!
line con 0
password cisco
line vty 0 4
transport input telnet ssh
login local
line vty 5 15
transport input all
!
end
`},
"cisco-good": {"Cisco", `Building configuration...
!
version 15.2
service password-encryption
!
hostname kyiv-core-sw01
!
enable secret 5 $1$mERr$X1s0Kk9yq2mA3cCf1uJvS/
!
username noc privilege 15 secret 5 $1$hM3z$8QeJm0Wl2sN6tR4vB7xYc.
!
aaa new-model
!
ip access-list standard MGMT-ACL
permit 10.20.30.0 0.0.0.255
!
snmp-server community NP-ro-2024 RO MGMT-ACL
!
logging host 10.20.30.15
logging trap informational
!
ntp server 10.20.30.10 prefer
ntp server 10.20.30.11
!
line con 0
exec-timeout 5 0
line vty 0 15
access-class MGMT-ACL in
transport input ssh
login local
!
end
`},
// --- Eltex MES ---------------------------------------------------
//
// Свідомо змішаний: telnet відкритий, решта в порядку. Саме такий
// конфіг і ловить помилку «зразок написаний під Cisco, а вендор у
// переліку є» — на бездоганному конфізі її не видно.
"eltex-mixed": {"Eltex", `!
version 4.0.14
!
hostname odesa-acc-sw03
!
username admin password encrypted 5f4dcc3b5aa765d61d8327deb882cf99 privilege 15
!
ip telnet server
!
snmp-server community NP-ro-2024 ro
!
logging host 10.20.30.15
!
sntp unicast client enable
sntp server 10.20.30.10
!
line telnet
exec-timeout 10
line ssh
exec-timeout 10
!
management access-list MGMT
permit ip-source 10.20.30.0 mask 255.255.255.0
management access-class MGMT
!
end
`},
// --- ZTE ZXR10 ---------------------------------------------------
"zte-mixed": {"ZTE", `!
!<Configuration Data Version 2.0>
!
hostname kharkiv-agg-zte01
!
username zteadmin password 0 zte123 privilege 15
!
snmp-server community public view AllView ro
snmp-server enable
!
ntp server 10.20.30.10
!
logging server 10.20.30.15
!
line telnet idle-timeout 10
!
line vty 0 4
transport input telnet ssh
!
end
`},
// --- Huawei VRP --------------------------------------------------
"huawei-bad": {"Huawei", `!Last configuration was updated at 2026-03-11 09:12:44+02:00
#
sysname lviv-agg-sw02
#
telnet server enable
#
snmp-agent
snmp-agent community read public
snmp-agent community write private
snmp-agent sys-info version v2c
#
aaa
local-user admin password simple Huawei@123
local-user admin service-type telnet ssh
#
user-interface vty 0 4
authentication-mode password
set authentication password simple Admin@123
protocol inbound all
#
return
`},
"huawei-good": {"Huawei", `#
sysname lviv-core-sw01
#
undo telnet server enable
undo telnet ipv6 server enable
#
snmp-agent
snmp-agent community read cipher %^%#NP2024ro%^%#
snmp-agent sys-info version v3
#
info-center enable
info-center loghost 10.20.30.15
#
ntp-service unicast-server 10.20.30.10
ntp-service unicast-server 10.20.30.11
#
aaa
local-user noc password irreversible-cipher $1c$KJHGfdsa
local-user noc service-type ssh
#
user-interface vty 0 4
acl 2000 inbound
authentication-mode aaa
protocol inbound ssh
#
return
`},
// --- H3C Comware -------------------------------------------------
"h3c-bad": {"H3C", `#
version 7.1.070, Release 6127P02
#
sysname dnipro-acc-h3c01
#
telnet server enable
#
snmp-agent
snmp-agent community read public
#
local-user admin class manage
password simple H3C@1234
service-type telnet ssh
#
line vty 0 15
authentication-mode scheme
protocol inbound all
#
return
`},
"h3c-good": {"H3C", `#
sysname dnipro-core-h3c01
#
undo telnet server enable
#
info-center loghost 10.20.30.15
#
ntp-service unicast-server 10.20.30.10
#
snmp-agent community read cipher $c$3$Nq8kL2Rt
#
local-user noc class manage
password hash $h$6$OqRsTuVw
service-type ssh
#
line vty 0 15
acl 2000 inbound
protocol inbound ssh
#
return
`},
// --- D-Link ------------------------------------------------------
"dlink-bad": {"D-Link", `#-------------------------------------------------------------------
# DGS-1210-28 Gigabit Ethernet Switch
# Configuration
#-------------------------------------------------------------------
# BASIC
config serial_port baud_rate 115200 auto_logout 10_minutes
enable telnet 23
enable web 80
# ACCOUNT
create account admin admin
# SNMP
enable snmp
create snmp community public view CommunityView read_only
create snmp community private view CommunityView read_write
# TIME AND SNTP
disable sntp
# SYSLOG
disable syslog
`},
"dlink-good": {"D-Link", `#-------------------------------------------------------------------
# DGS-3420-28SC Gigabit Ethernet Switch
# Configuration
#-------------------------------------------------------------------
# BASIC
disable telnet
enable ssh
config ssh authmode password enable
# SNMP
enable snmp
create snmp community NP-ro-2024 view CommunityView read_only
# TIME AND SNTP
config sntp primary 10.20.30.10 secondary 10.20.30.11 poll-interval 720
enable sntp
# SYSLOG
create syslog host 1 ipaddress 10.20.30.15 severity informational facility local0 udp_port 514 state enable
enable syslog
# ACCESS
create trusted_host network 10.20.30.0/24
`},
// --- MikroTik RouterOS -------------------------------------------
"mikrotik-bad": {"MikroTik", `# mar/11/2026 09:12:44 by RouterOS 7.14.3
# software id = ABCD-1234
#
/interface bridge
add name=bridge1
/ip service
set telnet disabled=no port=23
set ftp disabled=no
set www disabled=no
set ssh disabled=no
set winbox disabled=no
/snmp community
set [ find default=yes ] name=public addresses=0.0.0.0/0
/snmp
set enabled=yes
/system clock
set time-zone-name=Europe/Kyiv
/system logging
add topics=info
/user
add name=noc password=Str0ngPass group=full
`},
"mikrotik-good": {"MikroTik", `# mar/11/2026 09:12:44 by RouterOS 7.14.3
#
/interface bridge
add name=bridge1
/ip service
set telnet disabled=yes
set ftp disabled=yes
set www disabled=yes
set ssh address=10.20.30.0/24 disabled=no
set winbox address=10.20.30.0/24 disabled=no
/snmp community
set [ find default=yes ] name=NP-ro-2024 addresses=10.20.30.0/24
/snmp
set enabled=yes trap-version=2
/system ntp client
set enabled=yes servers=10.20.30.10,10.20.30.11
/system logging action
add name=remote target=remote remote=10.20.30.15
/system logging
add action=remote topics=info,error,warning
/system clock
set time-zone-name=Europe/Kyiv
`},
}
// ---------------------------------------------------------------------
// Очікування
// ---------------------------------------------------------------------
// true — правило пройдено (порушення немає), false — знайдено порушення.
//
// Заповнено для КОЖНОЇ пари «правило + зразок вендора з його переліку».
// Пропуск — це не «нам байдуже», а помилка тесту: саме через пропущену
// пару вендор потрапляє в правило неперевіреним.
var expect = map[string]map[string]bool{
// Єдине правило без прив'язки до вендора — тому перелічені всі
// зразки набору.
"snmp-default-community": {
"cisco-bad": false, "cisco-good": true,
"eltex-mixed": true, "zte-mixed": false,
"huawei-bad": false, "huawei-good": true,
"h3c-bad": false, "h3c-good": true,
"dlink-bad": false, "dlink-good": true,
"mikrotik-bad": false, "mikrotik-good": true,
},
"no-telnet-ios": {
// transport input telnet / transport input all
"cisco-bad": false, "cisco-good": true,
// ip telnet server — форма Eltex, якої в синтаксисі Cisco немає
"eltex-mixed": false,
"zte-mixed": false,
},
"no-telnet-vrp": {
"huawei-bad": false, "huawei-good": true,
"h3c-bad": false, "h3c-good": true,
},
"no-telnet-dlink": {
"dlink-bad": false, "dlink-good": true,
},
"no-telnet-routeros": {
"mikrotik-bad": false, "mikrotik-good": true,
},
"plaintext-password-ios": {
// no service password-encryption + enable password + password 0
"cisco-bad": false, "cisco-good": true,
// password encrypted <md5> — незворотна схема, не порушення
"eltex-mixed": true,
// password 0 <відкрито>
"zte-mixed": false,
},
"plaintext-password-vrp": {
"huawei-bad": false, "huawei-good": true,
"h3c-bad": false, "h3c-good": true,
},
"plaintext-password-routeros": {
"mikrotik-bad": false, "mikrotik-good": true,
},
"syslog-target-ios": {
"cisco-bad": false, "cisco-good": true,
"eltex-mixed": true,
// logging server <адреса> — форма ZTE
"zte-mixed": true,
},
"syslog-target-vrp": {
"huawei-bad": false, "huawei-good": true,
"h3c-bad": false, "h3c-good": true,
},
"syslog-target-dlink": {
"dlink-bad": false, "dlink-good": true,
},
"syslog-target-routeros": {
// /system logging без action — це журнал у пам'ять
"mikrotik-bad": false, "mikrotik-good": true,
},
"ntp-source-ios": {
"cisco-bad": false, "cisco-good": true,
// sntp server — форма Eltex
"eltex-mixed": true,
"zte-mixed": true,
},
"ntp-source-vrp": {
"huawei-bad": false, "huawei-good": true,
"h3c-bad": false, "h3c-good": true,
},
"ntp-source-dlink": {
// disable sntp
"dlink-bad": false, "dlink-good": true,
},
"ntp-source-routeros": {
"mikrotik-bad": false, "mikrotik-good": true,
},
"mgmt-acl-ios": {
"cisco-bad": false, "cisco-good": true,
// management access-class — форма Eltex
"eltex-mixed": true,
// на ZTE список доступу до vty не заведений
"zte-mixed": false,
},
"mgmt-acl-vrp": {
"huawei-bad": false, "huawei-good": true,
"h3c-bad": false, "h3c-good": true,
},
"mgmt-acl-dlink": {
"dlink-bad": false, "dlink-good": true,
},
"mgmt-acl-routeros": {
"mikrotik-bad": false, "mikrotik-good": true,
},
}
// ---------------------------------------------------------------------
// Самі перевірки
// ---------------------------------------------------------------------
// Склад набору звіряється поіменно: правило, яке з'явилось у міграції й
// не має очікувань, інакше проїхало б повз тест непоміченим — тобто
// найдорожчим способом.
func TestBuiltinRulesAllCovered(t *testing.T) {
rules := loadBuiltinRules(t)
var missing []string
for key := range rules {
if _, ok := expect[key]; !ok {
missing = append(missing, key)
}
}
sort.Strings(missing)
if len(missing) > 0 {
t.Errorf("правила без очікувань у тесті: %s", strings.Join(missing, ", "))
}
var stale []string
for key := range expect {
if _, ok := rules[key]; !ok {
stale = append(stale, key)
}
}
sort.Strings(stale)
if len(stale) > 0 {
t.Errorf("очікування для правил, яких у міграції немає: %s", strings.Join(stale, ", "))
}
}
// Кожне правило звіряється зі зразками конфігів усіх своїх вендорів.
func TestBuiltinRulesAgainstRealConfigs(t *testing.T) {
rules := loadBuiltinRules(t)
for key, br := range rules {
want, ok := expect[key]
if !ok {
continue // про це вже сказав TestBuiltinRulesAllCovered
}
re, err := regexp.Compile(br.Pattern)
if err != nil {
t.Errorf("%s: зразок не компілюється: %v", key, err)
continue
}
rule := ComplianceRule{Kind: br.Kind, Pattern: br.Pattern}
for name, s := range samples {
// Правило застосовне до цього конфігу, якщо вендорів у
// нього немає взагалі або цей вендор у переліку.
if !applies(br.Vendors, s.Vendor) {
continue
}
exp, has := want[name]
if !has {
t.Errorf("%s: для вендора %s (зразок %s) немає очікування — "+
"вендор у правилі є, а перевіреного конфігу немає",
key, s.Vendor, name)
continue
}
got, line, num := checkCompliance(rule, re, s.Body)
if got != exp {
t.Errorf("%s на %s: очікували %s, отримали %s (рядок %d: %q)",
key, name, verdict(exp), verdict(got), num, line)
}
}
}
}
// Найважливіша перевірка файлу.
//
// Зразок, який не збігається НІКОЛИ, дає «порушень немає» на всьому
// парку — тобто ідеальний вигляд і нуль користі. Зразок, який
// збігається ЗАВЖДИ, дає суцільне червоне, після якого на розділ
// перестають дивитись. Обидва стани перевіряються однаково: у наборі
// зразків має бути хоч один конфіг, де правило спрацювало, і хоч один,
// де ні.
func TestBuiltinRulesAreNotAlwaysGreen(t *testing.T) {
rules := loadBuiltinRules(t)
for key, br := range rules {
re, err := regexp.Compile(br.Pattern)
if err != nil {
continue // про це вже сказав попередній тест
}
rule := ComplianceRule{Kind: br.Kind, Pattern: br.Pattern}
var passed, failed int
for _, s := range samples {
if !applies(br.Vendors, s.Vendor) {
continue
}
if ok, _, _ := checkCompliance(rule, re, s.Body); ok {
passed++
} else {
failed++
}
}
if failed == 0 {
t.Errorf("%s: зразок не спрацював ЖОДНОГО разу на %d конфігах своїх вендорів — "+
"таке правило показує «порушень немає» завжди", key, passed)
}
if passed == 0 {
t.Errorf("%s: зразок спрацював на ВСІХ %d конфігах своїх вендорів — "+
"правило, яке ніколи не буває зеленим, перестають читати", key, failed)
}
}
}
// Кожен вендор, названий у правилах, мусить мати конфіг у наборі.
//
// Без цієї перевірки вендора можна дописати в перелік правила, не
// показавши жодного його конфігу, — і правило мовчки поширилось би на
// синтаксис, якого ніхто не бачив.
func TestBuiltinRuleVendorsHaveSamples(t *testing.T) {
rules := loadBuiltinRules(t)
have := map[string]bool{}
for _, s := range samples {
have[s.Vendor] = true
}
seen := map[string]bool{}
for key, br := range rules {
for _, v := range br.Vendors {
if !have[v] && !seen[key+v] {
seen[key+v] = true
t.Errorf("%s: вендор %q названий у правилі, а конфігу цього вендора в наборі немає",
key, v)
}
}
}
}
// Тип конфігу заданий явно в кожному правилі (0056), і це саме
// 'running': жодне з правил набору не описує конфіг-файли сервера.
func TestBuiltinRulesDeclareConfigType(t *testing.T) {
for key, br := range loadBuiltinRules(t) {
if br.ConfigType != "running" {
t.Errorf("%s: тип конфігу %q — набір писався під конфіги заліза", key, br.ConfigType)
}
if !complianceKinds[br.Kind] {
t.Errorf("%s: вид правила %q рушій не знає", key, br.Kind)
}
}
}
// Прототипи з цієї ж міграції мають бути вимкнені.
//
// Правило проєкту: оновлення не вмикає нічого, що заводить чеки.
// Ввімкнений прототип у вбудованому шаблоні заводить snmp.walk і
// snmp.get на кожному хості, якому шаблон причеплений, — мовчки, під
// час накату. Перевіряти це очима на рев'ю вже двічі не вийшло.
func TestBuiltinPrototypesShipDisabled(t *testing.T) {
body, err := schema.Files.ReadFile(builtinMigration)
if err != nil {
t.Fatalf("міграція %s не читається: %v", builtinMigration, err)
}
text := string(body)
// Хвіст рядка INSERT у tpl.item_prototypes: discovery_sec,
// interval_sec, max_rows, enabled.
tail := regexp.MustCompile(`(?m)^\s*\d+,\s*\d+,\s*\d+,\s*(true|false)\)`)
hits := tail.FindAllStringSubmatch(text, -1)
if len(hits) != 2 {
t.Fatalf("очікували 2 прототипи в міграції, знайшли %d", len(hits))
}
for _, h := range hits {
if h[1] != "false" {
t.Errorf("прототип заведено ввімкненим: %q", strings.TrimSpace(h[0]))
}
}
}
func applies(vendors []string, vendor string) bool {
if len(vendors) == 0 {
return true
}
for _, v := range vendors {
if v == vendor {
return true
}
}
return false
}
func verdict(passed bool) string {
if passed {
return "«порушень немає»"
}
return "«порушення»"
}