П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
264 lines
23 KiB
Markdown
264 lines
23 KiB
Markdown
# Тести вебу
|
||
|
||
```bash
|
||
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`. Це знайдено під
|
||
час написання тестів мережевого шару й сторінок; тестів на це навмисно немає —
|
||
або вони були б червоні, або зафіксували б неправильну поведінку.
|
||
|
||
1. **«Будь-який машинний токен» у журналі аудиту їде не тим параметром.**
|
||
`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`.
|
||
|
||
2. **Режим кіоска нікому нічого не показує.** При заданому
|
||
`VITE_API_TOKEN` `App` пропускає відновлення сесії, тож
|
||
`session.me()` лишається `null` назавжди — а `session.can()` без `me`
|
||
повертає `false` на будь-яке право. Наслідок: бічне меню порожнє, а
|
||
кожен маршрут показує «Розділ недоступний». `api.me()` у клієнті є, але
|
||
не викликається звідки-небудь жодного разу.
|
||
|
||
3. **`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` — останній екран перед
|
||
незворотною дією, і його дві тихі поломки (другий запит на видалення; закриття
|
||
після невдачі, яке читається як «виконано») коштують найдорожче.
|
||
|
||
---
|
||
|
||
## Правила для нових тестів
|
||
|
||
1. **Перевіряй те, що ламається, а не те, що легко перевірити.** Відсоток
|
||
покриття тут не рахується навмисно: він винагороджує тести на гетери.
|
||
2. **Назва тесту — це твердження про поведінку**, а не про виклик функції.
|
||
«нумерація обох колонок іде без пропусків і без повторів», а не «buildDiff
|
||
works».
|
||
3. **Не закріплюй ваду зеленим тестом.** Якщо поведінка неправильна — місце їй
|
||
у розділі «знайдене» звіту або в цьому файлі, а не в `expect`. Виняток —
|
||
коли поточне правило треба зафіксувати свідомо (як сортування `uniq` кодами
|
||
символів); тоді це має бути сказано в коментарі.
|
||
4. **Випадкові дані — лише з детермінованим генератором.** Тест, що падає раз
|
||
на сто прогонів і не відтворюється, гірший за відсутність тесту: у нього
|
||
перестають вірити й починають перезапускати CI, доки не позеленіє.
|
||
5. **Модульний стан прибирай за собою** (див. `labelLayout.test.ts`): інакше
|
||
падатиме не той тест, що зламався.
|