Прив'язка Telegram: сторінка була, дороги до неї не було
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m21s
CI / server (push) Successful in 1m48s
CI / agent (push) Successful in 2m59s

Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.

Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.

* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.

Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.

65 міграцій, усе зелене проти справжньої бази.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-28 19:57:09 +03:00
parent ec4b2cd54b
commit ae07bd2a79
12 changed files with 728 additions and 27 deletions

View file

@ -7612,3 +7612,68 @@ NULL і валить запис. Правило без власних канал
міграції: падіння виглядало як «немає стовпця respect_quiet_hours», хоча міграції: падіння виглядало як «немає стовпця respect_quiet_hours», хоча
стовпець у гілці є. Тепер міграції котяться з того ж дерева, що й тести стовпець у гілці є. Тепер міграції котяться з того ж дерева, що й тести
(`go run ./cmd/netpulse-migrate`). Перевірено: 64 міграції, усе зелене. (`go run ./cmd/netpulse-migrate`). Перевірено: 64 міграції, усе зелене.
---
## 2026-08-28 — Прив'язка Telegram: сторінка була, дороги до неї не було
Власник натиснув кнопку під сповіщенням у Telegram і отримав «Ваш Telegram
не прив'язано до NetPulse. Профіль → Telegram → отримати код». Відповідь
правильна по суті. Виходу з неї не існувало.
**Що показала база:** нуль прив'язок і нуль згенерованих кодів за весь
час. Механізмом не скористався ніхто, включно з власником.
**Чому.** Сторінка профілю з карткою прив'язки існує й працює. Пункту
меню в неї не було; єдиний вхід — власне ім'я користувача в правому
верхньому куті. А малювалось воно за умовою «є ім'я або пошта», і `/me`
віддавало лише пошту — порожню в облікового запису власника, який
заводить установник і який входить ЗА ІМЕНЕМ. Тобто в типовій інсталяції
вхід у профіль не малювався взагалі, і сторінка була досяжна лише
введенням адреси вручну.
Тип `Me` у фронтенді при цьому вже містив `username`. Сервер його просто
не слав — знову «обіцянка без виконання», тільки на межі шарів.
**Виправлено чотири речі, кожна закриває свій бік:**
* `/me` віддає `username`. Одне поле, без якого решта марна.
* Вхід у профіль малюється ЗАВЖДИ для людини (не для машинного токена),
з іконкою й підписом «Мій профіль» у підказці; без імені й пошти пише
просто «Профіль» замість того, щоб зникнути.
* Пункт меню «Обліковий запис → Мій профіль». Це свідомий виняток із
власного правила файлу («група з одного пункту додала б заголовок,
який нічого не групує»): усі інші групи — про систему, ця — про того,
хто на неї дивиться, і саме через різницю в роді профіль нікуди не
вписався й лишився без пункту. `perm` у пунктів меню став
необов'язковим: вигадувати право для власного профілю означало б
брехати про модель доступу заради типу.
* Текст бота називає те, що людина побачить на екрані, а не назву
сторінки в коді.
Плюс сторінка каналів більше не мовчить: біля telegram-каналу видно, чи
прив'язаний Telegram у того, хто дивиться, і що саме не працюватиме без
цього. Не червоним — це незавершене налаштування, а не поломка.
### Оренда сходинки: токен замість строку (0073)
Остання знахідка рецензії ескалацій. `TakeDueEscalations` ставила в рядок
лише СТРОК оренди (2 хв), а партія з сотні сходинок із мережевими
таймаутами легко його переростає. Оренда спливала, другий інстанс брав ту
саму сходинку з тим самим `step_idx` і доставляв її — а перший доставляв
теж. Два дзвінки о третій ночі й два рядки «надіслано».
Тепер у рядку лежить `lease_token`, і `ApplyEscalation` пише лише за його
збігу. Довша оренда цього не вирішила б: довший строк — це пересунута
межа, і він псує протилежний випадок (процес, що впав одразу після
взяття). При розбіжності не відбувається НІЧОГО: запис не проходить,
доставки немає (стан пишеться перед нею), оренду того, хто зараз працює,
не зривають. Сходинка лишається належною, і її доводить до розв'язку
власник токена — або, якщо і він зник, наступний такт після спливання
строку.
Свідомо НЕ вимагається, щоб оренда була ще й живою: інакше кожна повільна
партія втрачала б усі свої сходинки, і драбина повзла б зі швидкістю
однієї сходинки за такт.
Прогнано проти справжньої бази: **65 міграцій, усі пакети зелені**.

View file

