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