Netpulse_SasS/server/internal/store/audit_actions_source_test.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

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

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

409 lines
15 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 (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Тест, який не дає новій дії потрапити в журнал безіменною.
//
// ЧОМУ ЦЕ НЕ ПЕРЕВІРИТИ ІНАКШЕ
//
// Словник auditActions — єдине місце, де ключ `ncm.mirror.push`
// перетворюється на «Примусовий пуш архіву на дзеркало». Дописати туди
// рядок нічого не коштує; проблема в тому, що ЗАБУТИ його теж нічого не
// коштує й нічим не проявляється: запис у журнал іде, сторінка
// відкривається, фільтр працює. Єдиний, хто побачить пропуск, — людина,
// яка через місяць читає журнал і бачить у ньому сирий ключ замість
// назви. Саме так у продукті й опинились без назв дзеркало Git, ролі та
// прив'язка хоста до зонда — усі три додали правильно, просто в іншому
// файлі.
//
// Звідси вимога до перевірки: вона має брати перелік ключів НЕ з
// окремого списку (той розійшовся б із кодом так само), а з самого
// коду — з тих місць, де ключ реально лягає в AuditEntry.Action. Тому
// тест розбирає дерево server/ як текст програми й дивиться на кожне
// таке присвоєння.
//
// ЩО САМЕ ВИМАГАЄТЬСЯ ВІД АВТОРА НОВОЇ ДІЇ
//
// 1. Ключ оголошено константою в audit_actions.go (AuditAction… /
// AuditObject…). Літерал у місці виклику тест вважає помилкою — не
// з любові до констант, а тому, що інакше ключ має два місця
// оголошення й розходяться вони мовчки.
// 2. У константи є рядок у словнику.
//
// Обидві вимоги — це одна дія автора: дописати константу поруч із
// назвою. Тест лише робить її обов'язковою.
//
// ЧОГО ТЕСТ НЕ ВИМАГАЄ
//
// Зворотного — щоб кожен рядок словника десь писався. Журнал старший за
// код: у ньому лежать дії збірок, яких уже немає, і назви до них мають
// лишатись, поки лежать самі рядки.
// auditScanRoot — корінь розбору. Не лише internal/: дію в журнал може
// писати й службова команда з cmd/, і пропустити її було б рівно тією
// помилкою, від якої цей тест і стоїть.
const auditScanRoot = "../.."
// auditKeyUse — одне місце в коді, де ключ лягає в AuditEntry.
type auditKeyUse struct {
pos string // файл:рядок
field string // Action або ObjectType
key string // саме значення ключа
literal bool // ключ написано рядком просто в місці виклику
}
func TestAuditDictionaryCoversEveryWrittenKey(t *testing.T) {
fset := token.NewFileSet()
files := parseAuditScanTree(t, fset)
consts := auditConstValues(files)
uses := collectAuditKeyUses(t, fset, files, consts)
// Захист від «тест зелений, бо нічого не знайшов». Перейменують
// AuditEntry — і мовчазний нуль знахідок виглядав би як успіх.
var actions int
for _, u := range uses {
if u.field == "Action" {
actions++
}
}
if actions < 10 {
t.Fatalf("розбір знайшов лише %d присвоєнь AuditEntry.Action — "+
"так мало бути не може; найімовірніше, змінилась назва типу "+
"або структура виклику, і перевірка перестала щось перевіряти", actions)
}
for _, u := range uses {
if u.literal {
t.Errorf("%s: ключ %q написано рядком просто в місці виклику. "+
"Заведіть константу в store/audit_actions.go — інакше ключ має "+
"два місця оголошення, і словник розійдеться з кодом мовчки",
u.pos, u.key)
// Далі все одно перевіряємо: відсутня назва — окрема біда.
}
switch u.field {
case "Action":
if _, ok := auditActionByKey[u.key]; !ok {
t.Errorf("%s: дія %q пишеться в журнал, але назви для неї немає. "+
"Допишіть рядок у auditActions (store/audit_actions.go) — "+
"інакше адміністратор побачить у журналі сирий ключ",
u.pos, u.key)
}
case "ObjectType":
if _, ok := auditObjectTypeByKey[u.key]; !ok {
t.Errorf("%s: тип об'єкта %q пишеться в журнал, але назви для нього "+
"немає. Допишіть рядок у auditObjectTypes (store/audit_actions.go)",
u.pos, u.key)
}
}
}
}
// Константи без назви — та сама діра з іншого боку.
//
// Ключ, оголошений константою й ще нікуди не підставлений, потрапить у
// журнал наступним комітом, і на той момент про словник ніхто вже не
// згадає. Дешевше вимагати назву одразу.
func TestAuditConstantsHaveLabels(t *testing.T) {
fset := token.NewFileSet()
files := parseAuditScanTree(t, fset)
for name, val := range auditConstValues(files) {
switch {
case strings.HasPrefix(name, "AuditObject"):
if _, ok := auditObjectTypeByKey[val]; !ok {
t.Errorf("константа %s = %q оголошена, але назви в auditObjectTypes немає",
name, val)
}
case strings.HasPrefix(name, "AuditAction"):
if _, ok := auditActionByKey[val]; !ok {
t.Errorf("константа %s = %q оголошена, але назви в auditActions немає", name, val)
}
}
}
}
// Сам словник теж має бути справним: порожня назва або два рядки з
// однаковим ключем ламають фільтр на сторінці мовчки.
func TestAuditDictionaryWellFormed(t *testing.T) {
seen := map[string]bool{}
for _, a := range auditActions {
if a.Key == "" || a.Label == "" || a.Group == "" {
t.Errorf("дія %+v: ключ, назва й розділ обов'язкові", a)
}
if seen[a.Key] {
t.Errorf("ключ %q описано двічі", a.Key)
}
seen[a.Key] = true
// Звіряти розділ із тим, що виводиться з ключа
// (auditActionGroupFromKey), тут НЕ можна: «Команди» —
// повноцінний розділ фільтра, а ключ у прогонів команд
// починається з ncm, тобто з ключа виводиться «Конфігурації».
// Розбіжність тут навмисна: з ключа розділ виводять лише для
// дії, назви якої немає взагалі, і це запасний варіант, а не
// друге джерело правди.
}
seen = map[string]bool{}
for _, o := range auditObjectTypes {
if o.Key == "" || o.Label == "" {
t.Errorf("тип об'єкта %+v: ключ і назва обов'язкові", o)
}
if seen[o.Key] {
t.Errorf("тип об'єкта %q описано двічі", o.Key)
}
seen[o.Key] = true
}
}
// ---------------------------------------------------------------------
// Розбір дерева
// ---------------------------------------------------------------------
func parseAuditScanTree(t *testing.T, fset *token.FileSet) []*ast.File {
t.Helper()
var out []*ast.File
err := filepath.WalkDir(auditScanRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case ".git", "node_modules", "webui", "migrations":
return fs.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
f, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
return err
}
out = append(out, f)
return nil
})
if err != nil {
t.Fatalf("розбір дерева %s: %v", auditScanRoot, err)
}
if len(out) == 0 {
t.Fatalf("у %s не знайдено жодного файлу .go", auditScanRoot)
}
return out
}
// auditConstValues — усі рядкові константи AuditAction… / AuditObject…
// разом з їхніми значеннями.
func auditConstValues(files []*ast.File) map[string]string {
out := map[string]string{}
for _, f := range files {
for _, decl := range f.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.CONST {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, name := range vs.Names {
if i >= len(vs.Values) {
continue
}
if !strings.HasPrefix(name.Name, "AuditAction") &&
!strings.HasPrefix(name.Name, "AuditObject") {
continue
}
bl, ok := vs.Values[i].(*ast.BasicLit)
if !ok || bl.Kind != token.STRING {
continue
}
v, err := strconv.Unquote(bl.Value)
if err != nil {
continue
}
out[name.Name] = v
}
}
}
}
return out
}
// collectAuditKeyUses — усі місця, де в AuditEntry кладуть Action або
// ObjectType.
//
// Окремий випадок, заради якого тут узагалі є другий прохід: половина
// дій пишеться не напряму, а через невеликий помічник —
// auditRole(r, p, "core.role.create", …), auditSelfAgent(…). У самому
// виклику WriteAudit там стоїть параметр, а не ключ, і взяти значення
// з того рядка неможливо. Тому такі місця відкладаються, а потім
// шукаються виклики самого помічника — саме там ключ і написано.
func collectAuditKeyUses(t *testing.T, fset *token.FileSet, files []*ast.File,
consts map[string]string) []auditKeyUse {
t.Helper()
type pending struct {
fn string // ім'я функції-помічника
param string // ім'я параметра, у якому їде ключ
field string
pos string
}
var uses []auditKeyUse
var pend []pending
// Кожна функція дерева — на випадок, якщо ключ їде в неї параметром.
funcs := map[string]*ast.FuncDecl{}
resolve := func(expr ast.Expr, field, pos string) (auditKeyUse, bool, string) {
switch v := expr.(type) {
case *ast.BasicLit:
if v.Kind != token.STRING {
return auditKeyUse{}, false, "не рядок"
}
s, err := strconv.Unquote(v.Value)
if err != nil {
return auditKeyUse{}, false, "не розібрати рядок"
}
return auditKeyUse{pos: pos, field: field, key: s, literal: true}, true, ""
case *ast.Ident:
if s, ok := consts[v.Name]; ok {
return auditKeyUse{pos: pos, field: field, key: s}, true, ""
}
return auditKeyUse{}, false, v.Name
case *ast.SelectorExpr:
if s, ok := consts[v.Sel.Name]; ok {
return auditKeyUse{pos: pos, field: field, key: s}, true, ""
}
return auditKeyUse{}, false, v.Sel.Name
}
return auditKeyUse{}, false, "вираз"
}
for _, f := range files {
for _, decl := range f.Decls {
fd, ok := decl.(*ast.FuncDecl)
if !ok || fd.Body == nil {
continue
}
funcs[fd.Name.Name] = fd
ast.Inspect(fd.Body, func(n ast.Node) bool {
cl, ok := n.(*ast.CompositeLit)
if !ok || !isAuditEntryType(cl.Type) {
return true
}
for _, el := range cl.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || (key.Name != "Action" && key.Name != "ObjectType") {
continue
}
pos := fset.Position(kv.Pos()).String()
use, ok, what := resolve(kv.Value, key.Name, pos)
if ok {
uses = append(uses, use)
continue
}
// Не вивелось: або це параметр помічника, або щось
// таке, з чого ключ не дістати взагалі.
pend = append(pend, pending{
fn: fd.Name.Name, param: what, field: key.Name, pos: pos,
})
}
return true
})
}
}
for _, p := range pend {
fd := funcs[p.fn]
idx := -1
if fd != nil && fd.Type.Params != nil {
i := 0
for _, field := range fd.Type.Params.List {
for _, name := range field.Names {
if name.Name == p.param {
idx = i
}
i++
}
}
}
if idx < 0 {
t.Errorf("%s: у %s.%s підставляють %s — значення ключа не виводиться з коду. "+
"Передавайте константу store.AuditAction… (хай навіть через параметр "+
"функції-помічника), інакше цю дію нічим не перевірити",
p.pos, p.fn, p.field, p.param)
continue
}
found := 0
for _, f := range files {
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || calleeName(call.Fun) != p.fn || idx >= len(call.Args) {
return true
}
pos := fset.Position(call.Pos()).String()
use, ok, what := resolve(call.Args[idx], p.field, pos)
if !ok {
t.Errorf("%s: у виклик %s ключ %s їде як %s — значення не виводиться "+
"з коду; підставте константу store.AuditAction…",
pos, p.fn, p.field, what)
return true
}
found++
uses = append(uses, use)
return true
})
}
if found == 0 {
t.Errorf("%s: %s приймає ключ %s параметром, але жодного виклику не знайдено — "+
"перевірити цю дію нічим", p.pos, p.fn, p.field)
}
}
return uses
}
func isAuditEntryType(expr ast.Expr) bool {
switch v := expr.(type) {
case *ast.Ident:
return v.Name == "AuditEntry"
case *ast.SelectorExpr:
return v.Sel.Name == "AuditEntry"
}
return false
}
func calleeName(expr ast.Expr) string {
switch v := expr.(type) {
case *ast.Ident:
return v.Name
case *ast.SelectorExpr:
return v.Sel.Name
}
return ""
}