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

464 lines
21 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 (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"strconv"
"strings"
"testing"
"time"
)
// Перевірки ліцензійного механізму без бази.
//
// ЩО САМЕ ТУТ ПЕРЕВІРЯЄТЬСЯ І ЧОМУ САМЕ ЦЕ
//
// Урок цього проєкту, дослівно: зелена перевірка доводить рівно те, що
// вона перевіряє. Тест ізоляції RLS був зелений і пропустив зламаний
// вхід, бо перевіряв «чи не видно чужого», коли зламалось «чи видно
// своє».
//
// У ліцензії симетрична пастка, і вона тут головна. Легко написати
// набір «ключ підроблено — відмовили», «строк минув — стан expired», і
// весь він буде зелений на реалізації, яка після прострочення вимикає
// збір даних. Тобто перевірка доводила б, що механізм СПРАЦЬОВУЄ, і
// мовчала б про те, що він спрацьовує руйнівно.
//
// Тому половина перевірок нижче — про те, що НЕ має статись:
// протермінована ліцензія не опускає стель нижче зайнятого, порожні
// поля в ключі не закривають того, що дає тариф, зсув годинника нічого
// не вимикає, а невідоме поле в payload не робить чужий ключ недійсним.
// ---------------------------------------------------------------------
// Допоміжне
// ---------------------------------------------------------------------
func testKeypair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("генерація ключа: %v", err)
}
return pub, priv
}
func intp(v int) *int { return &v }
// ---------------------------------------------------------------------
// Підпис
// ---------------------------------------------------------------------
func TestLicenseRoundTrip(t *testing.T) {
pub, priv := testKeypair(t)
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
exp := time.Now().AddDate(1, 0, 0).Unix()
key, err := SignLicense(priv, LicensePayload{
LicenseID: "11111111-1111-1111-1111-111111111111",
KeyID: "k1",
Plan: "enterprise",
IssuedTo: "ТОВ «Мережа»",
ExpiresAt: exp,
GraceDays: 30,
// Кирилиця в issued_to тут не для краси: назва клієнта
// українською — звичайний випадок, і base64url над UTF-8 має
// пережити подорож ключа туди й назад без втрат.
MaxDevices: intp(250),
})
if err != nil {
t.Fatalf("підпис: %v", err)
}
got, err := v.ParseLicense(key)
if err != nil {
t.Fatalf("перевірка щойно підписаного ключа: %v", err)
}
if got.IssuedTo != "ТОВ «Мережа»" {
t.Errorf("issued_to не пережив кодування: %q", got.IssuedTo)
}
if got.MaxDevices == nil || *got.MaxDevices != 250 {
t.Errorf("max_devices не пережив кодування: %v", got.MaxDevices)
}
}
// Ключ їздить у листах і повертається з переносами рядків.
//
// Це не педантизм: половина звернень «ключ не приймається» — саме про
// це. Перевірка стоїть тут, бо мовчазна відмова на пробілі виглядає
// точно так само, як відмова на підробці, і людина шукатиме не там.
func TestLicenseSurvivesCopyPasteDamage(t *testing.T) {
pub, priv := testKeypair(t)
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
key, _ := SignLicense(priv, LicensePayload{
KeyID: "k1", Plan: "pro", ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(),
})
damaged := " " + key[:20] + "\n" + key[20:40] + " \r\n" + key[40:] + "\t"
if _, err := v.ParseLicense(damaged); err != nil {
t.Fatalf("ключ із листа не прийнявся: %v", err)
}
}
func TestLicenseRejectsTampering(t *testing.T) {
pub, priv := testKeypair(t)
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
key, _ := SignLicense(priv, LicensePayload{
KeyID: "k1", Plan: "free", MaxDevices: intp(15),
ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(),
})
parts := strings.Split(key, ".")
// Підмінений payload зі своїм, більш щедрим, вмістом і старим
// підписом — це і є та атака, проти якої весь механізм.
forged, _ := SignLicense(priv, LicensePayload{
KeyID: "k1", Plan: "enterprise", MaxDevices: intp(100000),
ExpiresAt: time.Now().AddDate(10, 0, 0).Unix(),
})
forgedParts := strings.Split(forged, ".")
cases := []struct {
name string
key string
}{
{"чужий payload зі старим підписом", parts[0] + "." + forgedParts[1] + "." + parts[2]},
{"порожній підпис", parts[0] + "." + parts[1] + "."},
{"не той префікс", "NP2." + parts[1] + "." + parts[2]},
{"дві частини замість трьох", parts[0] + "." + parts[1]},
{"сміття", "просто рядок"},
{"порожньо", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := v.ParseLicense(c.key); err == nil {
t.Fatal("ключ прийнято, хоча він недійсний")
}
})
}
}
func TestLicenseRejectsForeignSigner(t *testing.T) {
ourPub, _ := testKeypair(t)
_, theirPriv := testKeypair(t)
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": ourPub})
// Ключ, підписаний чужим приватним ключем, але з НАШИМ kid: саме так
// виглядала б спроба видати собі ліцензію самотужки.
key, _ := SignLicense(theirPriv, LicensePayload{
KeyID: "k1", Plan: "enterprise",
ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(),
})
if _, err := v.ParseLicense(key); err == nil {
t.Fatal("прийнято ключ, підписаний чужим ключем")
}
}
// Ключ, виданий новішим генератором, має лишатись дійсним.
//
// Перевірка про те, чого не має статись: невідоме поле в payload не
// робить ключ недійсним. Інакше перше ж розширення формату
// перетворило б усі видані ключі на «підпис не сходиться» — у клієнтів,
// які нічого не робили.
func TestLicenseKeepsUnknownFields(t *testing.T) {
pub, priv := testKeypair(t)
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
body := []byte(`{"v":1,"kid":"k1","plan":"pro","exp":` +
strconv.FormatInt(time.Now().AddDate(1, 0, 0).Unix(), 10) +
`,"майбутнє_поле":{"a":[1,2,3]}}`)
enc := base64.RawURLEncoding
key := licensePrefix + "." +
enc.EncodeToString(body) + "." + enc.EncodeToString(ed25519.Sign(priv, body))
got, err := v.ParseLicense(key)
if err != nil {
t.Fatalf("ключ із невідомим полем відхилено: %v", err)
}
if got.Plan != "pro" {
t.Errorf("plan = %q, очікувалось pro", got.Plan)
}
}
// Збірка без відкритого ключа не вдає, що перевірила.
//
// Найгірший із можливих варіантів тут — мовчазне «ключ прийнято»: тоді
// будь-який рядок відкривав би будь-які стелі, і виявилось би це ніколи.
func TestLicenseWithoutVerifierRefuses(t *testing.T) {
v, err := ParseLicenseVerifier("")
if err != nil {
t.Fatalf("порожній набір ключів має бути робочим станом: %v", err)
}
if !v.Empty() {
t.Fatal("порожній рядок дав непорожній набір")
}
_, priv := testKeypair(t)
key, _ := SignLicense(priv, LicensePayload{KeyID: "k1", Plan: "enterprise"})
if _, err := v.ParseLicense(key); err != ErrNoVerifier {
t.Fatalf("очікувалось ErrNoVerifier, отримано %v", err)
}
}
// ---------------------------------------------------------------------
// Стан за часом
// ---------------------------------------------------------------------
func TestEvaluateLicenseStates(t *testing.T) {
exp := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
p := &LicensePayload{ExpiresAt: exp.Unix(), GraceDays: 30}
cases := []struct {
name string
now time.Time
want string
}{
{"задовго до строку", exp.AddDate(0, -6, 0), LicenseActive},
{"за секунду до строку", exp.Add(-time.Second), LicenseActive},
{"за секунду після строку", exp.Add(time.Second), LicenseGrace},
{"в середині пільгового", exp.AddDate(0, 0, 15), LicenseGrace},
{"за секунду до кінця пільгового", exp.AddDate(0, 0, 30).Add(-time.Second), LicenseGrace},
{"одразу після пільгового", exp.AddDate(0, 0, 30).Add(time.Second), LicenseExpired},
{"через рік після", exp.AddDate(1, 0, 0), LicenseExpired},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := EvaluateLicense(p, c.now); got.State != c.want {
t.Fatalf("стан = %q, очікувалось %q", got.State, c.want)
}
})
}
// Ключ без пільгового періоду переходить у expired одразу. Окремо,
// бо нуль тут — це справді нуль, а не «не задано»: інакше
// GraceDays=0 мовчки давав би місяць понад строк.
zero := &LicensePayload{ExpiresAt: exp.Unix()}
if got := EvaluateLicense(zero, exp.Add(time.Second)); got.State != LicenseExpired {
t.Fatalf("без пільгового: стан = %q, очікувалось %q", got.State, LicenseExpired)
}
}
func TestEvaluateLicenseNil(t *testing.T) {
if got := EvaluateLicense(nil, time.Now()); got.State != LicenseUnlicensed {
t.Fatalf("без ключа стан = %q, очікувалось %q", got.State, LicenseUnlicensed)
}
}
// ---------------------------------------------------------------------
// Монотонний годинник
// ---------------------------------------------------------------------
func TestClockRollbackDoesNotExtendLicense(t *testing.T) {
seen := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
// Годинник перевели на рік назад — строк має рахуватись за
// побаченим, а не за тим, що показує машина.
back := seen.AddDate(-1, 0, 0)
eff, warped := effectiveNow(back, seen)
if !eff.Equal(seen) {
t.Fatalf("час рахується за переведеним годинником: %v", eff)
}
if !warped {
t.Fatal("зсув на рік назад не позначено")
}
// Дрібний стрибок назад — норма: поправка NTP, віртуалка з паузи.
// Позначати його зсувом означало б блимати попередженням щотижня.
small := seen.Add(-10 * time.Minute)
eff, warped = effectiveNow(small, seen)
if !eff.Equal(seen) {
t.Fatalf("дрібний стрибок має все одно рахуватись за побаченим: %v", eff)
}
if warped {
t.Fatal("десять хвилин назад позначено як зсув — попередження блиматиме на рівному місці")
}
// Час іде вперед — звичайне життя, нічого не позначаємо.
fwd := seen.Add(time.Hour)
eff, warped = effectiveNow(fwd, seen)
if !eff.Equal(fwd) || warped {
t.Fatalf("нормальний хід часу: eff=%v warped=%v", eff, warped)
}
}
// Зсув годинника нічого не вимикає — він лише позначається.
//
// Перевірка про те, чого не має статись. Спокуса «побачили обман —
// заблокували» тут особливо сильна, і вона хибна: годинник з'їжджає й
// сам (сів CMOS, зник NTP після переїзду в ізольований сегмент), а
// покарати за несправність означає вимкнути моніторинг тому, хто нічого
// не порушував.
func TestClockWarpDoesNotChangeLimits(t *testing.T) {
plan := PlanLimits{MaxDevices: intp(100), RetentionDays: 90}
used := Usage{Devices: 40}
// Стан лишається тим, який дала EvaluateLicense; годинник впливає
// лише на те, ЯКИЙ момент їй передали, а не на самі стелі.
got := EffectiveLimits(plan, LicenseActive, used)
if got.MaxDevices == nil || *got.MaxDevices != 100 {
t.Fatalf("стелю змінено без причини: %v", got.MaxDevices)
}
if got.RetentionDays != 90 {
t.Fatalf("строк зберігання змінено без причини: %d", got.RetentionDays)
}
}
// ---------------------------------------------------------------------
// Стелі після прострочення — головна перевірка файлу
// ---------------------------------------------------------------------
// Протермінована ліцензія не опускає стелю нижче зайнятого.
//
// Це і є те «не спрацювало як слід», яке найдорожче: якби стеля впала
// до тарифної, 485 із 500 хостів стали б такими, яких не можна повернути
// з архіву й не можна ввімкнути назад — тобто продукт відібрав би в
// клієнта те, що вже працює, за несплачений рахунок.
func TestExpiredLicenseFreezesCeilingAtUsage(t *testing.T) {
plan := PlanLimits{
MaxDevices: intp(15), MaxMaps: intp(1),
MaxAgents: intp(1), MaxUsers: intp(3),
RetentionDays: 7,
}
used := Usage{Devices: 500, Maps: 12, Agents: 4, Users: 9}
got := EffectiveLimits(plan, LicenseExpired, used)
check := func(name string, got *int, want int) {
t.Helper()
if got == nil {
t.Fatalf("%s: стеля зникла зовсім", name)
}
if *got != want {
t.Fatalf("%s: стеля = %d, очікувалось %d", name, *got, want)
}
}
check("хости", got.MaxDevices, 500)
check("мапи", got.MaxMaps, 12)
check("зонди", got.MaxAgents, 4)
check("користувачі", got.MaxUsers, 9)
// Строк зберігання прострочення НЕ чіпає. Скоротити його означало б
// знищити вже зібрану історію — тобто вчинити з даними клієнта те,
// чого не робить навіть свідоме зменшення тарифу.
if got.RetentionDays != 7 {
t.Fatalf("строк зберігання = %d, ліцензія не має його чіпати", got.RetentionDays)
}
}
// А от РОСТИ протермінована ліцензія не дає: стеля дорівнює зайнятому,
// тобто наступний хост уже не влізе. Без цієї перевірки попередня
// доводила б лише те, що ми нічого не обмежуємо взагалі.
func TestExpiredLicenseStopsGrowth(t *testing.T) {
plan := PlanLimits{MaxDevices: intp(15)}
got := EffectiveLimits(plan, LicenseExpired, Usage{Devices: 500})
if *got.MaxDevices != 500 {
t.Fatalf("стеля = %d, очікувалось рівно зайняте (500)", *got.MaxDevices)
}
// Стеля 500 при 500 зайнятих — це саме «нічого нового»: тригер у БД
// відмовляє на cnt >= lim.
if *got.MaxDevices > 500 {
t.Fatal("стеля вища за зайняте — прострочена ліцензія дозволяє рости")
}
}
// Ліцензія без обмеження лишається без обмеження й після прострочення.
func TestExpiredUnlimitedStaysUnlimited(t *testing.T) {
got := EffectiveLimits(PlanLimits{}, LicenseExpired, Usage{Devices: 500})
if got.MaxDevices != nil {
t.Fatalf("необмежена стеля стала %d", *got.MaxDevices)
}
}
// До прострочення стеля дорівнює купленій, а не зайнятому.
//
// Симетрична перевірка до попередніх: якби «не нижче зайнятого» діяло
// завжди, клієнт, який якось завів 500 хостів на тарифі в 15, назавжди
// лишався б із 500 — тобто стеля не діяла б узагалі.
func TestActiveLicenseKeepsPlanCeiling(t *testing.T) {
plan := PlanLimits{MaxDevices: intp(15)}
for _, state := range []string{LicenseActive, LicenseGrace, LicenseUnlicensed} {
got := EffectiveLimits(plan, state, Usage{Devices: 500})
if got.MaxDevices == nil || *got.MaxDevices != 15 {
t.Fatalf("стан %s: стеля = %v, очікувалось 15", state, got.MaxDevices)
}
}
}
// ---------------------------------------------------------------------
// Стелі з самого ключа
// ---------------------------------------------------------------------
// Порожні поля ключа не забирають того, що дає тариф.
//
// Ще одна перевірка «чого не має статись», і ціна помилки тут — робочий
// день клієнта: ключ, у якому не заповнили max_devices, мусить лишити
// тарифну стелю, а не заблокувати перший же хост нулем.
func TestLicenseEmptyFieldsFallBackToPlan(t *testing.T) {
base := PlanLimits{
MaxDevices: intp(100), MaxMaps: intp(10), MaxMapNodes: intp(50),
MaxAgents: intp(5), MaxUsers: intp(25), RetentionDays: 90,
}
got := LimitsFromPayload(base, &LicensePayload{Plan: "pro"})
if *got.MaxDevices != 100 || *got.MaxMaps != 10 || *got.MaxMapNodes != 50 ||
*got.MaxAgents != 5 || *got.MaxUsers != 25 || got.RetentionDays != 90 {
t.Fatalf("ключ без стель перекрив тариф: %+v", got)
}
// А заповнене — перекриває: саме тут живе домовленість «вам 250,
// хоча в тарифі 100».
got = LimitsFromPayload(base, &LicensePayload{MaxDevices: intp(250)})
if *got.MaxDevices != 250 {
t.Fatalf("стеля з ключа не застосувалась: %v", got.MaxDevices)
}
if *got.MaxUsers != 25 {
t.Fatalf("незадане поле ключа зачепило сусіднє: %v", got.MaxUsers)
}
// Нуль у ключі — це справді нуль, а не «не задано». Різницю тримає
// вказівник, і саме її ця перевірка й стереже.
got = LimitsFromPayload(base, &LicensePayload{MaxAgents: intp(0)})
if got.MaxAgents == nil || *got.MaxAgents != 0 {
t.Fatalf("нуль у ключі прочитано як «не задано»: %v", got.MaxAgents)
}
}
func TestLicenseFeaturesFallBackToPlan(t *testing.T) {
base := []string{"icmp", "snmp", "ncm_git"}
if got := FeaturesFromPayload(base, &LicensePayload{}); len(got) != 3 {
t.Fatalf("порожній перелік у ключі закрив фічі тарифу: %v", got)
}
got := FeaturesFromPayload(base, &LicensePayload{Features: []string{"snmp", "icmp"}})
if len(got) != 2 || got[0] != "icmp" || got[1] != "snmp" {
t.Fatalf("перелік із ключа не застосувався або не впорядкований: %v", got)
}
}
// ---------------------------------------------------------------------
// Набір ключів підпису
// ---------------------------------------------------------------------
func TestParseLicenseVerifier(t *testing.T) {
pub, _ := testKeypair(t)
spec := "k1=" + hex.EncodeToString(pub)
v, err := ParseLicenseVerifier(spec)
if err != nil {
t.Fatalf("розбір набору: %v", err)
}
if v.Empty() {
t.Fatal("набір порожній після розбору непорожнього рядка")
}
bad := []string{
"безЗнакаРівності",
"k1=не-hex-і-не-base64",
"k1=" + hex.EncodeToString([]byte{1, 2, 3}), // коротший за ed25519.PublicKeySize
}
for _, s := range bad {
if _, err := ParseLicenseVerifier(s); err == nil {
t.Errorf("прийнято некоректний набір %q", s)
}
}
}