Netpulse_SasS/server/internal/store/sla_db_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

242 lines
11 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 (
"context"
"os"
"strings"
"testing"
"time"
)
// Перевірка SLA ПРОТИ БАЗИ.
//
// Чиста арифметика покрита в sla_test.go, і це головна половина. Друга
// половина арифметикою не перевіряється взагалі, бо вона не в коді:
//
// - закритий період справді не переписується — це властивість тригера
// на таблиці, а не гілки в Go, і тест без бази про неї нічого не
// каже, скільки б зеленого не показував;
// - закритий період переживає ПОВНЕ видалення хоста (0057) — це
// властивість знятого зовнішнього ключа;
// - повторне закриття нічого не міняє — це властивість трьох запитів
// разом, а не функції.
//
// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має
// вимагати бази. Запускати ЛИШЕ на одноразовій базі — тест створює
// кабінет і видаляє його з усім вмістом:
//
// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \
// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16
// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
// go test ./internal/store/ -run SLAAgainstDB -v
func TestSLAAgainstDB(t *testing.T) {
dsn := os.Getenv("NETPULSE_TEST_DSN")
if dsn == "" {
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
}
ctx := context.Background()
st, err := New(ctx, dsn)
if err != nil {
t.Fatalf("підключення: %v", err)
}
t.Cleanup(st.Close)
slug := "sla-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
var tenantID string
if err := st.pool.QueryRow(ctx, `
INSERT INTO core.tenants (slug, name, timezone) VALUES ($1, $2, 'UTC')
RETURNING id::text
`, slug, "Перевірка SLA").Scan(&tenantID); err != nil {
t.Fatalf("кабінет: %v", err)
}
t.Cleanup(func() {
bg := context.Background()
// Тригер незмінності стоїть і на DELETE: без дозволу каскад від
// кабінету не змів би закритих періодів, і прибирання тесту
// лишило б за собою кабінет.
_, _ = st.pool.Exec(bg, `SET app.sla_reopen = 'on'`)
_, _ = st.pool.Exec(bg, `DELETE FROM core.tenants WHERE id = $1`, tenantID)
_, _ = st.pool.Exec(bg, `RESET app.sla_reopen`)
})
// Період — позаминула доба: вона вже скінчилась і давно «встоялась».
day := time.Now().UTC().AddDate(0, 0, -2).Truncate(24 * time.Hour)
var deviceID string
if err := st.pool.QueryRow(ctx, `
INSERT INTO inv.devices (tenant_id, name, address, kind, created_at)
VALUES ($1, $2, '10.88.0.1', 'switch', $3) RETURNING id::text
`, tenantID, slug+"-sw", day.AddDate(0, 0, -10)).Scan(&deviceID); err != nil {
t.Fatalf("хост: %v", err)
}
// Дані кладемо ПРЯМО в годинну згортку, а не в сирі виміри.
//
// Не з лінощів: у бойовій базі ts.icmp_1h наповнює політика
// TimescaleDB, і чекати на неї в тесті довелось би реальну годину.
// А перевіряємо тут не роботу згорток (вона перевірена 0005), а
// поведінку звіту над ними.
mat := st.slaMaterializedHypertable(ctx, t, "ts.icmp_1h")
for i := 0; i < 24; i++ {
down := int64(0)
if i == 5 {
down = 30 // чверть години простою
}
if _, err := st.pool.Exec(ctx, `
INSERT INTO `+mat+` (bucket, device_id, tenant_id, samples, down_samples)
VALUES ($1, $2, $3, 120, $4)
`, day.Add(time.Duration(i)*time.Hour), deviceID, tenantID, down); err != nil {
t.Skipf("не вдалось покласти відро напряму в матеріалізовану таблицю (%v) — "+
"перевірка потребує доступу до внутрішньої схеми TimescaleDB", err)
}
}
target, err := st.SaveSLATarget(ctx, tenantID, SLATargetInput{
Name: "Ядро",
PeriodKind: SLAPeriodDaily,
TargetPct: 99.9,
TZ: "UTC",
MinCoveragePct: 95,
Enabled: true,
})
if err != nil {
t.Fatalf("ціль: %v", err)
}
anchor := day.Add(12 * time.Hour)
// --- 1. Живий розрахунок бачить дані.
rep, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor)
if err != nil {
t.Fatalf("звіт: %v", err)
}
if rep.Closed {
t.Fatal("незакритий період не має видавати себе за закритий")
}
if len(rep.Rows) != 1 {
t.Fatalf("рядків у звіті: %d, очікували 1", len(rep.Rows))
}
if rep.Rows[0].DownSec != 900 {
t.Fatalf("простій: %d с, очікували 900", rep.Rows[0].DownSec)
}
// --- 2. Закриття робить число фактом.
closed, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, false)
if err != nil {
t.Fatalf("закриття: %v", err)
}
if !closed.Closed || closed.Revision != 1 {
t.Fatalf("закритий період: closed=%v revision=%d", closed.Closed, closed.Revision)
}
// --- 3. Повторне закриття нічого не міняє.
again, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, false)
if err != nil {
t.Fatalf("повторне закриття: %v", err)
}
if again.Revision != 1 {
t.Fatalf("повторне закриття підняло ревізію до %d", again.Revision)
}
// --- 4. ГОЛОВНЕ: дані зникли, а звіт лишився тим самим.
//
// Саме це й моделює строк зберігання: за два місяці відер під
// кварталом не буде. Наївний розрахунок після цього дав би інше
// число; закритий період має віддати те саме.
if _, err := st.pool.Exec(ctx,
`DELETE FROM `+mat+` WHERE tenant_id = $1`, tenantID); err != nil {
t.Fatalf("видалення відер: %v", err)
}
after, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor)
if err != nil {
t.Fatalf("звіт після втрати даних: %v", err)
}
if !after.Closed {
t.Fatal("після втрати даних звіт перестав бути закритим")
}
if after.Rows[0].DownSec != closed.Rows[0].DownSec ||
after.Totals.UptimePct != closed.Totals.UptimePct {
t.Fatalf("закритий звіт змінився після зникнення даних: було %v%%, стало %v%%",
closed.Totals.UptimePct, after.Totals.UptimePct)
}
// --- 5. Тригер не дає переписати закритий рядок повз наш код.
if _, err := st.pool.Exec(ctx, `
UPDATE core.sla_periods SET uptime_pct = 100
WHERE tenant_id = $1 AND sla_target_id = $2
`, tenantID, target.ID); err == nil {
t.Fatal("закритий період переписався звичайним UPDATE — тригер незмінності не працює")
}
// --- 6. Свідомий перерахунок піднімає ревізію.
forced, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, true)
if err != nil {
t.Fatalf("свідомий перерахунок: %v", err)
}
if forced.Revision != 2 {
t.Fatalf("ревізія після перерахунку: %d, очікували 2", forced.Revision)
}
// Дані ми щойно знесли, тож перерахунок ЧЕСНО дає невідомість, а не
// ті самі числа. Це і є ціна свідомого перерахунку, і саме тому він
// лишає слід.
if !forced.Totals.Insufficient {
t.Fatal("перерахунок по зниклих даних мусить дати «недостатньо даних»")
}
// --- 7. Закритий період переживає ПОВНЕ видалення хоста.
if _, err := st.pool.Exec(ctx,
`DELETE FROM inv.devices WHERE id = $1`, deviceID); err != nil {
t.Fatalf("видалення хоста: %v", err)
}
var left int
if err := st.pool.QueryRow(ctx, `
SELECT count(*)::int FROM core.sla_periods WHERE tenant_id = $1
`, tenantID).Scan(&left); err != nil {
t.Fatalf("перелік періодів: %v", err)
}
if left == 0 {
t.Fatal("повне видалення хоста забрало з собою закритий звіт — " +
"зовнішній ключ із CASCADE повернувся")
}
survived, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor)
if err != nil {
t.Fatalf("звіт після видалення хоста: %v", err)
}
if len(survived.Rows) == 0 || survived.Rows[0].DeviceName == "" {
t.Fatal("у звіті лишився рядок без імені хоста — знімок імені не працює")
}
found := false
for _, w := range survived.Rows[0].Warnings {
if w == SLAWarnDevicePurged {
found = true
}
}
if !found {
t.Fatal("рядок видаленого хоста не позначено попередженням")
}
// --- 8. Незавершений період закрити не можна навіть на вимогу.
if _, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, time.Now(), true); err == nil {
t.Fatal("поточну добу вдалось закрити — недорахований звіт став би фактом")
}
}
// slaMaterializedHypertable — фізична таблиця під безперервним агрегатом.
//
// Писати у вигляд не можна, а політика наповнить його не раніше ніж за
// годину. Та сама асиметрія, що описана в 0064: політики й розміри
// живуть на матеріалізованій таблиці, а не на вигляді.
func (s *Store) slaMaterializedHypertable(ctx context.Context, t *testing.T, view string) string {
t.Helper()
var schema, name string
if err := s.pool.QueryRow(ctx, `
SELECT materialization_hypertable_schema, materialization_hypertable_name
FROM timescaledb_information.continuous_aggregates
WHERE view_schema = split_part($1, '.', 1) AND view_name = split_part($1, '.', 2)
`, view).Scan(&schema, &name); err != nil {
t.Skipf("не знайдено матеріалізованої таблиці для %s: %v", view, err)
}
return schema + "." + name
}