fix(alerts): сохранять параметры выборочно и проверять результат отправки

This commit is contained in:
Denozordec committed 2026-10-03 17:06:44 +07:00
1 parent 0bcac655ea
commit 98319c9d50
11 files changed
+90 -58

No files matched your search

+2 -1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc",
"test:alerts-telegram": "tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts",
"test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
@@ -27,7 +28,7 @@
"test:scheduler": "tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database",
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
+2 -1
View File
@@ -72,10 +72,11 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.put("/alerts/telegram", async (req, reply) => {
const parsed = putTelegramSchema.safeParse(req.body)
const parsed = putTelegramSchema.strict().safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
if (Object.keys(parsed.data).length === 0) return reply.status(400).send({error:"Нет изменённых параметров Telegram"})
const next = await updateTelegramSettings({
token: parsed.data.token,
chatId: parsed.data.chatId,
+15 -51
View File
@@ -1,3 +1,5 @@
import {sendTelegramRequest} from "./alerts-telegram-send.js"
import {executeTelegramSettingsUpdate} from "./alerts-telegram-settings.js"
import { asc, desc, eq, sql } from "drizzle-orm"
import { db, dbAll } from "../db/index.js"
import {
@@ -58,7 +60,7 @@ export type ApiAlertHistoryEntry = {
async function ensureTelegramRow() {
let row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
if (!row) {
await db.insert(alertTelegramSettings).values({ id: 1 })
await db.insert(alertTelegramSettings).values({ id: 1 }).onConflictDoNothing({target:alertTelegramSettings.id})
row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
}
return row!
@@ -309,37 +311,13 @@ export async function updateTelegramSettings(patch: {
/** undefined — не менять; null — сбросить тему */
messageThreadId?: number | null
}) {
const cur = await ensureTelegramRow()
let nextToken = cur.botToken
let nextChat = cur.chatId ?? ""
let nextThread: number | null | undefined = undefined
if (patch.token !== undefined) {
if (patch.token === null || patch.token === "") nextToken = ""
else nextToken = patch.token.trim()
}
if (patch.chatId !== undefined) nextChat = patch.chatId.trim()
if (patch.messageThreadId !== undefined) {
if (patch.messageThreadId === null) nextThread = null
else {
const n = Math.floor(patch.messageThreadId)
nextThread = Number.isFinite(n) && n >= 1 ? n : null
}
}
const base = {
botToken: nextToken,
chatId: nextChat,
updatedAt: sql`now()`,
}
if (nextThread !== undefined) {
await db.update(alertTelegramSettings)
.set({ ...base, messageThreadId: nextThread })
.where(eq(alertTelegramSettings.id, 1))
} else {
await db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1))
}
return await getTelegramPublic()
return executeTelegramSettingsUpdate(patch, {
ensure: ensureTelegramRow,
update: async values => {
const [row] = await db.update(alertTelegramSettings).set({...values, updatedAt:sql`now()`}).where(eq(alertTelegramSettings.id,1)).returning()
return row
},
})
}
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
@@ -395,26 +373,12 @@ export async function sendTelegramAlertMessage(opts: {
? Math.floor(opts.messageThreadId)
: undefined
: await getTelegramMessageThreadIdForApi()
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
try {
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
chat_id: chatId,
text: opts.text,
}
if (threadId != null) payload.message_thread_id = threadId
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
if (!res.ok || data.ok === false) {
return { ok: false, error: data.description ?? res.statusText ?? "Telegram API error" }
}
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Ошибка сети" }
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
chat_id: chatId,
text: opts.text,
}
if (threadId != null) payload.message_thread_id = threadId
return sendTelegramRequest(token,payload)
}
export async function appendAlertHistory(entry: {
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {sendTelegramRequest} from "./alerts-telegram-send.js"
test("Telegram send requires HTTP success and literal true receipt; malformed body is never success",async()=>{
for(const body of [{},{ok:false},{ok:"true"},null]){let calls=0;const result=await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>{calls++;return Response.json(body)});assert.equal(result.ok,false);assert.equal(calls,1)}
assert.equal((await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>new Response("html"))).ok,false)
assert.equal((await sendTelegramRequest("fixture-secret",{chat_id:"123",text:"fixture"},async()=>Response.json({ok:true},{status:503}))).ok,false)
})
test("Telegram send uses bounded single POST and preserves confirmed result",async()=>{
let calls=0;const result=await sendTelegramRequest("fixture:secret",{chat_id:"123",text:"fixture",message_thread_id:1},async(url,init)=>{calls++;assert.equal(url,"https://api.telegram.org/botfixture%3Asecret/sendMessage");assert.equal(init?.method,"POST");assert.ok(init?.signal);assert.deepEqual(JSON.parse(init?.body as string),{chat_id:"123",text:"fixture",message_thread_id:1});return Response.json({ok:true})});assert.deepEqual(result,{ok:true});assert.equal(calls,1)
})
test("Telegram network unknown has no retry or secret; upstream error is redacted",async()=>{
let calls=0;assert.deepEqual(await sendTelegramRequest("fixture:secret",{chat_id:"123",text:"fixture"},async()=>{calls++;throw Error("fixture:secret network URL")}),{ok:false,error:"Результат отправки в Telegram неизвестен: ответ не получен"});assert.equal(calls,1)
const result=await sendTelegramRequest("fixture:secret",{chat_id:"123",text:"fixture"},async()=>Response.json({ok:false,description:"fixture:secret fixture%3Asecret"}));assert.equal(result.ok,false);if(!result.ok)assert.equal(result.error,"[TOKEN] [TOKEN]")
})
@@ -0,0 +1,11 @@
type Payload={chat_id:string;text:string;message_thread_id?:number}
export async function sendTelegramRequest(token:string,payload:Payload,fetcher:typeof fetch=fetch):Promise<{ok:true}|{ok:false;error:string}>{
const redact=(message:string)=>message.replaceAll(token,"[TOKEN]").replaceAll(encodeURIComponent(token),"[TOKEN]")
try{
const response=await fetcher("https://api.telegram.org/bot"+encodeURIComponent(token)+"/sendMessage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload),signal:AbortSignal.timeout(30_000)})
const raw:unknown=await response.json().catch(()=>undefined)
if(response.ok&&typeof raw==="object"&&raw!==null&&"ok" in raw&&raw.ok===true)return {ok:true}
const description=typeof raw==="object"&&raw!==null&&"description" in raw&&typeof raw.description==="string"?raw.description:"Telegram не подтвердил приём сообщения"
return {ok:false,error:redact(description)}
}catch{return {ok:false,error:"Результат отправки в Telegram неизвестен: ответ не получен"}}
}
@@ -0,0 +1,9 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {executeTelegramSettingsUpdate,type TelegramStoredPatch} from "./alerts-telegram-settings.js"
function fixture(){let row={botToken:"fixture-secret",chatId:"old",messageThreadId:10 as number|null};const writes:TelegramStoredPatch[]=[];return {writes,get:()=>row,set:(next:typeof row)=>row=next,deps:{ensure:async()=>{},update:async(patch:TelegramStoredPatch)=>{writes.push(patch);row={...row,...patch};return {...row}}}}
}
test("Telegram selective update changes only submitted fields and does not expose secret",async()=>{const f=fixture();const publicFields=await executeTelegramSettingsUpdate({chatId:" new "},f.deps);assert.deepEqual(f.writes,[{chatId:"new"}]);assert.deepEqual(f.get(),{botToken:"fixture-secret",chatId:"new",messageThreadId:10});assert.deepEqual(publicFields,{chatId:"new",tokenConfigured:true,messageThreadId:10});assert.equal(JSON.stringify(publicFields).includes("fixture-secret"),false)})
test("Telegram disjoint concurrent patches preserve token/chat/thread without stale full-row writes",async()=>{const f=fixture();await Promise.all([executeTelegramSettingsUpdate({token:null},f.deps),executeTelegramSettingsUpdate({messageThreadId:null},f.deps),executeTelegramSettingsUpdate({chatId:"new"},f.deps)]);assert.deepEqual(f.get(),{botToken:"",chatId:"new",messageThreadId:null});assert.equal(f.writes.length,3);assert.ok(f.writes.every(row=>Object.keys(row).length===1))})
test("Telegram invalid/empty patch does not ensure or update",async()=>{let calls=0;const deps={ensure:async()=>{calls++},update:async()=>{calls++;return undefined}};for(const patch of [{},{messageThreadId:0},{messageThreadId:1.5},{messageThreadId:2147483648},{token:4}])await assert.rejects(executeTelegramSettingsUpdate(patch as never,deps),/Проверьте/);assert.equal(calls,0)})
test("Telegram missing/mismatched returning row does not confirm save or retry",async()=>{for(const row of [undefined,{botToken:"secret",chatId:"old",messageThreadId:null}]){let calls=0;await assert.rejects(executeTelegramSettingsUpdate({chatId:"new"},{ensure:async()=>{},update:async()=>{calls++;return row}}),/не подтверждено/);assert.equal(calls,1)}})
@@ -0,0 +1,20 @@
import {putTelegramSchema,telegramPublicSchema,type PutTelegramBody} from "@mmapp/contracts/alerts"
export type TelegramStoredRow={botToken:string;chatId:string;messageThreadId:number|null}
export type TelegramStoredPatch=Partial<TelegramStoredRow>
interface Dependencies {ensure:()=>Promise<unknown>;update:(patch:TelegramStoredPatch)=>Promise<TelegramStoredRow|undefined>}
export async function executeTelegramSettingsUpdate(input:PutTelegramBody,deps:Dependencies){
const parsed=putTelegramSchema.strict().safeParse(input)
if(!parsed.success||Object.keys(parsed.data).length===0)throw Error("Проверьте изменённые параметры Telegram")
const patch:TelegramStoredPatch={}
if(parsed.data.token!==undefined)patch.botToken=parsed.data.token?.trim()??""
if(parsed.data.chatId!==undefined)patch.chatId=parsed.data.chatId.trim()
if(parsed.data.messageThreadId!==undefined)patch.messageThreadId=parsed.data.messageThreadId
if(Object.keys(patch).length===0)throw Error("Нет изменённых параметров Telegram")
await deps.ensure()
const row=await deps.update(patch)
if(!row||Object.entries(patch).some(([key,value])=>row[key as keyof TelegramStoredRow]!==value))throw Error("Сохранение параметров Telegram не подтверждено")
const result=telegramPublicSchema.safeParse({chatId:row.chatId,tokenConfigured:Boolean(row.botToken.trim()),messageThreadId:row.messageThreadId,connected:Boolean(row.botToken.trim()&&row.chatId.trim())})
if(!result.success)throw Error("Сохранённые параметры Telegram не подтверждены")
const {connected:_connected,...publicFields}=result.data
return publicFields
}
+3 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
Этап 11Б в работе; последний проверенный блок 11Б.4.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.2.1: backend Telegram selective atomic save/RETURNING, DI HTTP/JWT/send receipts; затем rules concurrency и Alerts UI. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Этап 11Б в работе; последний проверенный блок 11Б.4.2.1.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.2.1.2: DI Alerts HTTP/JWT read/write/save/test и согласованный источник Telegram; затем rules concurrency и Alerts UI. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
@@ -306,3 +306,5 @@ REST запрос → ожидание → подтверждённый output /
2026-10-03: 11Б.3.6.2.3.2 — 6 source и 18 соседних E2E прошли; база принята в локальных границах. Далее 11Б.4.1 Alerts contracts/API.
2026-10-03: 11Б.4.1 — строгие Alerts output schemas/API и сверка save receipts; 5 unit/builds/typecheck/lint прошли. Module пока не подключён. Далее 11Б.4.2.1 backend Telegram.
2026-10-03: 11Б.4.2.1.1 — selective Telegram UPDATE RETURNING, strict send receipt; 7 DI/send tests/build/lint прошли. confirm-stability пропущен без PG. Далее HTTP/JWT 11Б.4.2.1.2.
+8 -1
View File
@@ -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.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.2.1.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,13 @@
## Блок 11Б.4.2.1.1 — selective Telegram save и подтверждение отправки (2026-10-03)
- updateTelegramSettings больше не перезаписывает token/chat из предварительно прочитанного snapshot: строит только переданные columns, SQL UPDATE RETURNING подтверждает запись и сохраняет остальные поля. Singleton insert conflict-safe. DI helper проверяет patch/returning, public response не возвращает secret. Route отклоняет unknown/empty patch до update; thread ID ограничен положительным PostgreSQL int4 вместо silent floor/overflow.
- Send helper требует HTTP success и literal ok:true, HTML/{}/false/строка true не подтверждают приём Telegram. Один POST, 30s signal, без повтора внутри helper. При отсутствии ответа результат неизвестен; raw network error не раскрывается, upstream description редактирует literal/URL-encoded token. Подтверждение приёма API не является доказательством доставки получателю; существующая outbox retry policy не изменялась и ещё требует отдельного аудита unknown результатов.
- 4 DI save + 3 send-response tests, backend build/профильный lint прошли; 5 frontend API unit из предыдущего блока сохраняют совместимость. Alert-engine groups/evaluate tests прошли, confirm-stability пропущен из-за отсутствия PostgreSQL — не считать проверенным. Реальные DB concurrency/Telegram не запускались.
- Следующий блок 11Б.4.2.1.2: DI реальных Alerts HTTP handlers + signed JWT read/write gates и save/test receipts, согласованный источник Telegram для test. Затем rules transaction/readback concurrency и UI known/drafts/source/rights/adaptive. UI пока использует прежний generic API.
## Блок 11Б.4.1 — строгие Alerts contracts/API (2026-10-03)
- Аудит существующего UI: GET/PUT/POST принимают unchecked JSON; load заменяет Telegram draft и маскирует вторичные каталоги пустыми, save error вызывает load; целевая metadata иногда подменяется demo значениями. UI/backend пока не мигрированы этим блоком и не приняты.
+2
View File
@@ -236,3 +236,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
Завершён 11Б.3.6.2.3.2: 6 source/ABA/reload и 18 General/DB E2E прошли, DB принят локально. Реальный PG/UDP worker/Portal не принят. Далее 11Б.4.1 — strict Alerts contracts/API, затем backend/UI.
Завершён 11Б.4.1: строгие Alerts output/API/receipt сверки, 5 unit/contracts/backend build/typecheck/lint прошли. UI/backend пока прежние. Далее 11Б.4.2.1 — atomic selective Telegram save/RETURNING и DI HTTP/JWT/test results; затем rules/UI.
Завершён 11Б.4.2.1.1: selective Telegram UPDATE RETURNING/partial concurrent patches, singleton conflict-safe и strict bounded send receipt; 7 tests/build/lint прошли. Groups/evaluate regression прошла, confirm-stability skip без PG. Далее 11Б.4.2.1.2 — Alerts real HTTP/JWT/save/test и согласованный источник; UI прежний.
+3 -3
View File
@@ -58,7 +58,7 @@ export const putRulesSchema = z.object({
export const putTelegramSchema = z.object({
token: z.union([z.string(), z.null()]).optional(),
chatId: z.string().optional(),
messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(),
messageThreadId: z.union([z.number().int().positive().max(2147483647), z.null()]).optional(),
})
export const rulePreviewTelegramSchema = z.object({
@@ -72,7 +72,7 @@ export const rulePreviewTelegramSchema = z.object({
export const testTelegramSchema = z.object({
token: z.string().optional(),
chatId: z.string().optional(),
messageThreadId: z.coerce.number().int().positive().optional(),
messageThreadId: z.coerce.number().int().positive().max(2147483647).optional(),
rulePreview: rulePreviewTelegramSchema.optional(),
})
@@ -86,7 +86,7 @@ export type TestTelegramBody = z.infer<typeof testTelegramSchema>
// Public response schemas: configured credentials do not prove Telegram connectivity/delivery.
const alertTimestampSchema = z.string().min(1).refine(value => Number.isFinite(Date.parse(value)), "Некорректное время оповещения")
export const telegramPublicSchema = z.object({
chatId:z.string(),tokenConfigured:z.boolean(),connected:z.boolean(),messageThreadId:z.number().int().positive().nullable(),
chatId:z.string(),tokenConfigured:z.boolean(),connected:z.boolean(),messageThreadId:z.number().int().positive().max(2147483647).nullable(),
}).strict().refine(value => value.connected === (value.tokenConfigured && Boolean(value.chatId.trim())), "Противоречивое состояние параметров Telegram")
export const alertRuleResponseSchema = z.object({
id:z.string().min(1),name:z.string().min(1),type:alertTypeSchema,target:z.string(),targets:z.array(z.string().min(1)),groupId:z.string().nullable(),condition:z.string(),conditions:z.array(z.string().min(1)),severity:alertSeveritySchema,enabled:z.boolean(),cooldown:alertCooldownSchema,