@ -44,6 +44,13 @@ const escalationBatch = 100
// чергу через FOR UPDATE SKIP LOCKED, а оренда на рядку // чергу через FOR UPDATE SKIP LOCKED, а оренда на рядку
// (EscalationLease) — другий рубіж на випадок, коли рядок усе-таки взяли // (EscalationLease) — другий рубіж на випадок, коли рядок усе-таки взяли
// двоє. // двоє.
//
// Партія при цьому МОЖЕ пережити власну оренду, і це не аварія, а
// нормальний вечір: escalationBatch сходинок, кожна з мережевим
// таймаутом на канал, легко переростають дві хвилини. Рятує від
// подвоєння не строк, а токен оренди (0073): ApplyEscalation відмовить
// сходинці, чию оренду вже перебрали, і надсилання до неї не дійде — бо
// стан пишеться ПЕРЕД доставкою, а не після.
func (e *Engine) escalate(ctx context.Context) { func (e *Engine) escalate(ctx context.Context) {
due, err := e.st.TakeDueEscalations(ctx, escalationBatch) due, err := e.st.TakeDueEscalations(ctx, escalationBatch)
if err != nil { if err != nil {
@ -88,10 +95,16 @@ func (e *Engine) escalate(ctx context.Context) {
continue continue
} }
if !applied { if !applied {
// Драбину зупинили, поки сходинка чекала своєї черги — // Три причини, і всі — не помилка.
// найчастіше людина натиснула «Прийняти». Це не помилка, це //
// той випадок, заради якого кнопка й існує. // Драбину зупинили, поки сходинка чекала своєї черги
e.log.Info("сходинку скасовано: драбину вже зупинено", // (найчастіше людина натиснула «Прийняти») — це той випадок,
// заради якого кнопка й існує. Алерт погас іншим шляхом —
// дзвонити нема про що. Або оренду перебрали: партія
// затягнулась довше за EscalationLease, і сходинку вже веде
// інше взяття (токен у 0073). У всіх трьох сходинка НЕ
// доставляється — саме тому запис іде перед надсиланням.
e.log.Info("сходинку скасовано: драбину зупинено, алерт погас або оренду перебрано",
"алерт", snap.AlertID, "сходинка", d.StepIdx+1) "алерт", snap.AlertID, "сходинка", d.StepIdx+1)
continue continue
} }

View file

@ -354,8 +354,16 @@ func (b *Bot) handleCallback(ctx context.Context, g botGroup, cq *tgCallbackQuer
acc, err := b.st.TelegramAccountByTgID(ctx, ch.TenantID, cq.From.ID) acc, err := b.st.TelegramAccountByTgID(ctx, ch.TenantID, cq.From.ID)
if errors.Is(err, store.ErrTelegramNotLinked) { if errors.Is(err, store.ErrTelegramNotLinked) {
// Текст називає те, що людина побачить на екрані, а не назву
// сторінки в коді. Попередній варіант відсилав до «Профіль →
// Telegram», і саме так це вперше й не спрацювало: пункту
// «Профіль» у меню не було, посилання в шапці не малювалось
// (власник входить іменем, а не поштою, а /me імені не
// віддавало), і людина, яка сумлінно виконала інструкцію,
// доходила до висновку, що зламалось.
answer = "Ваш Telegram не прив'язано до NetPulse.\n" + answer = "Ваш Telegram не прив'язано до NetPulse.\n" +
"Профіль → Telegram → отримати код, потім надішліть боту /link КОД" "У NetPulse: меню → Мій профіль → картка «Telegram» → " +
"«Отримати код», далі надішліть цьому боту: /link КОД"
return return
} }
if err != nil { if err != nil {

View file

@ -240,6 +240,17 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, p *Principal)
if p.IsUser() { if p.IsUser() {
resp["user_id"] = p.UserID resp["user_id"] = p.UserID
resp["email"] = p.Email resp["email"] = p.Email
// Ім'я входу віддається обов'язково, і це не дрібниця.
//
// Обліковий запис власника, який заводить установник, входить за
// ІМЕНЕМ (admin), а не за поштою — тобто email у нього порожній.
// Інтерфейс показував посилання на профіль лише за наявності
// імені або пошти, тож у типовій інсталяції воно не малювалось
// узагалі, і сторінка профілю ставала недосяжною: ні з меню, ні
// з шапки. Разом із нею — прив'язка Telegram, без якої кнопки
// під сповіщеннями відповідають «ваш Telegram не прив'язано» і
// не дають виходу.
resp["username"] = p.Username
} else { } else {
resp["token_id"] = p.TokenID resp["token_id"] = p.TokenID
resp["token_name"] = p.TokenName resp["token_name"] = p.TokenName

View file

@ -375,6 +375,15 @@ type EscalationSnapshot struct {
PassStart time.Time PassStart time.Time
Deadline time.Time Deadline time.Time
// Токен оренди, під якою цю сходинку взято (0073).
//
// Не інформація, а перепустка: ApplyEscalation запише рішення лише
// тоді, коли в рядку лежить рівно цей токен. Знімок, чия оренда
// встигла спливти й дістатись іншому взяттю, стає недійсним — і
// саме тому обробка партії, довша за EscalationLease, більше не
// коштує другого дзвінка о третій ночі.
LeaseToken string
Steps []EscalationStep Steps []EscalationStep
RepeatAfterMin int RepeatAfterMin int
MaxRepeats int MaxRepeats int
@ -620,6 +629,14 @@ func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision {
// сходинки — це той самий свідомий вибір, що вже зроблено для черги // сходинки — це той самий свідомий вибір, що вже зроблено для черги
// подієвих алертів у 0058: «спробували» не дорівнює «доставили», і // подієвих алертів у 0058: «спробували» не дорівнює «доставили», і
// краще не надіслати, ніж надіслати вдруге о третій ночі. // краще не надіслати, ніж надіслати вдруге о третій ночі.
//
// Строк лишається коротким навмисно, хоч партія буває довшою за нього.
// Подовжити його «щоб вистачало» неможливо: сотня сходинок, кожна з
// власним мережевим таймаутом, переросте будь-яке число, а довга
// оренда робить гірше тому єдиному випадку, заради якого вона й
// існує, — процесу, що впав одразу після взяття. Розрив «партія довша
// за оренду» закриває не строк, а токен оренди (0073): володіння
// звіряється при записі, тож перебрана сходинка вже не подвоюється.
const EscalationLease = 2 * time.Minute const EscalationLease = 2 * time.Minute
// TakeDueEscalations забирає сходинки, час яких настав. // TakeDueEscalations забирає сходинки, час яких настав.
@ -627,6 +644,13 @@ const EscalationLease = 2 * time.Minute
// Наскрізно по всіх кабінетах і робочим пулом — так само, як черга // Наскрізно по всіх кабінетах і робочим пулом — так само, як черга
// подієвих алертів: движок один на інсталяцію й крутиться під // подієвих алертів: движок один на інсталяцію й крутиться під
// advisory-блокуванням. // advisory-блокуванням.
//
// Разом зі строком оренди рядок отримує ТОКЕН цього взяття
// (core.new_id() у SET обчислюється для кожного рядка окремо). Строк
// каже лише «зайнято до», і після його спливання рядок дістається
// іншому взяттю — а перше про це не дізнається й піде доставляти. Токен
// перетворює строк на володіння: ApplyEscalation запише рішення лише
// під тим токеном, що лежить у рядку зараз.
func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) { func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) {
if limit <= 0 || limit > 500 { if limit <= 0 || limit > 500 {
limit = 100 limit = 100
@ -642,13 +666,16 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
), taken AS ( ), taken AS (
UPDATE alr.alert_escalations e UPDATE alr.alert_escalations e
SET leased_until = now() + $2::interval SET leased_until = now() + $2::interval,
lease_token = core.new_id()
FROM due d WHERE e.alert_id = d.alert_id FROM due d WHERE e.alert_id = d.alert_id
RETURNING e.alert_id, e.tenant_id, e.policy_id, e.is_event, RETURNING e.alert_id, e.tenant_id, e.policy_id, e.is_event,
e.step_idx, e.repeat_idx, e.pass_start, e.deadline e.step_idx, e.repeat_idx, e.pass_start, e.deadline,
e.lease_token
) )
SELECT t.alert_id::text, t.tenant_id::text, COALESCE(t.policy_id::text,''), SELECT t.alert_id::text, t.tenant_id::text, COALESCE(t.policy_id::text,''),
t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline, t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline,
t.lease_token::text,
COALESCE(p.name,''), COALESCE(p.steps::text,'[]'), COALESCE(p.name,''), COALESCE(p.steps::text,'[]'),
COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0), COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0),
COALESCE(p.respect_quiet_hours,false), COALESCE(p.respect_quiet_hours,false),
@ -675,7 +702,7 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
var s EscalationSnapshot var s EscalationSnapshot
var steps, sched string var steps, sched string
if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent, if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent,
&s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline, &s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline, &s.LeaseToken,
&s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats, &s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats,
&s.RespectQuietHours, &sched, &s.RespectQuietHours, &sched,
&s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message, &s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message,
@ -728,6 +755,16 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
// його й воскресив зупинену драбину — розбудив би саме того, хто // його й воскресив зупинену драбину — розбудив би саме того, хто
// щойно сказав «я цим займаюсь». // щойно сказав «я цим займаюсь».
// //
// оренда все ще НАША (lease_token, 0073) — третя умова, і вона про
// інше: не про алерт, а про право писати. Партія буває довшою за
// EscalationLease, і тоді рядок дістається наступному взяттю ще до
// того, як руки дійшли до нашої сходинки. Без звірки токена обидва
// доставили б ту саму сходинку, і в журналі стояло б два «надіслано»
// на одну — тобто саме той подвійний дзвінок о третій ночі, від якого
// весь цей порядок і побудовано. Токен звіряється завжди, а не лише
// для EscFire: запис зупинки чи відкладання від чужого імені так само
// зсунув би next_at під ногами того, хто зараз працює.
//
// алерт усе ще 'firing' (лише для сходинки, що має спрацювати) — // алерт усе ще 'firing' (лише для сходинки, що має спрацювати) —
// решта шляхів гасіння рядка драбини НЕ чіпають: вимкнення чи // решта шляхів гасіння рядка драбини НЕ чіпають: вимкнення чи
// видалення правила (resolveRuleAlerts), відновлення метрики // видалення правила (resolveRuleAlerts), відновлення метрики
@ -740,7 +777,10 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
// //
// Якщо жодного рядка не оновлено, наступний такт візьме цю сходинку // Якщо жодного рядка не оновлено, наступний такт візьме цю сходинку
// знову, PlanEscalation побачить 'resolved' і зупинить драбину штатно — // знову, PlanEscalation побачить 'resolved' і зупинить драбину штатно —
// тобто вона не застрягне. // тобто вона не застрягне. Те саме стосується й розбіжного токена:
// сходинка лишається належною (next_at не зрушено), її доводить до
// розв'язку той, чий токен зараз у рядку, а якщо не дійде й він —
// оренда спливе за EscalationLease, і сходинку переберуть заново.
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) (bool, error) { func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) (bool, error) {
var next any var next any
if d.NextAt != nil { if d.NextAt != nil {
@ -757,17 +797,23 @@ func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d
pass_start = $4, pass_start = $4,
next_at = $5, next_at = $5,
leased_until = NULL, leased_until = NULL,
lease_token = NULL,
last_step_at = CASE WHEN $6 THEN now() ELSE last_step_at END, last_step_at = CASE WHEN $6 THEN now() ELSE last_step_at END,
stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END, stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END,
stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END
WHERE alert_id = $1 WHERE alert_id = $1
AND stopped_at IS NULL AND stopped_at IS NULL
-- Знімок без токена (він міг лишитись від процесу, що
-- пережив накат 0073) до запису не допускається: NULL = NULL
-- хибне, тож рядків не буде. Це правильний бік помилки
-- сходинку переберуть, а не подвоять.
AND lease_token = $8::uuid
AND (NOT $6 OR EXISTS ( AND (NOT $6 OR EXISTS (
SELECT 1 FROM alr.alerts a SELECT 1 FROM alr.alerts a
WHERE a.id = alr.alert_escalations.alert_id WHERE a.id = alr.alert_escalations.alert_id
AND a.state = 'firing')) AND a.state = 'firing'))
`, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart, `, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart,
next, fired, d.Outcome) next, fired, d.Outcome, nullUUID(snap.LeaseToken))
if err != nil { if err != nil {
return err return err
} }
@ -862,7 +908,7 @@ func stopEscalationTx(ctx context.Context, tx pgx.Tx, tenantID, alertID, reason,
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
WITH stopped AS ( WITH stopped AS (
UPDATE alr.alert_escalations UPDATE alr.alert_escalations
SET next_at = NULL, leased_until = NULL, SET next_at = NULL, leased_until = NULL, lease_token = NULL,
stopped_at = now(), stop_reason = $3 stopped_at = now(), stop_reason = $3
WHERE tenant_id = $1 AND alert_id = $2 AND stopped_at IS NULL WHERE tenant_id = $1 AND alert_id = $2 AND stopped_at IS NULL
RETURNING policy_id, step_idx, repeat_idx RETURNING policy_id, step_idx, repeat_idx

View file

@ -351,6 +351,190 @@ func TestEscalationAgainstDB(t *testing.T) {
t.Error("сходинка не повернулась у чергу — драбину нікому зупинити") t.Error("сходинка не повернулась у чергу — драбину нікому зупинити")
} }
// --- Оренда: токен, а не строк ------------------------------------
//
// Розрив, якого не закривала сама лише leased_until. Партія — до
// сотні сходинок, кожна доставка з власним мережевим таймаутом, а
// оренда — дві хвилини: партія переростає її на першому ж підвислому
// каналі. Тоді рядок дістається наступному взяттю ще ДО того, як
// руки дійшли до нашої сходинки, і без звірки володіння обидва
// доставили б ту саму сходинку — два дзвінки о третій ночі й два
// рядки «надіслано» на одну сходинку.
//
// Нижче відтворено рівно цей порядок і перевірено три речі, кожна з
// яких могла б піти не так окремо.
// (1) Оренда спливла, але НІХТО не перебрав — запис має пройти.
//
// Це не дрібниця й не піддавки: якби токен вимагав ще й живого
// строку, кожна повільна партія втрачала б усі свої сходинки, і
// драбина повзла б зі швидкістю одного такту на сходинку. Токен
// доводить володіння, а володіння не зникає від того, що годинник
// пробив, поки на рядок ніхто інший не претендує.
a5 := newAlert(ruleID + ":dev:" + deviceID + ":5")
if err := st.ArmEscalation(ctx, tenantID, a5, policyID, false, policy, started); err != nil {
t.Fatal(err)
}
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
a5); err != nil {
t.Fatal(err)
}
// takeOne бере чергу й дістає з неї знімок потрібного алерту.
takeOne := func(alertID string) EscalationSnapshot {
t.Helper()
batch, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatalf("взяття черги: %v", err)
}
for _, x := range batch {
if x.AlertID == alertID {
return x
}
}
t.Fatalf("сходинка алерту %s не потрапила в чергу", alertID)
return EscalationSnapshot{}
}
// expireLease старить оренду, НЕ чіпаючи токена: саме так виглядає
// рядок, коли процес ще йде по партії, а строк уже вийшов.
expireLease := func(alertID string) {
t.Helper()
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET leased_until = now() - interval '1 second'
WHERE alert_id = $1`, alertID); err != nil {
t.Fatal(err)
}
}
// ladder читає стан драбини так, як його бачить база.
ladder := func(alertID string) (stepIdx int, token string, nextAt *time.Time) {
t.Helper()
if err := st.pool.QueryRow(ctx, `
SELECT step_idx, COALESCE(lease_token::text,''), next_at
FROM alr.alert_escalations WHERE alert_id = $1
`, alertID).Scan(&stepIdx, &token, &nextAt); err != nil {
t.Fatal(err)
}
return stepIdx, token, nextAt
}
sLate := takeOne(a5)
if sLate.LeaseToken == "" {
t.Fatal("взяття не поставило токен оренди — звіряти буде нічого")
}
expireLease(a5)
dLate := PlanEscalation(sLate, time.Now())
if dLate.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dLate.Action, dLate.Outcome)
}
okLate, err := st.ApplyEscalation(ctx, sLate, dLate)
if err != nil {
t.Fatal(err)
}
if !okLate {
t.Error("запис не пройшов, хоча оренду ніхто не перебирав — " +
"повільна партія втрачатиме всі свої сходинки")
}
// (2) Оренду ПЕРЕБРАЛИ — запізнілий запис не має пройти.
a6 := newAlert(ruleID + ":dev:" + deviceID + ":6")
if err := st.ArmEscalation(ctx, tenantID, a6, policyID, false, policy, started); err != nil {
t.Fatal(err)
}
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
a6); err != nil {
t.Fatal(err)
}
first := takeOne(a6) // перший інстанс: узяв і пішов доставляти партію
expireLease(a6) // партія затягнулась довше за оренду
second := takeOne(a6) // другий інстанс перебрав ту саму сходинку
if first.LeaseToken == second.LeaseToken {
t.Fatal("два взяття отримали однаковий токен — перевірка нічого не доводить")
}
if first.StepIdx != second.StepIdx {
t.Fatalf("другому взяттю дісталась інша сходинка (%d проти %d) — "+
"відтворено не той розрив", second.StepIdx, first.StepIdx)
}
dFirst := PlanEscalation(first, time.Now())
if dFirst.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dFirst.Action, dFirst.Outcome)
}
okFirst, err := st.ApplyEscalation(ctx, first, dFirst)
if err != nil {
t.Fatal(err)
}
if okFirst {
t.Error("запис пройшов під перебраною орендою — сходинка піде в канали вдруге")
}
// Відмова має бути БЕЗ наслідків: чужий запис не зриває ані оренду
// того, хто зараз працює, ані стан драбини. Інакше запізнілий
// інстанс, нічого не доставивши, псував би роботу тому, хто доставить.
stepAfter, tokenAfter, nextAfter := ladder(a6)
if stepAfter != first.StepIdx {
t.Errorf("невдалий запис усе-таки просунув драбину: step_idx = %d", stepAfter)
}
if tokenAfter != second.LeaseToken {
t.Errorf("невдалий запис зірвав оренду того, хто працює: токен %q", tokenAfter)
}
if nextAfter == nil {
t.Error("невдалий запис зняв час наступної сходинки — драбина осиротіла")
}
// (3) І драбина не застрягла.
//
// Найгірший розклад: другий інстанс, який перебрав сходинку, теж не
// дійшов до запису (впав, викотили нову версію). Тоді сходинка не
// доставлена НІКИМ, і єдине, що має її врятувати, — спливання
// оренди. Перевіряємо, що наступний такт бере її з тим самим
// step_idx і доводить до розв'язку.
expireLease(a6)
third := takeOne(a6)
if third.LeaseToken == second.LeaseToken || third.LeaseToken == first.LeaseToken {
t.Error("нове взяття не оновило токен — оренда перестала бути власністю")
}
if third.StepIdx != first.StepIdx {
t.Errorf("сходинку загублено: наступний такт узяв step_idx = %d замість %d",
third.StepIdx, first.StepIdx)
}
dThird := PlanEscalation(third, time.Now())
if dThird.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dThird.Action, dThird.Outcome)
}
okThird, err := st.ApplyEscalation(ctx, third, dThird)
if err != nil {
t.Fatal(err)
}
if !okThird {
t.Fatal("наступний такт не зміг записати рішення — драбина застрягла назавжди")
}
stepDone, tokenDone, nextDone := ladder(a6)
if stepDone != first.StepIdx+1 {
t.Errorf("драбина не просунулась: step_idx = %d", stepDone)
}
if tokenDone != "" {
t.Errorf("токен не знято разом з орендою: %q", tokenDone)
}
if nextDone == nil {
t.Error("у живої драбини не лишилось часу наступної сходинки")
}
// І найпізніший з усіх: перший інстанс, який усе ще тримає свій
// протухлий знімок, після цього теж нічого не запише.
okStale, err := st.ApplyEscalation(ctx, first, dFirst)
if err != nil {
t.Fatal(err)
}
if okStale {
t.Error("протухлий знімок записався після просування драбини — сходинка подвоїлась")
}
// --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ---- // --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ----
a2 := newAlert(ruleID + ":dev:" + deviceID + ":2") a2 := newAlert(ruleID + ":dev:" + deviceID + ":2")
if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil { if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil {

View file

@ -0,0 +1,93 @@
-- =====================================================================
-- NetPulse :: 0073_escalation_lease_token.sql
-- Оренда сходинки ескалації отримує токен: хто взяв, той і записує.
--
-- ЩО БУЛО НЕ ТАК
--
-- 0066 дала оренді рядка (leased_until) рівно половину роботи. Вона
-- чесно закриває вікно «взяли — ще не записали»: доки оренда не
-- спливла, другий інстанс сходинку не побачить. Але оренда — це строк,
-- а не володіння, і про друге вона не знає нічого.
--
-- Строк тут дві хвилини (store.EscalationLease), а партія — до сотні
-- сходинок (alerting.escalationBatch), і кожна доставка в ній має
-- ВЛАСНИЙ мережевий таймаут: Telegram, SMTP, вебхук у чужий контур.
-- Тобто «партія довша за оренду» — це не рідкісний збіг, а звичайний
-- вечір, коли один канал підвис.
--
-- Далі рахунок простий. Оренда спливає, поки перший інстанс ще йде по
-- партії. Другий (або наступний такт того самого процесу) бачить рядок
-- вільним, бере ту саму сходинку — з тим самим step_idx, бо перший її
-- ще не просунув, — і доставляє. Через хвилину доходить черга до неї в
-- першого, і він доставляє її вдруге. О третій ночі це два дзвінки, а в
-- журналі — два рядки «надіслано», тобто доказ перетворюється на
-- виправдання: за ним не видно, що система подзвонила двічі за одну
-- сходинку.
--
-- ApplyEscalation при цьому мав ДВІ перевірки стану (stopped_at IS NULL
-- і «алерт усе ще firing»), і обидві потрібні — але обидві про АЛЕРТ.
-- Жодна не про те, чи оренда все ще наша. Звірити було нічим: у рядку
-- лежав лише строк, і строк першого інстансу нічим не відрізнявся від
-- строку другого.
--
-- ЧОМУ ТОКЕН, А НЕ ДОВША ОРЕНДА
--
-- Спокуса очевидна: зробити оренду двадцять хвилин, і партія завжди
-- вкладеться. Це не рішення, а пересунута межа. Скільки б не було
-- поставлено, знайдеться партія довша: сотня сходинок по три канали,
-- кожен зі своїм таймаутом, — це арифметика, а не патологія. Крім
-- того, довга оренда псує протилежний випадок: процес, що впав одразу
-- після взяття, тримає сходинку заручницею рівно на цей строк, а
-- сходинка ескалації — це той, кого не розбудили.
--
-- Токен знімає обидва: строк лишається коротким (впав — сходинку
-- швидко перебирають), а подвоєння закриває звірка володіння, яка від
-- строку не залежить взагалі. Це той самий fencing-токен, що й у
-- розподілених блокуваннях: власність доводиться не тим, що годинник ще
-- не пробив, а тим, що номер збігся.
--
-- ЧОМУ ТОКЕН НА РЯДОК, А НЕ НА ПАРТІЮ
--
-- Один токен на всю партію теж закрив би подвоєння, і коду було б на
-- рядок менше. Але тоді два різні прогони, що поділили одну партію
-- (SKIP LOCKED саме для цього й стоїть), розрізнялись би лише
-- випадковістю збігу, а рядок у базі не казав би, ЯКЕ взяття його
-- тримає. core.new_id() у SET обчислюється для кожного рядка окремо —
-- ціна нульова, а стовпець стає самодостатнім: за ним видно конкретне
-- взяття конкретної сходинки.
--
-- ЩО СТАНЕТЬСЯ, КОЛИ ТОКЕН НЕ ЗБІГСЯ
--
-- Нічого не станеться — і це головна вимога до цього стовпця. UPDATE
-- не чіпає ЖОДНОГО рядка: ні next_at, ні step_idx, ні leased_until
-- (інакше запізнілий інстанс зривав би оренду того, хто зараз працює).
-- Движок бачить applied = false, у мережу нічого не йде, і сходинка
-- лишається належною — тим самим step_idx, з тим самим next_at у
-- минулому. Її доводить до розв'язку той, чий токен у рядку; якщо й він
-- не дійшов — оренда спливає за дві хвилини, і наступний такт бере
-- сходинку заново. Застрягти назавжди нема від чого: жодна гілка не
-- знімає next_at і не подовжує оренду безкінечно.
--
-- СУМІСНІСТЬ
--
-- Стовпець NULL для всіх наявних рядків, і це правильний стан: NULL
-- означає «зараз ніхто не тримає». Живі оренди на момент накату
-- (щонайбільше дві хвилини) отримають токен при наступному взятті.
-- Вимоги NOT NULL немає навмисно — вільний рядок мусить уміти сказати
-- «нічий», а не носити токен-привид від позаминулого взяття.
-- =====================================================================
ALTER TABLE alr.alert_escalations
ADD COLUMN IF NOT EXISTS lease_token uuid;
COMMENT ON COLUMN alr.alert_escalations.lease_token IS
'Токен поточної оренди (fencing). Ставиться разом із leased_until при '
'взятті сходинки, звіряється при записі рішення й знімається разом з '
'орендою. NULL — сходинку зараз ніхто не тримає. Потрібен тому, що '
'обробка партії буває довшою за оренду: без звірки володіння запізнілий '
'інстанс доставив би сходинку, яку вже доставив той, хто перебрав її '
'після спливання оренди.';
COMMENT ON COLUMN alr.alert_escalations.leased_until IS
'До якого моменту сходинка вважається взятою в роботу. Строк, а не '
'володіння: володіння доводить lease_token.';

View file

@ -12,7 +12,14 @@ interface NavItem {
to: string to: string
label: string label: string
icon: string icon: string
perm: Permission /**
* Право, без якого пункт не показується.
*
* Необов'язкове: є сторінки, які належать кожному, хто увійшов
* (власний профіль), і вигадувати для них право означало б брехати
* про модель доступу заради типу.
*/
perm?: Permission
/** Показувати лічильник алертів на цьому пункті. */ /** Показувати лічильник алертів на цьому пункті. */
badge?: boolean badge?: boolean
} }
@ -190,6 +197,27 @@ const navGroups: NavGroup[] = [
{ to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' }, { to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' },
], ],
}, },
{
// Окрема група з одного пункту — і це свідомий виняток із правила,
// записаного вище («група з одного пункту додала б заголовок,
// який нічого не групує»).
//
// Причина винятку: усі інші групи — про СИСТЕМУ, а ця про того,
// хто на неї дивиться. Заголовок тут групує саме це — і саме через
// цю різницю в роді профіль нікуди не вписався й лишився без пункту
// взагалі — рівно той випадок, про який написано вище:
// «сторінка, про існування якої знає лише той, хто її писав».
//
// Ціна була не косметична: тут живе прив'язка Telegram, без якої
// кнопки під сповіщеннями відповідають «ваш Telegram не прив'язано».
// За весь час цим не скористався ніхто — ні однієї прив'язки й жодного
// згенерованого коду, включно з власником.
//
// Без perm: профіль є в кожного, хто увійшов.
id: 'account',
title: 'Обліковий запис',
items: [{ to: '/profile', label: 'Мій профіль', icon: '👤' }],
},
] ]
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -392,7 +420,8 @@ export function AppShell() {
// Група без жодного доступного пункту зникає цілком: заголовок над // Група без жодного доступного пункту зникає цілком: заголовок над
// порожнечею обіцяє розділ, якого для цієї ролі не існує. // порожнечею обіцяє розділ, якого для цієї ролі не існує.
const groups = navGroups const groups = navGroups
.map((g) => ({ ...g, items: g.items.filter((i) => session.can(i.perm)) })) // Пункт без perm бачать усі, хто увійшов.
.map((g) => ({ ...g, items: g.items.filter((i) => !i.perm || session.can(i.perm)) }))
.filter((g) => g.items.length > 0) .filter((g) => g.items.length > 0)
const hasAny = groups.some((g) => g.items.length > 0) const hasAny = groups.some((g) => g.items.length > 0)
@ -454,15 +483,26 @@ export function AppShell() {
</NavLink> </NavLink>
)} )}
{(me?.username || me?.email) && ( {/*
Вхід у профіль малюється ЗАВЖДИ, а не лише коли є чим
підписати кнопку.
Раніше умова вимагала імені або пошти, а /me віддавало лише
пошту порожню в облікового запису власника, який заводить
установник і який входить за іменем. Тобто в типовій
інсталяції єдиний шлях у профіль не малювався взагалі, і
сторінка була досяжна лише введенням адреси вручну.
*/}
{me?.userID && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<NavLink <NavLink
to="/profile" to="/profile"
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1 className="flex max-w-[10rem] items-center gap-1 truncate rounded border
text-slate-400 hover:bg-slate-800" border-slate-700 px-2 py-1 text-slate-300 hover:bg-slate-800"
title={me.email || me.username} title="Мій профіль"
> >
{me.username || me.email} <span aria-hidden>👤</span>
<span className="truncate">{me?.username || me?.email || 'Профіль'}</span>
</NavLink> </NavLink>
<button <button
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800" className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api/client' import { api } from '../api/client'
import { session } from '../api/session' import { session } from '../api/session'
import { import {
@ -15,7 +16,7 @@ import {
inputClass, inputClass,
} from '../components/ui' } from '../components/ui'
import type { ConfirmRequest } from '../components/ui' import type { ConfirmRequest } from '../components/ui'
import type { Channel, Severity } from '../types' import type { Channel, Severity, TelegramLink } from '../types'
const kindLabel: Record<string, string> = { const kindLabel: Record<string, string> = {
telegram: 'Telegram', telegram: 'Telegram',
@ -34,6 +35,16 @@ const severityLabel: Record<Severity, string> = {
disaster: 'Лише аварії', disaster: 'Лише аварії',
} }
/**
* Як назвати прив'язаний акаунт. Той самий порядок, що й у картці
* профілю: @username, інакше ім'я, інакше числовий id щоб та сама
* прив'язка не називалась на двох сторінках по-різному.
*/
function tgName(l: TelegramLink): string {
if (l.tg_username) return `@${l.tg_username}`
return l.tg_name || String(l.tg_user_id)
}
export function ChannelsPage() { export function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([]) const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -46,8 +57,39 @@ export function ChannelsPage() {
null, null,
) )
/**
* Прив'язка Telegram того, хто зараз дивиться.
*
* `undefined` ще не з'ясовано: або вантажиться, або запит не вдався.
* Три стани, а не два, саме тому, що підказка тут другорядна: збій
* запиту про прив'язку не має перетворюватись на червону помилку над
* переліком каналів вона стосується зовсім іншого.
*/
const [tgLink, setTgLink] = useState<TelegramLink | null | undefined>(undefined)
const canWrite = session.can('alerts:write') const canWrite = session.can('alerts:write')
// Прив'язка цікавить лише там, де є кому нею скористатись. Без
// telegram-каналів це був би запит на кожне відкриття сторінки заради
// підказки, якої однаково не буде.
const hasTelegram = channels.some((c) => c.kind === 'telegram')
useEffect(() => {
if (!hasTelegram) return
let alive = true
api.getTelegramLink().then(
(l) => {
if (alive) setTgLink(l)
},
() => {
if (alive) setTgLink(undefined)
},
)
return () => {
alive = false
}
}, [hasTelegram])
const reload = async () => { const reload = async () => {
try { try {
setChannels(await api.listChannels()) setChannels(await api.listChannels())
@ -92,6 +134,40 @@ export function ChannelsPage() {
</div> </div>
)} )}
{/*
Канал і прив'язка різні речі, і сторінка каналів мовчала про
другу. Ззовні налаштування виглядало завершеним: канал є,
«Перевірити» проходить, повідомлення приходять. А кнопки під
ними особисті, і без прив'язки бот відповідає відмовою з
якої виходу тут не було написано.
Обидва стани промовляються вголос: мовчання читається як «не
налаштовано», тобто не відрізняється від справжньої проблеми.
*/}
{hasTelegram && tgLink !== undefined && (
<div className="p-4 pb-0">
{tgLink ? (
<div className="rounded border border-emerald-900/60 bg-emerald-950/40 px-3 py-2 text-xs text-emerald-200">
Ваш Telegram привязано ({tgName(tgLink)}) кнопки «Прийняти» й «Заглушити»
під сповіщеннями спрацьовують від вашого імені. Кожен, хто має ними
користуватись, привязує свій окремо.
</div>
) : (
// Не помилка й не червоне: канал справний, незавершене —
// особисте налаштування того, хто зараз дивиться.
<div className="rounded border border-amber-900/60 bg-amber-950/20 px-3 py-2 text-xs leading-relaxed text-amber-200">
Ваш Telegram не привязано: повідомлення приходитимуть, але ваші кнопки під
ними не спрацюють підтвердження алерту записується від конкретної людини.
Привязка у{' '}
<Link to="/profile" className="text-sky-400 hover:underline">
профілі
</Link>
.
</div>
)}
</div>
)}
{loading ? ( {loading ? (
<Spinner /> <Spinner />
) : ( ) : (

View file

@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { ChannelsPage } from '../pages/ChannelsPage'
import { session } from '../api/session'
import { fetchRouter } from './support'
/**
* Прив'язка Telegram, видима зі сторінки каналів.
*
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Канал Telegram заводиться тут, тут же він
* успішно проходить «Перевірити», і на цьому людина вважає налаштування
* завершеним. Але кнопки «Прийняти» / «Заглушити» під сповіщеннями
* особисті: без окремої прив'язки в профілі бот відповідає відмовою.
* Виміряний наслідок: у базі нуль прив'язок і нуль кодів за весь час
* цим не скористався НІХТО, включно з власником продукту, який дійшов
* до відмови бота й не знайшов із неї виходу.
*
* Мовчання сторінки тут не нейтральний стан, а хибне «усе готово».
* Тому перевіряються обидві половини:
*
* - **немає прив'язки** сказано про наслідок і показано, куди йти;
* - **є прив'язка** сказано, що вона є. Інакше людина, яка вже все
* зробила, щоразу питає себе, чи зробила.
*
* Третя перевірка про те, чого робити НЕ треба: без жодного
* telegram-каналу ні підказки, ні зайвого запиту на кожне відкриття
* сторінки.
*
* Форма підставних відповідей звірена з `server/API.md`
* (`GET /api/v1/channels`, `GET /api/v1/me/telegram`) і з типами
* `Channel` / `TelegramLink` у `web/src/types.ts`.
*/
const tgChannel = {
id: 'ch-tg',
kind: 'telegram',
name: 'Черговий',
config: { chat_id: '-1001234567890' },
min_severity: 'warning',
enabled: true,
has_secret: true,
}
const webhookChannel = {
id: 'ch-hook',
kind: 'webhook',
name: 'Вебхук',
config: { url: 'https://hooks.example.com/x' },
min_severity: 'warning',
enabled: true,
has_secret: false,
}
const link = {
id: 'tl-1',
user_id: 'u-me',
tg_user_id: 4242,
tg_username: 'duty_admin',
tg_name: 'Черговий',
linked_at: new Date().toISOString(),
}
function show() {
render(
<MemoryRouter>
<ChannelsPage />
</MemoryRouter>,
)
}
beforeEach(() => {
vi.stubGlobal('WebSocket', class {})
session.set('tok', {
userID: 'u-me',
username: 'me',
tenantID: 't-1',
permissions: ['alerts:read', 'alerts:write'],
})
})
describe('стан прив’язки Telegram на сторінці каналів', () => {
it('без прив’язки називає наслідок і веде в профіль', async () => {
fetchRouter({
'GET /api/v1/channels': { channels: [tgChannel] },
'GET /api/v1/me/telegram': { link: null },
})
show()
// Саме наслідок, а не «прив'яжіть Telegram»: людина не зобов'язана
// знати, що прив'язка взагалі існує, зате добре розуміє, що таке
// кнопка, яка не працює.
expect(await screen.findByText(/не спрацюють/)).toBeTruthy()
// Виходу з відмови бота не знайшли саме тому, що його ніде не було
// написано. Адреса тут — половина всієї правки.
const toProfile = screen.getByRole('link', { name: 'профілі' })
expect(toProfile.getAttribute('href')).toBe('/profile')
})
it('з прив’язкою каже про це спокійно й не лякає', async () => {
fetchRouter({
'GET /api/v1/channels': { channels: [tgChannel] },
'GET /api/v1/me/telegram': { link },
})
show()
// Мовчання читалось би як «не налаштовано» — тобто рівно так само,
// як стан без прив'язки, від якого його й треба відрізнити.
expect(await screen.findByText(/@duty_admin/)).toBeTruthy()
expect(screen.queryByText(/не спрацюють/)).toBeNull()
})
it('без telegram-каналів мовчить і не питає сервер про прив’язку', async () => {
const srv = fetchRouter({
'GET /api/v1/channels': { channels: [webhookChannel] },
// Адресу прив'язки навмисно НЕ описано: якби сторінка сходила по
// неї, тест побачив би це і як зайвий виклик, і як 404.
})
show()
await screen.findByText('Вебхук')
await waitFor(() => expect(srv.calls.length).toBeGreaterThan(0))
expect(screen.queryByText(/не спрацюють/)).toBeNull()
expect(screen.queryByText(/Telegram прив/)).toBeNull()
// Запит на кожне відкриття сторінки заради підказки, якої не буде.
expect(srv.calls.some((c) => c.url.includes('/me/telegram'))).toBe(false)
})
})

View file

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { EscalationsPage } from '../pages/EscalationsPage' import { EscalationsPage } from '../pages/EscalationsPage'
import { ChannelsPage } from '../pages/ChannelsPage' import { ChannelsPage } from '../pages/ChannelsPage'
import { session } from '../api/session' import { session } from '../api/session'
@ -117,7 +118,14 @@ describe('видалення каналу, на який спирається д
}, },
}) })
render(<ChannelsPage />) // Router — бо сторінка каналів має посилання в профіль (підказка про
// прив'язку Telegram). Тут вона не показується (тест не описав
// адресу прив'язки), але покладатись на це не варто.
render(
<MemoryRouter>
<ChannelsPage />
</MemoryRouter>,
)
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' })) fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
// Те саме, що вже показує видалення драбини: скільки чужих // Те саме, що вже показує видалення драбини: скільки чужих
@ -128,7 +136,14 @@ describe('видалення каналу, на який спирається д
it('без драбин попередження про них не вигадується', async () => { it('без драбин попередження про них не вигадується', async () => {
fetchRouter({ 'GET /api/v1/channels': { channels: [duty] } }) fetchRouter({ 'GET /api/v1/channels': { channels: [duty] } })
render(<ChannelsPage />) // Router — бо сторінка каналів має посилання в профіль (підказка про
// прив'язку Telegram). Тут вона не показується (тест не описав
// адресу прив'язки), але покладатись на це не варто.
render(
<MemoryRouter>
<ChannelsPage />
</MemoryRouter>,
)
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' })) fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
await screen.findByText(/більше не доставлятимуться/) await screen.findByText(/більше не доставлятимуться/)

View file

@ -99,7 +99,7 @@ describe('склад меню', () => {
// Розгорнутих груп рівно стільки, скільки їх узагалі є: людина, яка // Розгорнутих груп рівно стільки, скільки їх узагалі є: людина, яка
// відкрила продукт уперше, має побачити все, що їй доступне, а не // відкрила продукт уперше, має побачити все, що їй доступне, а не
// шість закритих шухляд. // шість закритих шухляд.
expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6) expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(7)
expect(screen.getByRole('link', { name: /Хости/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Хости/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument()
}) })
@ -120,7 +120,16 @@ describe('склад меню', () => {
expect(screen.queryByRole('button', { name: /^Конфігурації/ })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /^Конфігурації/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Сповіщення/ })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /^Сповіщення/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Адміністрування/ })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /^Адміністрування/ })).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(3)
// А ось «Обліковий запис» лишається ЗАВЖДИ, і це не виняток
// з правила, а його межа: власний профіль належить кожному, хто
// увійшов, і права його забрати не можуть. Це важливо саме для такого
// користувача: там живе прив'язка Telegram, без якої кнопки під
// сповіщеннями не працюють, а мережевому інженеру вони потрібні
// не менше, ніж власникові.
expect(header('Обліковий запис')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Мій профіль/ })).toBeInTheDocument()
expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(4)
}) })
}) })
@ -259,7 +268,7 @@ describe("пам'ять", () => {
fetchRouter({ 'GET /api/v1/alerts': alerts() }) fetchRouter({ 'GET /api/v1/alerts': alerts() })
shell('/devices') shell('/devices')
expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6) expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(7)
}) })
}) })
@ -269,11 +278,19 @@ describe("пам'ять", () => {
describe('активна група', () => { describe('активна група', () => {
it('розгортається сама, а решта згорнутих лишається згорнутою', async () => { it('розгортається сама, а решта згорнутих лишається згорнутою', async () => {
// Усі шість згорнуті — так виглядає пам'ять людини, яка звузила // Усі згорнуті — так виглядає пам'ять людини, яка звузила меню
// меню до заголовків. // до заголовків.
window.localStorage.setItem( window.localStorage.setItem(
GROUPS_KEY, GROUPS_KEY,
JSON.stringify(['monitoring', 'inventory', 'configs', 'collect', 'notify', 'admin']), JSON.stringify([
'monitoring',
'inventory',
'configs',
'collect',
'notify',
'admin',
'account',
]),
) )
fetchRouter({ 'GET /api/v1/alerts': alerts() }) fetchRouter({ 'GET /api/v1/alerts': alerts() })
shell('/devices') shell('/devices')