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

325 lines
14 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"
"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
}