Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
409 lines
15 KiB
Go
409 lines
15 KiB
Go
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 ""
|
||
}
|