П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
23 KiB
Тести вебу
npm test # vitest run — один прогін
npm run test:watch # у режимі спостереження
npm run check # типи + тести + збірка
sh ../scripts/check.sh web # те саме, але тим самим шляхом, що й CI
Раннер — vitest, а не jest: проєкт на Vite, і vitest.config.ts зроблено
через mergeConfig(viteConfig, …). Тобто тести проходять ТУ САМУ трансформацію,
що й збірка. З jest вийшло б два різні конвеєри, і розбіжність між ними
знаходили б не тестом, а на стенді.
Середовище — jsdom. Тести лежать у src/test/.
Головне про цей файл
Сьогодні на проєкті сталося ось що: перед зміною ролі БД прогнали ретельний тест ізоляції, він був зелений — і пропустив поломку, бо перевіряв «чи не видно чужого», а зламалось «чи видно своє».
Зелена перевірка доводить рівно те, що вона перевіряє. Тому нижче спершу йде перелік того, чого тести НЕ покривають. Він важливіший за перелік покритого: доки він тут і чесний, зелений прогін не можна прийняти за доказ справності.
Чого тести НЕ покривають
Мапа: перетягування вузлів і все, що робить d3-drag
MapCanvas тягне вузли через React Flow, а той — через d3-drag. d3-drag не
запускається синтетичними подіями jsdom: він читає pointerId, PointerEvent,
elementFromPoint і матрицю перетворення SVG, а jsdom нічого з цього не рахує.
Наслідок практичний: тесту на перетягування немає й не буде без справжнього
браузера.
Що саме через це лишається неперевіреним:
- запис нової позиції вузла після перетягування (
MapCanvas.tsx, порівнянняMath.round(x/y)з попереднім і рішення «слати чи ні»); - групове перетягування виділених вузлів;
- малювання нового звʼязку мишею від порту до порту;
- вибір бока підключення при перетягуванні (
autoSidesперевірено як функція, але не перевірено, що полотно взагалі її кличе й із тими координатами).
Усе, що вимагає справжнього рушія
- Мінікарта конфігу (
Minimap.tsx) — перевірено лишеbuildShape. Саме малювання йде в<canvas>, а jsdom не має 2D-контексту:getContext('2d')повертаєnull, і всі три шари мовчки нічого не роблять. Тобто розкладка й вигляд мінікарти не перевірені ніяк: помилка вyOfRow,rowAtY, у межах вікна прокрутки або в колонці щільності позначок пройде зеленою. - Точка підпису на кривій (
pointsOnPathуmapStyle.ts) — код сам повертає[]під jsdom, боSVGPathElement.getTotalLengthтам не реалізовано. Гілка з реальними вимірами не виконується жодного разу. - Ширина символа (
useCharWidth) і будь-яка залежність відgetBoundingClientRect: у jsdom усі розміри нульові. Віртуальна прокруткаConfigViewerперевірена лише в арифметичній частині (rowAt,uniformOffsets), не в тій, що читає реальні розміри. - Медіазапити Tailwind.
DataTableмалює кожен рядок ДВІЧІ — таблицею для десктопа й карткою для телефона, — а ховає зайве CSS. У тесті видно обидві копії; який саме варіант побачить людина на 375 px, тест не знає. ResizeObserverпідмінено заглушкою вsrc/test/setup.ts. Логіка, що залежить від зміни розміру, не спрацьовує.
Мережа й живі дані
Мережевий шар тепер покритий (див. «Що покрито»), але покритий він проти
підставного fetch, а не проти сервера. Тому лишається непокритим:
- Жодного тесту зі справжнім сервером. Підставні відповіді написані за
server/API.mdі за обробникамиserver/internal/httpapi/, і звірка ця зроблена очима, один раз. Ніщо не тримає її актуальною: якщо сервер перейменує поле, тести лишаться зеленими на старій формі. Розбіжність міжtypes.tsі реальною відповіддю API тут не ловиться в принципі — так само, як і раніше. - Справжній
WebSocketне бере участі. Перевірено логікуLiveConnectionпроти підставного сокета: рукостискання, підпротоколи, поведінка проксі,bufferedAmount, порядок подій у реальному браузері — ні. - Черга подій під навантаженням. Що буде, коли сервер надішле тисячі повідомлень за секунду, тест не знає: усі перевірки — на одиницях подій.
- Ротація refresh-токена в часі. Перевірено, що обмін ОДИН на сплеск
запитів; що буде, коли access-токен протухне рівно між
fetchі читанням тіла, — не перевірено. - Режим кіоска (
VITE_API_TOKEN).staticTokenPresentчитається зimport.meta.envпід час імпорту модуля, тому в тестах він завжди порожній: жодна перевірка цієї гілки не виконується (див. «знайдене» у звіті — там про неї є що сказати).
Сторінки
Покрито п’ять сторінок із двадцяти семи (AuditPage, DevicesPage,
TeamPage, RolesPage, AlertsPage) і два вікна незворотних дій.
Решта двадцять дві не покриті нічим: MapPage, DevicePage,
ConfigsPage, CommandsPage, RulesPage, TemplatesPage, TrapsPage,
StoragePage, QueuesPage, MetricsPage, DashboardPage, GroupsPage,
CredentialsPage, ProfilesPage, CompliancePage, MirrorPage,
ChannelsPage, EscalationsPage, AgentsPage, ServerFilesPage,
ProfilePage, TvPage. Для них не перевірено ні права, ні форми, ні фільтри.
Окремо про те, чого немає навіть на покритих сторінках:
- Фільтри в адресі перевірені лише в
AuditPage.DevicesPageтримає свої фільтри в стані компонента, а не в адресі, — тобто відфільтрований перелік хостів колезі не перешлеш. Це не поломка тесту, це властивість продукту, і тест її не покриває, бо покривати нічого. DeviceFilterPanelі фільтри метрик — перевірено лише чисті функції (filters.test.ts), не зв’язку «панель → запит».- Створення хоста, шаблона, правила, каналу — форми не покриті. Перевірено лише те, що кнопка створення з’являється за правом.
- Порядок і кількість запитів при монтуванні сторінки перевірено лише
там, де це саме предмет тесту (зонди на
DevicesPage). Зайвий запит на решті сторінок пройде зеленим.
Права доступу
Перевірено пари «є право / немає права» для хостів, користувачів, ролей і алертів. Не перевірено:
- звуження групами доступу (
writable,access: read|writeна мапі): тести працюють із хостами, у якихwritable: true; - розбіжність між тим, що ховає клієнт, і тим, що відхиляє сервер.
Клієнтський
session.can()і сервернийrequirePerm()— два різні переліки, і ніщо не звіряє їх між собою. Право, яке сервер уже вимагає, а клієнт ще ні (або навпаки), пройде зеленим по обидва боки; - право, що змінилось під час роботи вкладки. Сервер перечитує членство
при кожній ротації токена, клієнт — при
session.set. Що бачить людина в проміжку, не перевірено.
Доступність і клавіатура
- Модальне вікно не переносить фокус усередину й не тримає його там. Тесту на це немає, бо й поведінки немає (див. «знайдене» нижче). Перевірено лише закриття: Esc, клік повз панель, хрестик.
- Порядок обходу Tab, читачі з екрана, контраст — не перевірено ніяк.
Дрібне, але варте згадки
download()(збереження конфігу файлом) — не покрито:URL.createObjectURLу jsdom існує, але завантаження не відбувається.- Часові пояси:
RollbackDialog.fmtDateіAuditPage.fmt*користуютьсяtoLocaleString('uk-UA'), результат залежить від пояса машини. Тестів немає саме тому — вони були б зелені локально й червоні в CI. MetricChart.build(розрахунок меж осей, накопичення, розриви) — не покрито. Покрито лишеfmtValueіseriesLabel.
Знайдене, але не закріплене тестом
Правило 3 нижче забороняє закріплювати ваду зеленим expect. Це знайдено під
час написання тестів мережевого шару й сторінок; тестів на це навмисно немає —
або вони були б червоні, або зафіксували б неправильну поведінку.
-
«Будь-який машинний токен» у журналі аудиту їде не тим параметром.
AuditPageкладе значення-уfilter.tokens, звідкиapi.audit()надсилаєtoken=-. Сервер шукає-середactor(httpapi/audit.go, гілкаf.AnyToken), аtokenкладе вActorTokenIDs— і той іде в SQL якactor_token_id = ANY(...)по стовпцюuuid(migrations/0001_core.sql:215). Тобто вибір цієї позначки дає не «рядки без людини», а помилку розбору UUID. Показово: коментар уtypes.tsнадAuditFilter.actorsописує ПРАВИЛЬНУ поведінку —-мав лежати середactors. -
Режим кіоска нікому нічого не показує. При заданому
VITE_API_TOKENAppпропускає відновлення сесії, тожsession.me()лишаєтьсяnullназавжди — аsession.can()безmeповертаєfalseна будь-яке право. Наслідок: бічне меню порожнє, а кожен маршрут показує «Розділ недоступний».api.me()у клієнті є, але не викликається звідки-небудь жодного разу. -
api.logout()кидає помилку в порожнечу.AppShellкличе його якvoid api.logout(); сесія чиститься вfinally, але відмова сервера стає необробленим відхиленням промісу.
Тестів на п.1 і п.2 немає навмисно: вони були б червоні, а червоний тест у
check.sh зупиняє роботу всім.
Що покрито
Чиста логіка
| Файл тесту | Що саме |
|---|---|
format.test.ts |
plural, ago, humanInterval, fmtBytes, formatBps, fmtValue, fmtBps, uniq — межі одиниць, знак, дати з майбутнього |
linediff.test.ts |
splitLines, diffSegs, buildDiff, wordDiff |
configview.test.ts |
rowAt, uniformOffsets, maxLen, gutterWidth, safeName, buildShape |
map.test.ts |
autoSides, autoLabelPositions, labelCandidates, estimateLabelBox, edgeState |
labelLayout.test.ts |
жадібна розкладка підписів: зсув, невміщення, пріоритет, масштаб, звільнення місця |
filters.test.ts |
matchesMetricQuery, matchesMetricFilter, defaultsFromSchema |
Найважливіше тут — порівняння конфігів. Воно перевіряється не прикладами, а інваріантами на 500 випадкових парах із детермінованим генератором: із ділянок має точно відновлюватись і стара версія, і нова; нумерація обох колонок має йти без пропусків і повторів; текст у рядку має відповідати своєму номеру. Приклади ловлять те, про що встиг подумати автор тесту; інваріант ловить те, про що не подумав ніхто. Окремо перевірено розрахунковий випадок Myers — один змінений рядок серед 30 000.
Поведінка компонентів
| Файл тесту | Що саме |
|---|---|
modal.test.tsx |
Esc (у т.ч. коли фокус у полі), клік повз панель, виділення тексту за край вікна НЕ закриває, зняття слухача при розмонтуванні, role=dialog/aria-modal |
confirm.test.tsx |
подвійний клік не шле другий запит, помилка сервера НЕ закриває вікно, Esc = скасувати, підпис кнопки |
datatable.test.tsx |
порожній стан, подвійна відмальовка рядків, hideOnMobile, Toggle |
Мережевий шар
| Файл тесту | Що саме |
|---|---|
apiclient.test.ts |
заголовок Bearer, credentials: same-origin, 204 без тіла, розбір {"error":{code,message}}, isConflict/isPlanLimit/isForbidden і навпаки, тихий обмін на 401 token_expired, відсутність обміну на no_session і 403, один обмін на десять паралельних запитів, restore() один на завантаження, logout чистить сесію навіть при відмові сервера, session.can, meFromLogin |
ws.test.ts |
адреса й токен у підпротоколі, читання токена заново після реконекту, connecting/online/offline, затримка 1→2→4→8→15 с зі стелею й скиданням, stop() зупиняє реконект і забуває підписку, повторна підписка на мапу після кожного розриву, пошкоджене повідомлення, слухач, що впав |
hooks.test.tsx |
злиття сплеску подій в один перечит, перечит після паузи, чужі повідомлення ігноруються, розмонтування скасовує заплановане, useAlerts без alerts:read не питає нічого |
Найважливіше тут — негативні половини. «Обмін відбувся» нічого не варте
без «на no_session не відбувся»; «реконект стався» — без «після stop() не
стався». Саме асиметрична перевірка й пропустила зламаний вхід у тесті
ізоляції RLS.
Сторінки
| Файл тесту | Що саме |
|---|---|
routing.test.tsx |
неавторизований на закритій адресі бачить вхід і сторінка даних не питає; після входу відкривається та сама адреса; Guard без права не малює сторінку й не робить запиту; домівка залежить від ролі; пункт меню без права відсутній; вихід повертає до входу й розриває сокет |
pagepermissions.test.tsx |
пари «є право / немає права» для хостів, користувачів, ролей, алертів; себе й власника прибрати не можна; право, якого немає в тебе, не можна віддати ролі; зміна ролі шле лише те, що змінилось; порожня правка запиту не робить; подвійний клік не шле двох PATCH; 409 shared_user лишає вікно з набраним |
auditfilter.test.tsx |
фільтр із посилання й той самий фільтр, обраний руками, дають однаковий запит і однакову адресу; типовий період в адресу не пишеться; скидання чистить і адресу, і запит; гортання йде у вікні, яке зібрав сервер |
destructive.test.tsx |
видалення хоста: набір звіряється з сервером, повне видалення заблоковане до підтвердження втрати, без зібраного галочки немає, типово — повне, підпис кнопки називає режим, подвійний клік не дублює запит, помилка не закриває вікно; відкат конфігу: план, непідтримуваний профіль, ручні рядки, plan_hash, збережена причина після відмови |
Підставні відповіді написані не «схоже», а за формою сервера: помилка — з
writeError() (httpapi/server.go), вхід і refresh — з API.md, масові
дії над хостами — з devices_bulk.go і store.BulkDeviceTarget, журнал — з
handleListAudit. Спільні підпори лежать у src/test/support.ts — щоб
розбіжність із сервером правилась в одному місці й ламала всі тести одразу.
Обрано саме ці два вікна не випадково: Modal — єдине місце, де самовільне
закриття зʼїдає набране в довгій формі, а ConfirmDialog — останній екран перед
незворотною дією, і його дві тихі поломки (другий запит на видалення; закриття
після невдачі, яке читається як «виконано») коштують найдорожче.
Правила для нових тестів
- Перевіряй те, що ламається, а не те, що легко перевірити. Відсоток покриття тут не рахується навмисно: він винагороджує тести на гетери.
- Назва тесту — це твердження про поведінку, а не про виклик функції. «нумерація обох колонок іде без пропусків і без повторів», а не «buildDiff works».
- Не закріплюй ваду зеленим тестом. Якщо поведінка неправильна — місце їй
у розділі «знайдене» звіту або в цьому файлі, а не в
expect. Виняток — коли поточне правило треба зафіксувати свідомо (як сортуванняuniqкодами символів); тоді це має бути сказано в коментарі. - Випадкові дані — лише з детермінованим генератором. Тест, що падає раз на сто прогонів і не відтворюється, гірший за відсутність тесту: у нього перестають вірити й починають перезапускати CI, доки не позеленіє.
- Модульний стан прибирай за собою (див.
labelLayout.test.ts): інакше падатиме не той тест, що зламався.