Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд — усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам NetPulse, треба було заходити по ssh. Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ (8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки буває довшим за сотню звичайних, тож «5000 рядків» означало б непередбачувані десятки мегабайтів. Два процеси — один перелік із позначкою джерела, а не дві вкладки: людина знає симптом («о третій ночі перестали йти сповіщення»), а не те, який із двох процесів за це відповідає. Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені в місці народження, але кільце робить журнал видимим у браузері й вивантажуваним у файл, що піде в тікет. Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не переживає перезапуску й не покаже причини падіння бази. Поруч — скільки записів витіснено: без цього числа не відрізнити «нічого не сталося» від «сталося стільки, що початок уже не влазить».
164 lines
7.5 KiB
Go
164 lines
7.5 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"log/slog"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||
)
|
||
|
||
// Тести злиття й фільтрів — БЕЗ бази навмисно.
|
||
//
|
||
// Решта тестів цього пакета йде проти справжнього Postgres і сама себе
|
||
// пропускає без NETPULSE_TEST_DSN. Для журналу сервера це було б погано:
|
||
// саме злиття двох джерел і фільтр за рівнем — та частина, яка
|
||
// ламається тихо, а перевірка, яку локально ніхто не запускає, ловить
|
||
// рівно нічого. Store тут не потрібен узагалі, тож і фікстури немає.
|
||
|
||
func rec(src string, seq uint64, at time.Time, level, msg, attrs string) logbuf.Record {
|
||
return logbuf.Record{Seq: seq, Time: at, Level: level, Msg: msg, Attrs: attrs, Source: src}
|
||
}
|
||
|
||
// snaps будує двох сусідів із рядками, що перемішані в часі.
|
||
func snaps() []logbuf.Snapshot {
|
||
t0 := time.Date(2026, 8, 27, 3, 0, 0, 0, time.UTC)
|
||
api := logbuf.Snapshot{Source: "api", Records: []logbuf.Record{
|
||
rec("api", 3, t0.Add(30*time.Second), "ERROR", "аудит строків зберігання", "err=timeout"),
|
||
rec("api", 2, t0.Add(10*time.Second), "INFO", "http", "path=/api/v1/devices status=200"),
|
||
rec("api", 1, t0, "WARN", "ліцензія: пільговий період", "діб=5"),
|
||
}}
|
||
collector := logbuf.Snapshot{Source: "collector", Records: []logbuf.Record{
|
||
rec("collector", 2, t0.Add(20*time.Second), "ERROR", "дзеркало", "url=https://forgejo.example/np.git"),
|
||
rec("collector", 1, t0.Add(5*time.Second), "INFO", "бекап", "хостів=12"),
|
||
}}
|
||
return []logbuf.Snapshot{api, collector}
|
||
}
|
||
|
||
// Головне про злиття: обидва джерела в одному переліку й за часом, а не
|
||
// за процесами. Половина цієї перевірки — що рядки колектора взагалі
|
||
// доїхали: перелік лише з API виглядає як робочий і мовчки ховає ту
|
||
// половину системи, заради якої сторінку й відкривали.
|
||
func TestMergeLogInterleavesSources(t *testing.T) {
|
||
got := mergeLog(snaps(), slog.LevelDebug, "", 100)
|
||
|
||
want := []string{"api", "collector", "api", "collector", "api"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("рядків %d замість %d", len(got), len(want))
|
||
}
|
||
seen := map[string]int{}
|
||
for i, r := range got {
|
||
if r.Source != want[i] {
|
||
t.Fatalf("позиція %d: джерело %q замість %q (%s)", i, r.Source, want[i], r.Msg)
|
||
}
|
||
seen[r.Source]++
|
||
if i > 0 && got[i-1].Time.Before(r.Time) {
|
||
t.Fatalf("порядок не за спаданням часу на позиції %d", i)
|
||
}
|
||
}
|
||
if seen["collector"] == 0 {
|
||
t.Fatal("рядки колектора не доїхали — сторінка показувала б half-системи як цілу")
|
||
}
|
||
}
|
||
|
||
// Фільтр рівня — НИЖНЯ межа, а не точний збіг.
|
||
func TestMergeLogLevelIsMinimum(t *testing.T) {
|
||
warn := mergeLog(snaps(), slog.LevelWarn, "", 100)
|
||
if len(warn) != 3 {
|
||
t.Fatalf("на рівні warn %d рядків замість 3", len(warn))
|
||
}
|
||
var errs int
|
||
for _, r := range warn {
|
||
if r.Level == "INFO" {
|
||
t.Fatalf("info просочився крізь фільтр warn: %s", r.Msg)
|
||
}
|
||
if r.Level == "ERROR" {
|
||
errs++
|
||
}
|
||
}
|
||
// Друга половина: error НЕ відсіяно. Без неї зеленим був би й
|
||
// фільтр, що розуміє рівень як точний збіг, — і людина, яка обрала
|
||
// «warn», не побачила б жодної помилки.
|
||
if errs != 2 {
|
||
t.Fatalf("помилки відсіяно фільтром warn: %d із 2", errs)
|
||
}
|
||
|
||
if got := len(mergeLog(snaps(), slog.LevelError, "", 100)); got != 2 {
|
||
t.Fatalf("на рівні error %d рядків замість 2", got)
|
||
}
|
||
if got := len(mergeLog(snaps(), slog.LevelDebug, "", 100)); got != 5 {
|
||
t.Fatalf("без фільтра %d рядків замість 5", got)
|
||
}
|
||
}
|
||
|
||
// Пошук іде і по повідомленню, і по атрибутах: адреси, імена хостів і
|
||
// тексти помилок живуть саме в атрибутах, і пошук лише по msg знаходив
|
||
// би найменш цікаве.
|
||
func TestMergeLogSearchesAttrs(t *testing.T) {
|
||
byMsg := mergeLog(snaps(), slog.LevelDebug, "дзеркало", 100)
|
||
if len(byMsg) != 1 || byMsg[0].Source != "collector" {
|
||
t.Fatalf("пошук по повідомленню: %+v", byMsg)
|
||
}
|
||
byAttr := mergeLog(snaps(), slog.LevelDebug, "forgejo.example", 100)
|
||
if len(byAttr) != 1 || byAttr[0].Msg != "дзеркало" {
|
||
t.Fatalf("пошук по атрибутах нічого не дав: %+v", byAttr)
|
||
}
|
||
if got := mergeLog(snaps(), slog.LevelDebug, "ДЕВАЙС", 100); len(got) != 0 {
|
||
t.Fatalf("знайдено те, чого немає: %+v", got)
|
||
}
|
||
// Регістр не має значення: імена хостів пишуть як завгодно.
|
||
if got := mergeLog(snaps(), slog.LevelDebug, "http", 100); len(got) != 2 {
|
||
t.Fatalf("пошук чутливий до регістру або до джерела: %d", len(got))
|
||
}
|
||
}
|
||
|
||
// Стеля накладається ПІСЛЯ злиття: інакше «останні N» означало б N із
|
||
// кожного процесу, і найсвіжіші рядки одного випадали б на користь
|
||
// старих рядків другого.
|
||
func TestMergeLogLimitAfterMerge(t *testing.T) {
|
||
got := mergeLog(snaps(), slog.LevelDebug, "", 2)
|
||
if len(got) != 2 {
|
||
t.Fatalf("стелю не накладено: %d", len(got))
|
||
}
|
||
if got[0].Source != "api" || got[1].Source != "collector" {
|
||
t.Fatalf("обрізано не найсвіжіші: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestParseLogLevel(t *testing.T) {
|
||
for in, want := range map[string]slog.Level{
|
||
"": slog.LevelDebug,
|
||
"info": slog.LevelInfo,
|
||
"WARN": slog.LevelWarn,
|
||
"warning": slog.LevelWarn,
|
||
" error ": slog.LevelError,
|
||
} {
|
||
got, ok := parseLogLevel(in)
|
||
if !ok || got != want {
|
||
t.Fatalf("%q: %v/%v замість %v", in, got, ok, want)
|
||
}
|
||
}
|
||
if _, ok := parseLogLevel("критично"); ok {
|
||
t.Fatal("невідомий рівень прийнято — сторінка мовчки показала б не те, що просили")
|
||
}
|
||
}
|
||
|
||
// Стан джерела має розрізняти «тихо» й «недоступне». Це те саме
|
||
// рішення, що й поле Blind у попередженні про місце: на екрані вони
|
||
// виглядають однаково, а означають протилежне.
|
||
func TestDescribeSourceSeparatesSilentFromBroken(t *testing.T) {
|
||
quiet := describeSource("collector", "Колектор",
|
||
logbuf.Snapshot{Source: "collector", MaxBytes: logbuf.DefaultMaxBytes}, nil)
|
||
if !quiet.OK || quiet.Error != "" || quiet.Records != 0 {
|
||
t.Fatalf("тихе джерело описано як зламане: %+v", quiet)
|
||
}
|
||
|
||
broken := describeSource("collector", "Колектор", logbuf.Snapshot{}, errTest{})
|
||
if broken.OK || broken.Error == "" {
|
||
t.Fatalf("недоступне джерело описано як тихе: %+v", broken)
|
||
}
|
||
}
|
||
|
||
type errTest struct{}
|
||
|
||
func (errTest) Error() string { return "з'єднання відхилено" }
|