П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
325 lines
14 KiB
Go
325 lines
14 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// Стеля тарифу проти СПРАВЖНЬОЇ бази.
|
||
//
|
||
// ЧОМУ ТУТ НЕ ОБІЙТИСЬ ЧИТАННЯМ SQL ОЧИМА
|
||
//
|
||
// Бо перевірка стелі — це тригер, і вся її суть у тому, на яких саме
|
||
// операціях він стоїть. 0009 повісила його лише на INSERT, і читанням
|
||
// коду це виглядало вичерпним: хост з'являється вставкою, стеля рахує
|
||
// хости, все сходиться. Не сходилось: хост з'являється ще й UPDATE-ом —
|
||
// поверненням з архіву й простим увімкненням. Стелю в 15 обходили за
|
||
// чотири дії, і жодна перевірка про це не знала.
|
||
//
|
||
// ГОЛОВНЕ ПРО ЦЕЙ ФАЙЛ
|
||
//
|
||
// Половина перевірок нижче — про те, що стеля НЕ спрацювала там, де не
|
||
// має. Це не симетрія заради симетрії: тест, який доводить лише
|
||
// «шістнадцятий не створився», лишається зеленим і на реалізації, яка
|
||
// не дає створити ЖОДНОГО хоста, перейменувати наявний або повернути
|
||
// вимкнений. Помилка в цей бік не проявляється помилкою — вона
|
||
// проявляється тим, що продукт перестає працювати, і скаржиться на неї
|
||
// клієнт, а не перевірка.
|
||
//
|
||
// Пропускається мовчки без NETPULSE_TEST_DSN. Запускати на ОДНОРАЗОВІЙ
|
||
// базі з накоченими міграціями:
|
||
//
|
||
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
|
||
// go test ./internal/store/ -run PlanLimit
|
||
|
||
func TestPlanLimitDevices(t *testing.T) {
|
||
s, tenantID := billingTestTenant(t)
|
||
ctx := context.Background()
|
||
|
||
setDeviceCeiling(t, s, tenantID, 2)
|
||
|
||
mk := func(name string, enabled bool) (string, error) {
|
||
return s.CreateDevice(ctx, tenantID, DeviceInput{
|
||
Name: name, Address: nextAddr(), Kind: "switch", Enabled: enabled,
|
||
})
|
||
}
|
||
|
||
// --- стеля не заважає працювати до неї -----------------------------
|
||
first, err := mk("host-1", true)
|
||
if err != nil {
|
||
t.Fatalf("перший хост при стелі 2 не створився: %v", err)
|
||
}
|
||
if _, err := mk("host-2", true); err != nil {
|
||
t.Fatalf("другий хост при стелі 2 не створився: %v", err)
|
||
}
|
||
|
||
// --- стеля спрацьовує ---------------------------------------------
|
||
_, err = mk("host-3", true)
|
||
lim, ok := AsPlanLimit(err)
|
||
if !ok {
|
||
t.Fatalf("третій хост при стелі 2 створився або відмова не впізнана: %v", err)
|
||
}
|
||
if lim.Kind != "devices" || lim.Allowed != 2 || lim.Used != 2 {
|
||
t.Fatalf("відмова без правильних чисел: %+v (%s)", lim, lim.Error())
|
||
}
|
||
// Найважливіше в самій відмові — щоб її можна було показати людині.
|
||
// Порожній або англійський текст тут означає, що на екран поїде або
|
||
// нічого, або мова розробника.
|
||
if lim.Error() == "" || !containsCyrillic(lim.Error()) {
|
||
t.Fatalf("текст відмови не для людини: %q", lim.Error())
|
||
}
|
||
|
||
// --- ДІРА 0009 №1: повернення з архіву -----------------------------
|
||
//
|
||
// Заархівований хост звільняє слот — це правильно й це перевіряється
|
||
// нижче. Але повернути його, коли слот уже зайняв інший, не можна:
|
||
// інакше стеля обходиться парою «заархівував — завів новий —
|
||
// повернув старий».
|
||
if err := s.DeleteDevice(ctx, tenantID, first); err != nil {
|
||
t.Fatalf("архівування: %v", err)
|
||
}
|
||
if _, err := mk("host-4", true); err != nil {
|
||
t.Fatalf("слот не звільнився після архівування: %v", err)
|
||
}
|
||
_, rerr := s.RestoreDevices(ctx, tenantID, Scope{Unrestricted: true}, []string{first})
|
||
if _, ok := AsPlanLimit(rerr); !ok {
|
||
t.Fatalf("повернення з архіву обійшло стелю (діра 0009): %v", rerr)
|
||
}
|
||
|
||
// --- ДІРА 0009 №2: увімкнення вимкненого ---------------------------
|
||
//
|
||
// Вимкнений хост слота не займає — його МОЖНА завести навіть під
|
||
// стелею (так заводять хост «про запас» перед переїздом). Але
|
||
// ввімкнути його, коли слотів немає, не можна.
|
||
off, err := mk("host-off", false)
|
||
if err != nil {
|
||
t.Fatalf("вимкнений хост під стелею не створився — "+
|
||
"стеля спрацювала там, де не має: %v", err)
|
||
}
|
||
uerr := s.UpdateDevice(ctx, tenantID, off, DeviceInput{
|
||
Name: "host-off", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||
})
|
||
if _, ok := AsPlanLimit(uerr); !ok {
|
||
t.Fatalf("увімкнення вимкненого обійшло стелю (діра 0009): %v", uerr)
|
||
}
|
||
}
|
||
|
||
// Стеля не має заважати правити те, що вже є.
|
||
//
|
||
// Перевірка «чого не має статись», і без неї попередня доводила б
|
||
// лише, що ми навчились відмовляти. Тригер на UPDATE — саме та зміна, у
|
||
// якій легко заборонити перейменування хоста на переповненому кабінеті,
|
||
// і виявилось би це не тут, а в клієнта, який не може виправити
|
||
// друкарську помилку в назві.
|
||
func TestPlanLimitDoesNotBlockEditing(t *testing.T) {
|
||
s, tenantID := billingTestTenant(t)
|
||
ctx := context.Background()
|
||
|
||
setDeviceCeiling(t, s, tenantID, 1)
|
||
|
||
id, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||
Name: "єдиний", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("хост при стелі 1: %v", err)
|
||
}
|
||
|
||
// Кабінет рівно на стелі. Кожна з правок нижче лишає кількість
|
||
// увімкнених хостів такою ж — отже, жодна не має впертись.
|
||
steps := []struct {
|
||
name string
|
||
in DeviceInput
|
||
}{
|
||
{"перейменування", DeviceInput{Name: "єдиний-2", Address: "10.77.0.1", Kind: "switch", Enabled: true}},
|
||
{"зміна адреси", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "switch", Enabled: true}},
|
||
{"зміна типу", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: true}},
|
||
{"вимкнення", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: false}},
|
||
{"повторне ввімкнення", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: true}},
|
||
}
|
||
for _, st := range steps {
|
||
if err := s.UpdateDevice(ctx, tenantID, id, st.in); err != nil {
|
||
t.Fatalf("%s під стелею впало: %v", st.name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Стеля, якої немає, не має вигадуватись.
|
||
//
|
||
// Це стан КОЖНОЇ інсталяції, яка накотила 0069: план self_hosted, усі
|
||
// стелі порожні. Якби тут щось спрацьовувало, оновлення відібрало б у
|
||
// клієнта можливість заводити хости — і саме проти цього написана
|
||
// перша половина 0069.
|
||
func TestNoCeilingNoRefusal(t *testing.T) {
|
||
s, tenantID := billingTestTenant(t)
|
||
ctx := context.Background()
|
||
|
||
if _, err := s.pool.Exec(ctx, `
|
||
UPDATE bill.entitlements SET max_devices = NULL, max_maps = NULL,
|
||
max_agents = NULL, max_users = NULL WHERE tenant_id = $1`, tenantID); err != nil {
|
||
t.Fatalf("зняття стель: %v", err)
|
||
}
|
||
|
||
for i := 0; i < 25; i++ {
|
||
if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||
Name: fmt.Sprintf("вільний-%d", i), Address: nextAddr(),
|
||
Kind: "switch", Enabled: true,
|
||
}); err != nil {
|
||
t.Fatalf("хост %d при знятих стелях: %v", i, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Кабінет без рядка в bill.entitlements працює й показує стан.
|
||
//
|
||
// Так виглядає кабінет, створений ПІСЛЯ 0069 (netpulse-user його
|
||
// заводить, стель не заводить). Перевірка на два боки: стеля не
|
||
// вигадується, а сторінка тарифу все одно має що показати.
|
||
func TestEntitlementCreatedLazily(t *testing.T) {
|
||
s, tenantID := billingTestTenant(t)
|
||
ctx := context.Background()
|
||
|
||
if _, err := s.pool.Exec(ctx,
|
||
`DELETE FROM bill.entitlements WHERE tenant_id = $1`, tenantID); err != nil {
|
||
t.Fatalf("прибирання стель: %v", err)
|
||
}
|
||
|
||
if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||
Name: "без-стель", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||
}); err != nil {
|
||
t.Fatalf("хост у кабінеті без стель: %v", err)
|
||
}
|
||
|
||
ent, err := s.TenantEntitlement(ctx, tenantID)
|
||
if err != nil {
|
||
t.Fatalf("стелі не завелись ліниво: %v", err)
|
||
}
|
||
if ent.PlanKey != "self_hosted" {
|
||
t.Fatalf("тариф = %q, очікувався self_hosted", ent.PlanKey)
|
||
}
|
||
if ent.MaxDevices != nil {
|
||
t.Fatalf("у щойно заведених стелях узялась стеля хостів: %d", *ent.MaxDevices)
|
||
}
|
||
}
|
||
|
||
// Використане, яке показує сторінка, і використане, на якому спрацьовує
|
||
// тригер, — одне й те саме число.
|
||
//
|
||
// Розходження тут не ламає нічого технічно й тому не помічається: обидва
|
||
// шляхи «працюють». Помічає його людина, у якої на екрані «14 з 15», а
|
||
// шістнадцятий хост не заводиться.
|
||
func TestUsageMatchesTrigger(t *testing.T) {
|
||
s, tenantID := billingTestTenant(t)
|
||
ctx := context.Background()
|
||
|
||
setDeviceCeiling(t, s, tenantID, 3)
|
||
|
||
var ids []string
|
||
for i := 0; i < 3; i++ {
|
||
id, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||
Name: fmt.Sprintf("рахунок-%d", i), Address: nextAddr(),
|
||
Kind: "switch", Enabled: i < 2, // третій вимкнений
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("хост %d: %v", i, err)
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
|
||
u, err := s.TenantUsage(ctx, tenantID)
|
||
if err != nil {
|
||
t.Fatalf("використання: %v", err)
|
||
}
|
||
// Вимкнений хост слота не займає — так каже bill.usage_now, і так
|
||
// само має рахувати тригер.
|
||
if u.Devices != 2 {
|
||
t.Fatalf("сторінка рахує %d хостів, очікувалось 2 (вимкнений не рахується)", u.Devices)
|
||
}
|
||
|
||
// Доводимо, що тригер рахує так само: при стелі 3 і двох увімкнених
|
||
// третій увімкнений має пройти, а четвертий — ні.
|
||
if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||
Name: "рахунок-3", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||
}); err != nil {
|
||
t.Fatalf("тригер порахував вимкнений хост як зайнятий слот: %v", err)
|
||
}
|
||
_, err = s.CreateDevice(ctx, tenantID, DeviceInput{
|
||
Name: "рахунок-4", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||
})
|
||
if _, ok := AsPlanLimit(err); !ok {
|
||
t.Fatalf("стеля не спрацювала на четвертому увімкненому: %v", err)
|
||
}
|
||
_ = ids
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Оснастка
|
||
// ---------------------------------------------------------------------
|
||
|
||
func billingTestTenant(t *testing.T) (*Store, string) {
|
||
t.Helper()
|
||
dsn := os.Getenv("NETPULSE_TEST_DSN")
|
||
if dsn == "" {
|
||
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
|
||
}
|
||
ctx := context.Background()
|
||
|
||
s, err := New(ctx, dsn)
|
||
if err != nil {
|
||
t.Fatalf("підключення: %v", err)
|
||
}
|
||
t.Cleanup(s.Close)
|
||
|
||
var tenantID string
|
||
slug := fmt.Sprintf("bill-test-%d", time.Now().UnixNano())
|
||
if err := s.pool.QueryRow(ctx, `
|
||
INSERT INTO core.tenants (slug, name) VALUES ($1, 'Тест білінгу') RETURNING id::text
|
||
`, slug).Scan(&tenantID); err != nil {
|
||
t.Fatalf("кабінет: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = s.pool.Exec(context.Background(),
|
||
`DELETE FROM core.tenants WHERE id = $1`, tenantID)
|
||
})
|
||
|
||
if _, err := s.pool.Exec(ctx, `
|
||
INSERT INTO bill.entitlements
|
||
(tenant_id, plan_key, metric_retention_days, features, source)
|
||
VALUES ($1, 'self_hosted', 400, '{}', 'license_key')
|
||
ON CONFLICT (tenant_id) DO NOTHING`, tenantID); err != nil {
|
||
t.Fatalf("стелі: %v", err)
|
||
}
|
||
return s, tenantID
|
||
}
|
||
|
||
func setDeviceCeiling(t *testing.T, s *Store, tenantID string, n int) {
|
||
t.Helper()
|
||
if _, err := s.pool.Exec(context.Background(),
|
||
`UPDATE bill.entitlements SET plan_key = 'free', max_devices = $2
|
||
WHERE tenant_id = $1`, tenantID, n); err != nil {
|
||
t.Fatalf("стеля хостів: %v", err)
|
||
}
|
||
}
|
||
|
||
// nextAddr дає неповторну адресу.
|
||
//
|
||
// Адреси мусять різнитись не через стелю, а через unique-індекс на
|
||
// (tenant_id, address): падіння на ньому виглядало б як падіння стелі,
|
||
// і півгодини пішло б не туди.
|
||
var addrN = 0
|
||
|
||
func nextAddr() string {
|
||
addrN++
|
||
return fmt.Sprintf("10.66.%d.%d", addrN/250, addrN%250+1)
|
||
}
|
||
|
||
func containsCyrillic(s string) bool {
|
||
for _, r := range s {
|
||
if r >= 'А' && r <= 'я' {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|