Netpulse_SasS/server/internal/httpapi/serverlog_test.go
byrsapty e6b585dd4c
All checks were successful
CI / hygiene (push) Successful in 11s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m55s
CI / agent (push) Successful in 1m0s
Сторінка «Журнал сервера»: кільцевий буфер у памяті
Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд —
усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам
NetPulse, треба було заходити по ssh.

Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно
тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ
(8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки
буває довшим за сотню звичайних, тож «5000 рядків» означало б
непередбачувані десятки мегабайтів.

Два процеси — один перелік із позначкою джерела, а не дві вкладки:
людина знає симптом («о третій ночі перестали йти сповіщення»), а не
те, який із двох процесів за це відповідає.

Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал
DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені
в місці народження, але кільце робить журнал видимим у браузері й
вивантажуваним у файл, що піде в тікет.

Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не
переживає перезапуску й не покаже причини падіння бази. Поруч —
скільки записів витіснено: без цього числа не відрізнити «нічого не
сталося» від «сталося стільки, що початок уже не влазить».
2026-08-27 23:50:03 +03:00

164 lines
7.5 KiB
Go
Raw 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 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 "з'єднання відхилено" }