П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
242 lines
11 KiB
Go
242 lines
11 KiB
Go
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
|
||
}
|