diff --git a/backend/package.json b/backend/package.json index 07f2970f..fe33c409 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +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:alerts-telegram": "tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.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", diff --git a/backend/src/plugins/auth.alerts.test.ts b/backend/src/plugins/auth.alerts.test.ts new file mode 100644 index 00000000..0535a4bb --- /dev/null +++ b/backend/src/plugins/auth.alerts.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {SignJWT} from "jose" +process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="evolution-fixture-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test" +const {default:authPlugin,requireAuth}=await import("./auth.js") +const {createAlertsRoutes,defaultAlertsDependencies}=await import("../routes/alerts.js") +const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth) +let writes=0,sends=0,reads=0 +await app.register(createAlertsRoutes({...defaultAlertsDependencies,telegram:async()=>({chatId:"123",tokenConfigured:true,messageThreadId:null}),groups:async()=>[],rules:async()=>[],history:async()=>[],meta:async()=>({servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}),writeTelegram:async()=>{writes++;return {chatId:"123",tokenConfigured:true,messageThreadId:null}},readTelegram:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:null}},send:async()=>{sends++;return {ok:true}},replace:async()=>{writes++}}),{prefix:"/api"}) +for(const permission of ["mm:alerts:read","mm:alerts:write","mm:alerts:admin","mm:settings:admin"]){ + const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret")) + for(const [method,url] of [["GET","/api/alerts"],["PUT","/api/alerts/telegram"],["POST","/api/alerts/telegram/test"],["PUT","/api/alerts/rules"]] as const){const permitted=permission==="mm:alerts:write"||permission==="mm:alerts:admin"||method==="GET"&&permission==="mm:alerts:read";const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:url.endsWith("rules")?{rules:[],groups:[]}:{chatId:"123"}}:{})});assert.equal(response.statusCode,permitted?200:403);assert.equal(response.body.includes("fixture-only"),false)} +} +assert.equal(writes,4);assert.equal(sends,2);assert.equal(reads,2) +for(const [method,url] of [["GET","/api/alerts"],["POST","/api/alerts/telegram/test"]] as const)assert.equal((await app.inject({method,url})).statusCode,401) +assert.equal(sends,2);await app.close();console.log("Alerts JWT: real read/write/admin gates before save/send/read, no token echo") diff --git a/backend/src/routes/alerts.test.ts b/backend/src/routes/alerts.test.ts new file mode 100644 index 00000000..2e88c8ed --- /dev/null +++ b/backend/src/routes/alerts.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {createAlertsRoutes,defaultAlertsDependencies} from "./alerts.js" +import {executeTelegramSettingsUpdate} from "../services/alerts-telegram-settings.js" +let stored={botToken:"fixture-secret",chatId:"123",messageThreadId:10 as number|null},reads=0,writes=0,sends=0,failSend=false,failWrite=false +const app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,meta:async()=>({servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}),telegram:async()=>({chatId:stored.chatId,tokenConfigured:!!stored.botToken,messageThreadId:stored.messageThreadId}),groups:async()=>[],rules:async()=>[],history:async()=>[],replace:async()=>{},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"}) +const test=()=>app.inject({method:"POST",url:"/api/alerts/telegram/test",payload:{}}) +let response=await app.inject({method:"GET",url:"/api/alerts"});assert.equal(response.statusCode,200);assert.equal(response.body.includes("fixture-secret"),false);assert.deepEqual(response.json().rules,[]) +response=await test();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{ok:true});assert.equal(reads,1);assert.equal(sends,1) +failSend=true;response=await test();assert.equal(response.statusCode,502);assert.equal(response.json().ok,false);assert.equal(reads,2);assert.equal(sends,2) +for(const payload of [{messageThreadId:0},{messageThreadId:1.2},{messageThreadId:true},{messageThreadId:"1"},{messageThreadId:2147483648},{unknown:true}]){response=await app.inject({method:"POST",url:"/api/alerts/telegram/test",payload});assert.equal(response.statusCode,400)}assert.equal(sends,2);assert.equal(reads,2) +for(const payload of [{},{token:4},{messageThreadId:1.5},{messageThreadId:2147483648},{unknown:true}]){response=await app.inject({method:"PUT",url:"/api/alerts/telegram",payload});assert.equal(response.statusCode,400)}assert.equal(writes,0) +response=await app.inject({method:"PUT",url:"/api/alerts/telegram",payload:{chatId:" new "}});assert.equal(response.statusCode,200);assert.equal(response.json().telegram.chatId,"new");assert.equal(stored.botToken,"fixture-secret");assert.equal(stored.messageThreadId,10);assert.equal(response.body.includes("fixture-secret"),false);assert.equal(writes,1) +failWrite=true;response=await app.inject({method:"PUT",url:"/api/alerts/telegram",payload:{chatId:"other"}});assert.equal(response.statusCode,500);assert.equal(response.json().ok,undefined);assert.equal(writes,2) +stored={...stored,botToken:""};response=await test();assert.equal(response.statusCode,400);assert.equal(sends,2) +await app.close();console.log("Alerts HTTP: one stored test snapshot, strict numeric thread, selective save, unknown send/no retry, missing returning/no success, no token echo") diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts index 4874483e..7dd1ce0a 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -1,33 +1,38 @@ +import {sendTelegramRequest} from "../services/alerts-telegram-send.js" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { getAlertsMeta, - getTelegramBotToken, - getTelegramMessageThreadIdForApi, getTelegramPublic, + getTelegramSettingsRow, listAlertGroups, listAlertRules, listMergedHistory, replaceAlertsConfig, formatAlertRuleTestTelegramText, - sendTelegramAlertMessage, updateTelegramSettings, } from "../services/alerts-service.js" import { putRulesSchema, putTelegramSchema, testTelegramSchema, + telegramSettingsReceiptSchema, } from "@mmapp/contracts/alerts" -const alertsRoutes: FastifyPluginAsyncZod = async (app) => { +interface Dependencies { + meta:typeof getAlertsMeta;telegram:typeof getTelegramPublic;groups:typeof listAlertGroups;rules:typeof listAlertRules;history:typeof listMergedHistory;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings + readTelegram:()=>Promise<{botToken:string;chatId:string;messageThreadId:number|null}> + send:(input:{token:string;chatId:string;text:string;messageThreadId?:number})=>Promise<{ok:true}|{ok:false;error:string}> +} +export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {return async(app)=>{ app.get("/alerts", async (_req, reply) => { - const tg = await getTelegramPublic() + const tg = await deps.telegram() const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim()) return reply.send({ telegram: { ...tg, connected }, - groups: await listAlertGroups(), - rules: await listAlertRules(), - history: await listMergedHistory(), - meta: await getAlertsMeta(), + groups: await deps.groups(), + rules: await deps.rules(), + history: await deps.history(), + meta: await deps.meta(), }) }) @@ -38,7 +43,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => { } const groups = parsed.data.groups ?? - (await listAlertGroups()).map((g) => ({ + (await deps.groups()).map((g) => ({ id: g.id, name: g.name, combineMode: g.combineMode, @@ -67,8 +72,8 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => { recoveryStabilitySec: r.recoveryStabilitySec ?? null, chatId: r.chatId, })) - await replaceAlertsConfig({ groups, rules }) - return reply.send({ ok: true, groups: await listAlertGroups(), rules: await listAlertRules() }) + await deps.replace({ groups, rules }) + return reply.send({ ok: true, groups: await deps.groups(), rules: await deps.rules() }) }) app.put("/alerts/telegram", async (req, reply) => { @@ -77,48 +82,45 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => { 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({ + const next = await deps.writeTelegram({ token: parsed.data.token, chatId: parsed.data.chatId, messageThreadId: parsed.data.messageThreadId, }) const connected = next.tokenConfigured && Boolean(next.chatId?.trim()) - return reply.send({ ok: true, telegram: { ...next, connected } }) + const receipt = telegramSettingsReceiptSchema.safeParse({ ok: true, telegram: { ...next, connected } }) + if (!receipt.success) return reply.status(500).send({error:"Сохранение параметров Telegram не подтверждено"}) + return reply.send(receipt.data) }) app.post("/alerts/telegram/test", async (req, reply) => { - const parsed = testTelegramSchema.safeParse(req.body ?? {}) + const parsed = testTelegramSchema.strict().safeParse(req.body ?? {}) if (!parsed.success) { return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) } - const fromDb = await getTelegramBotToken() - const token = (parsed.data.token?.trim() || fromDb).trim() - const pub = await getTelegramPublic() - const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim() - if (!token) { - return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" }) - } - if (!chatId) { - return reply.status(400).send({ error: "Не задан Chat ID" }) - } - const threadId = - parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1 - ? parsed.data.messageThreadId - : await getTelegramMessageThreadIdForApi() + const stored = await deps.readTelegram() + const token = (parsed.data.token?.trim() || stored.botToken).trim() + const chatId = (parsed.data.chatId?.trim() || stored.chatId || "").trim() + if (!token) return reply.status(400).send({error:"Не задан Bot Token (сохраните в БД или передайте в запросе)"}) + if (!chatId) return reply.status(400).send({error:"Не задан Chat ID"}) + const threadId = parsed.data.messageThreadId ?? stored.messageThreadId ?? undefined + if (threadId !== undefined && (!Number.isInteger(threadId) || threadId < 1 || threadId > 2147483647)) return reply.status(400).send({error:"ID темы Telegram не подтверждён"}) const text = parsed.data.rulePreview ? formatAlertRuleTestTelegramText(parsed.data.rulePreview) : "MikroTik Manager — тест оповещений (Telegram)." - const send = await sendTelegramAlertMessage({ + const send = await deps.send({ text, - token: parsed.data.token?.trim() || undefined, + token, chatId, messageThreadId: threadId, }) - if (!send.ok) { + if (send.ok !== true) { return reply.status(502).send({ ok: false, error: send.error }) } return reply.send({ ok: true }) }) } -export default alertsRoutes +} +export const defaultAlertsDependencies:Dependencies={meta:getAlertsMeta,telegram:getTelegramPublic,groups:listAlertGroups,rules:listAlertRules,history:listMergedHistory,replace:replaceAlertsConfig,writeTelegram:updateTelegramSettings,readTelegram:getTelegramSettingsRow,send:input=>sendTelegramRequest(input.token,{chat_id:input.chatId,text:input.text,...(input.messageThreadId!==undefined?{message_thread_id:input.messageThreadId}:{})})} +export default createAlertsRoutes(defaultAlertsDependencies) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 60d74183..c6a4bdf1 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 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. +Этап 11Б в работе; последний проверенный блок 11Б.4.2.1.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.2.2.1: rules/groups normalization и transactional receipt/DI без readback после commit; затем Alerts UI. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). @@ -308,3 +308,5 @@ REST запрос → ожидание → подтверждённый output / 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. + +2026-10-03: 11Б.4.2.1.2 — реальные Alerts HTTP/JWT gates и один Telegram stored snapshot, профильные tests/build/typecheck/lint прошли. Далее rules/groups 11Б.4.2.2.1. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f9989931..85cccc31 100644 --- a/docs/ux-ui-progress.md +++ b/docs/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.2.1.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.2.1.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11Б.4.2.1.2 — реальные Alerts HTTP/JWT и источник теста Telegram (2026-10-03) + +- Router вынесен в DI factory, production зависимости сохранены. Test Telegram читает один stored row token/chat/thread и передаёт resolved значения напрямую send helper; повторных DB rereads/fallback при отправке нет. Числовой thread строго int4, boolean/string/дробь/overflow отклоняются до reader/send. PUT Telegram проверяет public receipt перед ok. +- DI HTTP проверяет known empty GET, отсутствие token echo, success/unknown send без повтора, invalid inputs до вызова зависимостей, selective save и missing RETURNING без успеха. Реальный auth plugin с signed JWT проверяет alerts read/write/admin: read не разрешает save/send, settings admin сам по себе не разрешает alerts; 401/403 не вызывают действий. +- 7 service tests + HTTP/JWT, 5 API unit групп, backend build/typecheck и профильный lint прошли. Real Telegram/PG не вызывались. GET с несколькими queries/rules readback concurrency, outbox unknown retry и UI ещё не приняты. +- Следующий блок 11Б.4.2.2.1: правила/группы — shared normalization/validation и transactional receipt без независимого readback после commit; DI failure/rollback/concurrency. Затем Alerts UI migration/drafts/source/known/rights/adaptive, Releases/probe scheduling и приёмка 11Б/12. + ## Блок 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. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 01684e0a..349733b7 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -238,3 +238,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 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 прежний. + +Завершён 11Б.4.2.1.2: Alerts HTTP/JWT read/write gates и один Telegram snapshot; профильные tests/build/typecheck/lint прошли. Далее 11Б.4.2.2.1 rules/groups normalization/transactional receipt, затем UI. diff --git a/packages/contracts/src/alerts.ts b/packages/contracts/src/alerts.ts index b1b54120..557a848d 100644 --- a/packages/contracts/src/alerts.ts +++ b/packages/contracts/src/alerts.ts @@ -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().max(2147483647).optional(), + messageThreadId: z.number().int().positive().max(2147483647).optional(), rulePreview: rulePreviewTelegramSchema.optional(), })