fix(alerts): защитить тест правила общим замком Telegram
This commit is contained in:
1 parent
9c1d14635a
commit
dc5594ca7c
9 files changed
+110
-61
No files matched your search
+24
-47
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {can} from "@/lib/auth"
|
||||
import {useAlertsTelegramPreview} from "@/hooks/use-alerts-telegram-preview"
|
||||
import {useAlertsConfigWrite} from "@/hooks/use-alerts-config-write"
|
||||
import {AlertsGroupForm} from "@/components/alerts-group-form"
|
||||
import {useAlertsTargets} from "@/hooks/use-alerts-targets"
|
||||
@@ -46,8 +47,6 @@ import {
|
||||
import { Flag, countryName } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {getAlerts} from "@/shared/api/alerts"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -177,11 +176,7 @@ interface AlertsMeta {
|
||||
greTunnelDetail?: AlertsGreTunnelRow[]
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return requestJson<T>(backendUrl, path, init)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function formatRelativeRu(iso: string): string {
|
||||
const t = Date.parse(iso)
|
||||
@@ -1539,7 +1534,7 @@ function AddRuleSheet({
|
||||
initialForm,
|
||||
editRuleId,
|
||||
onTestRuleTelegram,
|
||||
testTelegramDisabled, saving,saveDisabled,saveError,review,
|
||||
testTelegramDisabled, saving,saveDisabled,saveError,review,testOutcome,testError,testReview,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -1549,6 +1544,9 @@ function AddRuleSheet({
|
||||
saveDisabled:boolean
|
||||
saveError:string|null
|
||||
review:React.ReactNode
|
||||
testOutcome:string|null
|
||||
testError:string|null
|
||||
testReview:React.ReactNode
|
||||
typeTargets: Record<AlertType, string[]>
|
||||
isLive: boolean
|
||||
serversCatalog: AlertsServerRow[]
|
||||
@@ -1557,7 +1555,7 @@ function AddRuleSheet({
|
||||
initialForm: RuleFormSnapshot | null
|
||||
editRuleId: string | null
|
||||
/** Live: отправка тестового сообщения с текстом как у сохранённого правила (без записи в БД). */
|
||||
onTestRuleTelegram?: (payload: RuleTelegramTestPayload) => Promise<void>
|
||||
onTestRuleTelegram?: (payload: RuleTelegramTestPayload) => Promise<boolean>
|
||||
/** true — кнопка теста неактивна (например, бэкенд недоступен). */
|
||||
testTelegramDisabled?: boolean
|
||||
}) {
|
||||
@@ -1682,9 +1680,6 @@ function AddRuleSheet({
|
||||
cooldown: form.cooldown,
|
||||
ruleChatId: form.chatId.trim(),
|
||||
})
|
||||
toast.success("Тестовое сообщение отправлено в Telegram.")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось отправить тест")
|
||||
} finally {
|
||||
setTestTelegramBusy(false)
|
||||
}
|
||||
@@ -2194,7 +2189,7 @@ function AddRuleSheet({
|
||||
disabled={!canSave || testTelegramBusy || testTelegramDisabled||saving}
|
||||
title={
|
||||
testTelegramDisabled
|
||||
? "Тест из формы будет доступен после подключения защиты повторной отправки"
|
||||
? "Проверьте права, доступность, сохранённый токен и результат предыдущего запроса"
|
||||
: !canSave
|
||||
? "Заполните название, объект, условие и при необходимости порог"
|
||||
: undefined
|
||||
@@ -2209,6 +2204,9 @@ function AddRuleSheet({
|
||||
Тест в Telegram
|
||||
</Button>
|
||||
) : null}
|
||||
{testError?<p role="alert" className="w-full text-xs text-destructive break-words">{testError}</p>:null}
|
||||
{testOutcome?<p role="status" className="w-full text-xs break-words">{testOutcome}</p>:null}
|
||||
{testReview}
|
||||
{review}
|
||||
{saveError?<p role="alert" className="w-full text-xs text-destructive break-words">{saveError}</p>:null}
|
||||
<Button type="button" className="w-full sm:flex-1 min-h-9" onClick={()=>void handleSave()} disabled={!canSave||saveDisabled||saving}>
|
||||
@@ -2229,7 +2227,6 @@ export default function AlertsPage(){const {mode,backendUrl}=useDataSource();ret
|
||||
function AlertsContent() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [rules, setRules] = useState<AlertRule[]>(isLive?[]:INIT_RULES)
|
||||
const [groups, setGroups] = useState<AlertGroup[]>(isLive?[]:INIT_GROUPS)
|
||||
@@ -2271,6 +2268,7 @@ function AlertsContent() {
|
||||
const beginOperation=()=>{if(!pageActive.current||operationLock.current)return false;operationLock.current=true;reader.current?.abort();revision.current++;setLoadBusy(false);setOperationBusy(true);return true}
|
||||
const endOperation=()=>{operationLock.current=false;setOperationBusy(false)}
|
||||
const config=useAlertsConfigWrite(backendUrl,isLive,known&&backendStatus===true,beginOperation,endOperation)
|
||||
const preview=useAlertsTelegramPreview(backendUrl,isLive,known&&backendStatus===true&&tokenConfigured,beginOperation,endOperation)
|
||||
const rulesSaveBusy=config.busy
|
||||
const writesDisabled=!config.enabled||operationBusy||config.unknown
|
||||
const [pendingDelete,setPendingDelete]=useState<{kind:"rule"|"group";id:string}|null>(null)
|
||||
@@ -2358,34 +2356,11 @@ function AlertsContent() {
|
||||
setRules(mapApiRules(receipt.rules));setGroups(mapApiGroups(receipt.groups));return true
|
||||
},[config])
|
||||
|
||||
const testRuleTelegramFromForm = useCallback(
|
||||
async (p: RuleTelegramTestPayload) => {
|
||||
if (!isLive) return
|
||||
const threadRaw = tg.messageThreadId.trim()
|
||||
const messageThreadId =
|
||||
threadRaw && Number.isFinite(Number.parseInt(threadRaw, 10))
|
||||
? Number.parseInt(threadRaw, 10)
|
||||
: undefined
|
||||
await apiFetch("/api/alerts/telegram/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
token: tg.token.trim() || undefined,
|
||||
chatId: p.ruleChatId.trim() || tg.chatId.trim() || undefined,
|
||||
...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1
|
||||
? { messageThreadId }
|
||||
: {}),
|
||||
rulePreview: {
|
||||
name: p.name,
|
||||
targets: p.targets,
|
||||
conditionLine: p.conditionLine,
|
||||
severity: p.severity,
|
||||
cooldown: p.cooldown,
|
||||
},
|
||||
}),
|
||||
})
|
||||
},
|
||||
[apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId],
|
||||
)
|
||||
const testRuleTelegramFromForm=async(p:RuleTelegramTestPayload)=>preview.test({
|
||||
chatId:p.ruleChatId.trim()||tg.chatId.trim()||undefined,
|
||||
...(tg.messageThreadId?{messageThreadId:Number(tg.messageThreadId)}:{}),
|
||||
rulePreview:{name:p.name,targets:p.targets,conditionLine:p.conditionLine,severity:p.severity,cooldown:p.cooldown},
|
||||
})
|
||||
|
||||
const serverNamesForPresets = useMemo(() => {
|
||||
if (isLive) return meta?.servers??[]
|
||||
@@ -2498,21 +2473,21 @@ function AlertsContent() {
|
||||
|
||||
const openRuleSheetAdd = useCallback(() => {
|
||||
if(!config.enabled||operationLock.current||config.unknown)return
|
||||
setSheetEditId(null)
|
||||
preview.clear();setSheetEditId(null)
|
||||
setSheetSnap(null)
|
||||
setSheetKey((k) => k + 1)
|
||||
setSheetOpen(true)
|
||||
}, [config.enabled,config.unknown])
|
||||
}, [config.enabled,config.unknown,preview])
|
||||
|
||||
const openRuleSheetEdit = useCallback((id: string) => {
|
||||
if(!config.enabled||operationLock.current||config.unknown)return
|
||||
const r = rules.find((x) => x.id === id)
|
||||
if (!r) return
|
||||
setSheetSnap(ruleToFormSnapshot(r))
|
||||
preview.clear();setSheetSnap(ruleToFormSnapshot(r))
|
||||
setSheetEditId(id)
|
||||
setSheetKey((k) => k + 1)
|
||||
setSheetOpen(true)
|
||||
}, [rules,config.enabled,config.unknown])
|
||||
}, [rules,config.enabled,config.unknown,preview])
|
||||
|
||||
const handleRuleSheetSave = useCallback(
|
||||
async (payload: Omit<AlertRule, "id" | "lastFired" | "enabled">, editId: string | null) => {
|
||||
@@ -2849,7 +2824,9 @@ function AlertsContent() {
|
||||
initialForm={sheetSnap}
|
||||
editRuleId={sheetEditId}
|
||||
onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
|
||||
testTelegramDisabled={true}
|
||||
testTelegramDisabled={!preview.enabled||operationBusy||preview.unknown}
|
||||
testOutcome={preview.outcome} testError={preview.error}
|
||||
testReview={preview.unknown&&!preview.busy?<div className="w-full space-y-2"><p role="status" className="text-xs">Результат Telegram неизвестен. Проверьте чат перед повтором: сообщение могло отправиться. Черновик правила не записан.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!preview.enabled||operationBusy} onClick={preview.review}>Я проверил Telegram — разрешить новый запрос</Button></div>:null}
|
||||
/>
|
||||
|
||||
<AlertDialog open={pendingDelete!==null} onOpenChange={open=>{if(!open&&!operationLock.current)setPendingDelete(null)}}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect,useRef,useState} from "react"
|
||||
import {useEffect,useRef,useState,useSyncExternalStore} from "react"
|
||||
import {EyeIcon,EyeOffIcon} from "lucide-react"
|
||||
import type {PutTelegramBody} from "@mmapp/contracts/alerts"
|
||||
import {telegramPending,subscribeTelegramPending,setTelegramPending as checkpoint} from "@/lib/alerts-telegram-checkpoint"
|
||||
import {can} from "@/lib/auth"
|
||||
import {ApiClientError} from "@/shared/api/http-client"
|
||||
import {saveAlertsTelegram,testAlertsTelegram} from "@/shared/api/alerts"
|
||||
@@ -11,24 +12,21 @@ import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
|
||||
export type TelegramDraft={token:string;chatId:string;messageThreadId:string;connected:boolean}
|
||||
const pending=new Set<string>()
|
||||
const storageKey=(source:string)=>"mm:alerts-telegram-pending:"+source
|
||||
function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(storageKey(source))==="true"}catch{return pending.has(source)}}
|
||||
function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(storageKey(source),"true");else sessionStorage.removeItem(storageKey(source))}catch{/* The memory guard remains active during this browser lifetime. */}}
|
||||
function thread(value:string){if(!value.trim())return null;if(!/^\d+$/.test(value.trim()))throw Error("ID темы: укажите положительное целое число или оставьте поле пустым");const n=Number(value);if(!Number.isSafeInteger(n)||n<1||n>2147483647)throw Error("ID темы должен быть от 1 до 2147483647");return n}
|
||||
|
||||
export function AlertsTelegramForm({initial,tokenConfigured,base,live,available,onBegin,onEnd,onSaved}:{initial:TelegramDraft;tokenConfigured:boolean;base:string;live:boolean;available:boolean;onBegin:()=>boolean;onEnd:()=>void;onSaved:(value:TelegramDraft,configured:boolean)=>void}){
|
||||
const source=JSON.stringify([live,base])
|
||||
const [draft,setDraft]=useState<Partial<Pick<TelegramDraft,"token"|"chatId"|"messageThreadId">>>({})
|
||||
const [showToken,setShowToken]=useState(false),[busy,setBusy]=useState<"save"|"test"|null>(null),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[unknown,setUnknown]=useState(false),[ready,setReady]=useState(false)
|
||||
const [showToken,setShowToken]=useState(false),[busy,setBusy]=useState<"save"|"test"|null>(null),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[ready,setReady]=useState(false)
|
||||
const unknown=useSyncExternalStore(subscribeTelegramPending,()=>telegramPending(source),()=>false)
|
||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
||||
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setUnknown(unresolved(source));setReady(true)}});return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setReady(true)}});return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||
const value={...initial,...draft},allowed=can("mm:alerts:write"),enabled=ready&&live&&available&&allowed
|
||||
const dirty=Object.keys(draft).some(key=>value[key as keyof typeof draft]!==initial[key as keyof typeof draft])
|
||||
function current(){return active.current&&can("mm:alerts:write")}
|
||||
function change(key:keyof typeof draft,value:string){setDraft(previous=>({...previous,[key]:value}));setError(null);setOutcome(null)}
|
||||
async function execute(kind:"save"|"test"){
|
||||
if(!enabled||lock.current||unknown||!current())return
|
||||
if(!enabled||lock.current||unknown||telegramPending(source)||!current())return
|
||||
const payload:PutTelegramBody={}
|
||||
try{
|
||||
const messageThreadId=thread(value.messageThreadId)
|
||||
@@ -45,7 +43,7 @@ export function AlertsTelegramForm({initial,tokenConfigured,base,live,available,
|
||||
}catch(e){setError(e instanceof Error?e.message:"Проверьте параметры Telegram");return}
|
||||
if(!onBegin())return
|
||||
lock.current=true;controller.current=new AbortController();setBusy(kind);setError(null);setOutcome(null)
|
||||
checkpoint(source,true);setUnknown(true)
|
||||
checkpoint(source,true)
|
||||
try{
|
||||
if(kind==="save"){
|
||||
const result=await saveAlertsTelegram(base,payload,{signal:controller.current.signal});if(!current())return
|
||||
@@ -57,11 +55,11 @@ export function AlertsTelegramForm({initial,tokenConfigured,base,live,available,
|
||||
await testAlertsTelegram(base,{chatId:value.chatId.trim(),...(value.token.trim()?{token:value.token.trim()}:{}),...(messageThreadId!==null?{messageThreadId}:{})},{signal:controller.current.signal});if(!current())return
|
||||
setOutcome("Telegram API подтвердил приём тестового сообщения. Проверьте его в чате.")
|
||||
}
|
||||
checkpoint(source,false);setUnknown(false)
|
||||
checkpoint(source,false)
|
||||
}catch(e){
|
||||
const rejected=e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)
|
||||
if(rejected&¤t())checkpoint(source,false)
|
||||
if(current()){setUnknown(!rejected);setError(e instanceof Error?e.message:"Результат Telegram не получен")}
|
||||
if(current()){setError(e instanceof Error?e.message:"Результат Telegram не получен")}
|
||||
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(null);onEnd()}}
|
||||
}
|
||||
return <section aria-label="Параметры Telegram" className="min-w-0"><OpsPanel title="Telegram" description={initial.connected?"Параметры заполнены. Доставка проверяется отдельно.":"Параметры не заполнены."} contentClassName="flex min-w-0 flex-col gap-3 px-5 py-4">
|
||||
@@ -73,7 +71,7 @@ export function AlertsTelegramForm({initial,tokenConfigured,base,live,available,
|
||||
{error?<p role="alert" className="text-xs text-destructive break-words">{error}</p>:null}
|
||||
{outcome?<p role="status" className="text-xs break-words">{outcome}</p>:null}
|
||||
{busy?<p role="status" className="text-xs text-muted-foreground">Ожидаем подтверждение сервера…</p>:null}
|
||||
{unknown&&!busy?<div className="space-y-2"><p role="status" className="text-xs break-words">Результат запроса неизвестен. Он мог выполниться. Перед повтором проверьте параметры в БД или сообщение в Telegram; обновление данных не подтверждает отправку.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!!busy||!enabled} onClick={()=>{checkpoint(source,false);setUnknown(false)}}>Я проверил результат — разрешить новый запрос</Button></div>:null}
|
||||
{unknown&&!busy?<div className="space-y-2"><p role="status" className="text-xs break-words">Результат запроса неизвестен. Он мог выполниться. Перед повтором проверьте параметры в БД или сообщение в Telegram; обновление данных не подтверждает отправку.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!!busy||!enabled} onClick={()=>{checkpoint(source,false)}}>Я проверил результат — разрешить новый запрос</Button></div>:null}
|
||||
<Button type="button" size="sm" disabled={!enabled||!!busy||unknown||!dirty} onClick={()=>void execute("save")}>{busy==="save"?"Сохранение…":"Сохранить в БД"}</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!enabled||!!busy||unknown} onClick={()=>void execute("test")}>{busy==="test"?"Отправка…":"Отправить тестовое сообщение"}</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={!dirty||!!busy} onClick={()=>{setDraft({});setError(null);setOutcome(null)}}>Сбросить черновик</Button>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
## Актуальная точка продолжения — 2026-10-03
|
||||
|
||||
Этап 11Б в работе; последний проверенный блок 11Б.4.3.3; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.3.1: защищённый тест Telegram из редактора правила с общим checkpoint и замком; затем согласованность GET/revision/outbox, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12.
|
||||
Этап 11Б в работе; последний проверенный блок 11Б.4.3.3.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.1: согласованный read-only GET Alerts и достоверные metadata/history; затем optimistic revision/outbox audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||
|
||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.3; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11Б.4.3.3.1 — общий guard Telegram preview (2026-10-03)
|
||||
|
||||
- Rule preview снова доступен: strict testAlertsTelegram, caller abort/source lifetime, synchronous lock со всеми Alerts mutations, alerts:write/live/health/known token. Отправляется snapshot черновика без записи rule и без токена из сохранённых параметров в body.
|
||||
- Общий Telegram checkpoint/external store связывает карточку и editor: unknown не обходится через другую форму. Explicit review доступен в editor без потери draft; late ABA receipt не снимает guard. API acceptance показана inline, прежний ложный toast «отправлено» удалён.
|
||||
- 3 preview browser, 2 preview source ABA и регрессия 10 Telegram + 4 Telegram source пройдены; typecheck без incremental/lint. Первоначальный typecheck поймал оставшийся setUnknown после store extraction; исправлен, повторная проверка прошла. Реальные Telegram/PG не запускались.
|
||||
- Аудит следующего блока обнаружил в backend fabricated rttLossTargets/probeTargets из имён серверов и synthetic live history с sent:true. Их нужно исправить вместе с multi-query GET. Это пока ограничения Alerts, не принятый результат.
|
||||
- Следующий точный шаг 11Б.4.4.1: согласованный read-only GET Alerts и достоверные metadata/history; затем optimistic revision/outbox audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||
|
||||
## Блок 11Б.4.3.3 — запись правил и групп по подтверждению API (2026-10-03)
|
||||
|
||||
- Strict saveAlertRules подключён вместо generic PUT/оптимистичной подмены. Pending/failed/unknown оставляет последний подтверждённый список; editor ждёт receipt и закрывается только после success, error/unknown сохраняет draft. Исправлена потеря recoveryMode/recoveryStabilitySec при edit — форма передаёт сохранённую политику.
|
||||
|
||||
@@ -263,3 +263,8 @@ Dirty/selective save, strict receipt, общий lock, bounded source lifetime,
|
||||
### 11Б.4.3.3 — правила и группы (2026-10-03)
|
||||
|
||||
Strict receipt вместо optimistic overwrite, draft editor/group формы, права/common lock/source/unknown, подтверждение delete и сохранение recovery policy. 8 browser + 2 source, Telegram regression, typecheck/lint/build пройдены. Preview Telegram временно disabled; picker и backend consistency ещё не приняты. Далее 11Б.4.3.3.1: защищённый тест Telegram из редактора правила с общим checkpoint и замком; затем согласованность GET/revision/outbox, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12.
|
||||
|
||||
|
||||
### 11Б.4.3.3.1 — Telegram preview (2026-10-03)
|
||||
|
||||
Strict preview и общий checkpoint карточки/editor без секретов, common lock/source/rights, inline API acceptance. 3 browser + 2 source, Telegram regression, typecheck/lint прошли. Доставка и реальные PG/Telegram не приняты. Далее 11Б.4.4.1: согласованный read-only GET Alerts и достоверные metadata/history; затем optimistic revision/outbox audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
import {useEffect,useRef,useState,useSyncExternalStore} from "react"
|
||||
import {testTelegramSchema,type TestTelegramBody} from "@mmapp/contracts/alerts"
|
||||
import {can} from "@/lib/auth"
|
||||
import {telegramPending,subscribeTelegramPending,setTelegramPending} from "@/lib/alerts-telegram-checkpoint"
|
||||
import {ApiClientError} from "@/shared/api/http-client"
|
||||
import {testAlertsTelegram} from "@/shared/api/alerts"
|
||||
|
||||
export function useAlertsTelegramPreview(base:string,live:boolean,available:boolean,onBegin:()=>boolean,onEnd:()=>void){
|
||||
const source=JSON.stringify([live,base])
|
||||
const unknown=useSyncExternalStore(subscribeTelegramPending,()=>telegramPending(source),()=>false)
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null)
|
||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
||||
useEffect(()=>{active.current=true;return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||
const enabled=live&&available&&can("mm:alerts:write")
|
||||
async function test(payload:TestTelegramBody){
|
||||
if(!enabled||lock.current||telegramPending(source)||!active.current||!can("mm:alerts:write"))return false
|
||||
const parsed=testTelegramSchema.strict().safeParse(payload)
|
||||
if(!parsed.success){setError("Проверьте параметры тестового сообщения");return false}
|
||||
if(!onBegin())return false
|
||||
lock.current=true;controller.current=new AbortController();setBusy(true);setError(null);setOutcome(null);setTelegramPending(source,true)
|
||||
const current=()=>active.current&&can("mm:alerts:write")
|
||||
try{
|
||||
await testAlertsTelegram(base,parsed.data,{signal:controller.current.signal});if(!current())return false
|
||||
setTelegramPending(source,false);setOutcome("Telegram API подтвердил приём тестового правила. Проверьте его в чате.");return true
|
||||
}catch(e){
|
||||
if(e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)&¤t())setTelegramPending(source,false)
|
||||
if(current())setError(e instanceof Error?e.message:"Результат отправки не получен");return false
|
||||
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(false);onEnd()}}
|
||||
}
|
||||
function review(){if(!enabled||busy)return;setTelegramPending(source,false)}
|
||||
function clear(){setError(null);setOutcome(null)}
|
||||
return {test,busy,error,outcome,unknown,enabled,review,clear}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Shared between Telegram settings and rule preview; never stores credentials.
|
||||
const pending=new Set<string>(),listeners=new Set<()=>void>()
|
||||
const key=(source:string)=>"mm:alerts-telegram-pending:"+source
|
||||
export function telegramPending(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}}
|
||||
export function subscribeTelegramPending(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener)}}
|
||||
export function setTelegramPending(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* Memory guard survives section navigation. */}for(const listener of listeners)listener()}
|
||||
@@ -31,3 +31,10 @@ for(const failed of [false,true])test("Rules source: ABA ignores old save and re
|
||||
const toggle=()=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true})
|
||||
try{await page.goto("/alerts");await expect(toggle()).toBeEnabled();await toggle().click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(toggle()).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText(/Результат сохранения правил неизвестен/)).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(toggle()).toBeChecked();await expect(toggle()).toBeDisabled();await expect(page.getByText("Правила и группы сохранены в БД",{exact:true})).toHaveCount(0);await expect(page.getByText("OLD RULES FAILED",{exact:true})).toHaveCount(0);expect(calls).toBe(1)}finally{release()}
|
||||
})
|
||||
|
||||
for(const failed of [false,true])test("Preview source: late ABA receipt cannot clear Telegram checkpoint/"+failed,async({page})=>{
|
||||
await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...alertsWithRule,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
|
||||
let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
|
||||
await page.route("http://localhost:8000/api/alerts/telegram/test",async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD PREVIEW FAILED"}}:{headers,json:{ok:true}})}catch{}finally{completed=true}})
|
||||
try{await page.goto("/alerts");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await source(page,"http://localhost:8000");await page.goto("/alerts");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await expect(page.getByRole("dialog")).toContainText("Результат Telegram неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();await expect(page.getByRole("dialog")).not.toContainText("API подтвердил приём");await expect(page.getByRole("dialog")).not.toContainText("OLD PREVIEW FAILED");expect(calls).toBe(1)}finally{release()}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import {test,expect,type Page,type Route} from "@playwright/test"
|
||||
import {alertsWithRule} from "./fixtures/alerts"
|
||||
const telegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9}
|
||||
async function setup(page:Page,send:(route:Route)=>Promise<unknown>){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill({json:{...alertsWithRule,telegram}});if(path==="/api/alerts/telegram/test")return send(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true})).toBeEnabled();await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click()}
|
||||
test("Rule preview: strict acceptance keeps unsaved form and excludes concurrent save",async({page})=>{
|
||||
let calls=0,payload:unknown,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
|
||||
try{await setup(page,async r=>{calls++;payload=r.request().postDataJSON();await gate;return r.fulfill({json:{ok:true}})});const dialog=page.getByRole("dialog");await dialog.getByRole("textbox",{name:"Название правила",exact:true}).fill("Unsaved preview");await dialog.getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect.poll(()=>calls).toBe(1);await expect(dialog.getByRole("button",{name:"Сохранить изменения",exact:true})).toBeDisabled();await expect(dialog.getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();release();await expect(dialog).toContainText("Telegram API подтвердил приём тестового правила. Проверьте его в чате.");await expect(dialog.getByRole("textbox",{name:"Название правила",exact:true})).toHaveValue("Unsaved preview");expect(payload).toMatchObject({chatId:telegram.chatId,messageThreadId:9,rulePreview:{name:"Unsaved preview",targets:["router-fixture"],conditionLine:"перешёл в offline"}});expect(payload).not.toHaveProperty("token");expect(calls).toBe(1)}finally{release()}
|
||||
})
|
||||
test("Rule preview: unknown send protects both preview and Telegram form until explicit review",async({page})=>{
|
||||
let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:{ok:false}})});const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждена");await expect(dialog.getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Отмена",exact:true}).click();const form=page.getByRole("region",{name:"Параметры Telegram",exact:true});await expect(form).toContainText("Результат запроса неизвестен");await expect(form.getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();await page.getByRole("dialog").getByRole("button",{name:"Я проверил Telegram — разрешить новый запрос",exact:true}).click();await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeEnabled();expect(calls).toBe(1)
|
||||
})
|
||||
test("Rule preview: Telegram form unknown send cannot be bypassed through editor",async({page})=>{
|
||||
let calls=0;await setup(page,r=>{calls++;return r.fulfill({status:502,json:{error:"Fixture upstream unknown"}})});await page.getByRole("dialog").getByRole("button",{name:"Отмена",exact:true}).click();const form=page.getByRole("region",{name:"Параметры Telegram",exact:true});await form.getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form.getByRole("alert")).toHaveText("Fixture upstream unknown");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();expect(calls).toBe(1)
|
||||
})
|
||||
Reference in new issue
Block a user