П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
146 lines
7.5 KiB
Go
146 lines
7.5 KiB
Go
package store
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
)
|
||
|
||
// Розпізнавання відмови через стелю — без бази.
|
||
//
|
||
// ЧОМУ ЦЕ ОКРЕМА ПЕРЕВІРКА, А НЕ ЧАСТИНА ТЕСТУ ПРОТИ БАЗИ
|
||
//
|
||
// Тому що зламана тут ланка була саме такою: перевірка в БД працювала
|
||
// бездоганно, а Go не впізнавав її відмову. httpapi/groups.go шукав у
|
||
// тексті помилки слово «ліміт», а тригер 0009 писав англійською — і
|
||
// збігу не було ніколи. Тобто в мить, коли стеля вперше спрацювала б,
|
||
// людина отримала б 500 «внутрішня помилка» замість пояснення.
|
||
//
|
||
// Тест проти бази цього НЕ ловить: там відмова справді сталася, рядок
|
||
// не створився, перевірка зелена. Ловить лише окрема перевірка того, що
|
||
// саме код зробив із цією відмовою далі.
|
||
//
|
||
// Тому нижче — не «стеля спрацювала», а «відмову впізнано, і з неї
|
||
// дістали числа, які можна показати людині».
|
||
|
||
// planLimitPgError збирає відмову рівно такою, якою її підіймає
|
||
// bill.deny_limit із 0069.
|
||
func planLimitPgError(msg, detail string) *pgconn.PgError {
|
||
return &pgconn.PgError{
|
||
Severity: "ERROR",
|
||
Code: "23514", // check_violation
|
||
Message: msg,
|
||
Detail: detail,
|
||
Hint: "upgrade_plan",
|
||
}
|
||
}
|
||
|
||
func TestAsPlanLimitReadsNumbers(t *testing.T) {
|
||
raw := planLimitPgError(
|
||
"у тарифі Free дозволено 15 хостів, зараз 15",
|
||
`{"limit":"devices","plan":"free","allowed":15,"used":15}`)
|
||
|
||
// Обгортка навмисно подвійна: у житті помилка проходить через
|
||
// fmt.Errorf у CreateDevice і ще раз через шар транзакції.
|
||
err := fmt.Errorf("створення хоста: %w", fmt.Errorf("tx: %w", raw))
|
||
|
||
lim, ok := AsPlanLimit(err)
|
||
if !ok {
|
||
t.Fatal("відмову через стелю не впізнано в обгортках")
|
||
}
|
||
if lim.Kind != "devices" || lim.Plan != "free" || lim.Allowed != 15 || lim.Used != 15 {
|
||
t.Fatalf("числа не дістались: %+v", lim)
|
||
}
|
||
if lim.Error() != "у тарифі Free дозволено 15 хостів, зараз 15" {
|
||
t.Fatalf("текст відмови загублено: %q", lim.Error())
|
||
}
|
||
|
||
// Типізована помилка відповідає наявному вартовому — на це
|
||
// спирається шлях правки мапи, де відмову перекладає mapPgError.
|
||
if !errors.Is(lim, ErrPlanLimit) {
|
||
t.Fatal("errors.Is(PlanLimitError, ErrPlanLimit) не спрацював")
|
||
}
|
||
|
||
// А СИРА помилка бази вартовому не відповідає, і це не недогляд, а
|
||
// факт, який мусить бути записаний перевіркою: у її ланцюжку лежить
|
||
// *pgconn.PgError, і жоден errors.Is його з ErrPlanLimit не зведе.
|
||
// Саме тому спільний обробник помилок питає AsPlanLimit окремим
|
||
// рядком. Якби хтось «спростив» його назад до одного errors.Is,
|
||
// відмова через стелю знову перетворилась би на 500 — на всіх
|
||
// шляхах, крім мап, і мовчки.
|
||
if errors.Is(err, ErrPlanLimit) {
|
||
t.Fatal("сира помилка бази раптом відповідає ErrPlanLimit — " +
|
||
"перевірте, чи не зайвий тепер AsPlanLimit у writeStoreError")
|
||
}
|
||
}
|
||
|
||
// Регресія на ту саму поломку, з якої почалась ця робота.
|
||
//
|
||
// Стара перевірка шукала слово «ліміт» у тексті. Відмова англійською —
|
||
// саме те, що підіймала 0009, і саме те, що вона пропускала. Тепер
|
||
// впізнавання йде за HINT, тобто текст може бути будь-яким, включно з
|
||
// таким, який ще не переклали.
|
||
func TestAsPlanLimitDoesNotDependOnWording(t *testing.T) {
|
||
raw := planLimitPgError("device limit reached for tenant abc (limit 15)", "")
|
||
lim, ok := AsPlanLimit(fmt.Errorf("створення хоста: %w", raw))
|
||
if !ok {
|
||
t.Fatal("англійська відмова 0009 не впізнана — та сама поломка, що й була")
|
||
}
|
||
// Чисел немає — стара міграція їх не передавала. Відмова від цього
|
||
// не перестає бути відмовою: 402 має поїхати в будь-якому разі.
|
||
if lim.Allowed != 0 || lim.Used != 0 {
|
||
t.Fatalf("узялись числа, яких у відмові не було: %+v", lim)
|
||
}
|
||
if lim.Error() == "" {
|
||
t.Fatal("текст відмови порожній — людині нічого показати")
|
||
}
|
||
}
|
||
|
||
// Перевірка протилежного боку: сторонні помилки бази не мають
|
||
// перетворюватись на «вичерпано тариф».
|
||
//
|
||
// Це те, що ламається тихо: обробник, який на будь-яку помилку віддає
|
||
// 402 «змініть тариф», сховає справжню поломку за пропозицією заплатити,
|
||
// і розбиратись у ній ніхто не піде.
|
||
func TestAsPlanLimitIgnoresOtherErrors(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
}{
|
||
{"звичайна помилка", errors.New("щось пішло не так")},
|
||
{"nil", nil},
|
||
{"порушення унікальності", fmt.Errorf("x: %w", &pgconn.PgError{
|
||
Code: "23505", Message: "duplicate key value violates unique constraint",
|
||
})},
|
||
{"check без підказки", fmt.Errorf("x: %w", &pgconn.PgError{
|
||
Code: "23514", Message: "new row violates check constraint",
|
||
})},
|
||
{"чужа підказка", fmt.Errorf("x: %w", &pgconn.PgError{
|
||
Code: "23514", Message: "щось", Hint: "GRANT SELECT ...",
|
||
})},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if _, ok := AsPlanLimit(c.err); ok {
|
||
t.Fatal("сторонню помилку прийнято за вичерпаний тариф")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Зіпсована подробиця не має губити саму відмову.
|
||
//
|
||
// Інсталяція зі свіжим Go й старою схемою (0069 ще не накотилась) —
|
||
// звичайний стан під час оновлення, і в ньому DETAIL порожній або
|
||
// чужого формату. Втратити через це 402 означало б показати «внутрішню
|
||
// помилку» саме тоді, коли причина відома точно.
|
||
func TestAsPlanLimitSurvivesBrokenDetail(t *testing.T) {
|
||
for _, detail := range []string{"", "не json", `{"allowed":"багато"}`, `[1,2,3]`} {
|
||
err := fmt.Errorf("x: %w", planLimitPgError("стеля", detail))
|
||
if _, ok := AsPlanLimit(err); !ok {
|
||
t.Fatalf("відмову втрачено через подробицю %q", detail)
|
||
}
|
||
}
|
||
}
|