From 2b62e0e956c581012d065db7ba56b8f7b3318e95 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 13:11:45 +0700 Subject: [PATCH] =?UTF-8?q?fix(evolution):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 2 ++ docs/ux-ui-progress.md | 9 ++++++++- docs/ux-ui-stage-11b.md | 2 ++ packages/contracts/package.json | 4 ++++ packages/contracts/src/evobgp.ts | 19 +++++++++++++++++++ packages/contracts/src/index.ts | 2 ++ shared/api/evobgp-settings.test.ts | 12 ++++++++++++ shared/api/evobgp-settings.ts | 24 ++++++++++++++++++++++++ 8 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/evobgp.ts create mode 100644 shared/api/evobgp-settings.test.ts create mode 100644 shared/api/evobgp-settings.ts diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 1b6dd31b..0d4ba0f1 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -270,3 +270,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.4.4.7.3: host export из одного снимка, фактические mask/WG/IPFIX ports, strict keys/peers и безопасные однострочные комментарии. Builder/key/HTTP/JWT/build/lint прошли без запуска host/RouterOS. Далее 11Б.3.4.4.8 — приёмка NetFlow; затем 11Б.3.5 Evolution. Нестандартные prefix allocator не приняты. Локальная приёмка NetFlow 11Б.3.4.4.8: 19 основных и 10 source E2E, backend JWT с purge, backend/frontend builds и API tests прошли в описанных блоках. Дублирующий purge Settings заменён переходом к единой панели (link E2E/typecheck). Исходные lint ошибки Settings остаются до его блока. Внешние PG/WG/RouterOS/Portal/нестандартный IPv4 prefix не приняты. Далее 11Б.3.5.1 — строгие Evolution settings/save/test API и затем provider/форма. + +Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index b4f9e0cf..17a455f4 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Б.3.4.4.8; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.1; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11Б.3.5.1 — строгий Evolution API (2026-10-03) + +- Добавлен общий contracts/evobgp: canonical HTTP(S) root URL без пути/query/credentials, явные enabled/secretConfigured, patch/test нормализация ключа без Bearer дублирования, one-line key и strict savedFields receipt. Empty endpoint/key clear и отсутствие поля имеют разную семантику. +- Новый API reader не считает malformed settings загрузкой; PUT проверяет literal ok, каждый переданный field, список реально подтверждённых полей и secretConfigured после clear/replace. Секрет не принимается в DTO/receipt. GET/PUT/test имеют caller abort/30s, неизвестный POST не повторяется. +- 3 API unit, contracts build, ESLint и полный frontend typecheck прошли. Backend пока отдаёт старый PUT DTO без receipt; новый helper ещё не подключён и не выдаёт существующий API за принятый. Catalog strict parser/test true принят ранее; provider всё ещё теряет данные и требует миграции. +- Далее 11Б.3.5.2: backend selective UPDATE и receipt с проверкой записанных полей/ключа без echo; затем 11Б.3.5.3 provider known/error/time/context, 11Б.3.5.4 форма/dirty/locks/приёмка. + ## Блок 11Б.3.4.4.8 — локальная приёмка NetFlow (2026-10-03) - Итоговые 19 основных NetFlow E2E (16 form + 3 responsive) и 10 backend source E2E прошли после общего контракта; добавлены дробный порог без default и GET после подтверждённого PUT. JWT gate дополнен purge: traffic reader не вызывает destructive handler. Backend профили/build и frontend production build приняты в предыдущих блоках; последнее изменение navigation/typecheck проверено отдельно. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index a18f0fb7..a2ab5b09 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -198,3 +198,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.4.4.7.3: host export из одного снимка, фактические mask/WG/IPFIX ports, strict keys/peers и безопасные однострочные комментарии. Builder/key/HTTP/JWT/build/lint прошли без запуска host/RouterOS. Далее 11Б.3.4.4.8 — приёмка NetFlow; затем 11Б.3.5 Evolution. Нестандартные prefix allocator не приняты. Локальная приёмка NetFlow 11Б.3.4.4.8: 19 основных и 10 source E2E, backend JWT с purge, backend/frontend builds и API tests прошли в описанных блоках. Дублирующий purge Settings заменён переходом к единой панели (link E2E/typecheck). Исходные lint ошибки Settings остаются до его блока. Внешние PG/WG/RouterOS/Portal/нестандартный IPv4 prefix не приняты. Далее 11Б.3.5.1 — строгие Evolution settings/save/test API и затем provider/форма. + +Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8820d65a..0e6e4aed 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -14,6 +14,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./evobgp": { + "types": "./dist/evobgp.d.ts", + "default": "./dist/evobgp.js" + }, "./servers": { "types": "./dist/servers.d.ts", "default": "./dist/servers.js" diff --git a/packages/contracts/src/evobgp.ts b/packages/contracts/src/evobgp.ts new file mode 100644 index 00000000..2ac6aba2 --- /dev/null +++ b/packages/contracts/src/evobgp.ts @@ -0,0 +1,19 @@ +import {z} from "zod" +export function normalizeEvoBgpBaseUrl(raw:string):string { + const value=raw.trim() + if(!value)return "" + let url:URL + try{url=new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value)?value:"https://"+value)}catch{throw new Error("Укажите корректный URL EvoBGP")} + if(!["http:","https:"].includes(url.protocol)||!url.hostname||url.username||url.password||url.pathname!=="/"||url.search||url.hash)throw new Error("Укажите HTTP(S) URL EvoBGP без пути, ключа, query и fragment") + return url.origin +} +export function normalizeEvoBgpApiKey(raw:string):string {return raw.trim().replace(/^Bearer\s+/i,"").trim()} +const baseInput=z.string().transform((raw,ctx)=>{try{return normalizeEvoBgpBaseUrl(raw)}catch(error){ctx.addIssue({code:"custom",message:error instanceof Error?error.message:"Неверный URL EvoBGP"});return z.NEVER}}) +const keyInput=z.string().refine(raw=>!/[\r\n]/.test(raw),"API-ключ должен быть одной строкой").transform(normalizeEvoBgpApiKey) +export const evoBgpSettingsSchema=z.object({baseUrl:z.string().refine(value=>{try{return value===normalizeEvoBgpBaseUrl(value)}catch{return false}},"Некорректный сохранённый URL EvoBGP"),enabled:z.boolean(),secretConfigured:z.boolean()}).strict() +export const evoBgpSettingsPatchSchema=z.object({baseUrl:baseInput.optional(),enabled:z.boolean().optional(),apiKey:z.union([keyInput,z.null()]).optional()}).strict().refine(patch=>Object.values(patch).some(value=>value!==undefined),"Укажите изменённые настройки EvoBGP") +export const evoBgpTestDraftSchema=z.object({baseUrl:baseInput.optional(),apiKey:keyInput.optional()}).strict() +export const evoBgpSaveReceiptSchema=evoBgpSettingsSchema.extend({ok:z.literal(true),savedFields:z.array(z.enum(["baseUrl","enabled","apiKey"])).min(1).refine(fields=>new Set(fields).size===fields.length)}) +export type EvoBgpSettingsDto=z.infer +export type EvoBgpSavePayload=z.input +export type EvoBgpTestDraft=z.input diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 728e0f72..bd87c647 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,3 +11,5 @@ export * from "./geoip.js" export * from "./statistics.js" export * from "./ospf-optimizer.js" + +export * from "./evobgp.js" diff --git a/shared/api/evobgp-settings.test.ts b/shared/api/evobgp-settings.test.ts new file mode 100644 index 00000000..8b9e7808 --- /dev/null +++ b/shared/api/evobgp-settings.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {getEvoBgpSettings,putEvoBgpSettings,testEvoBgpConnection} from "./evobgp-settings" +const settings={baseUrl:"https://evo.example.test",enabled:false,secretConfigured:false},base="http://fixture.test" +const json=(value:unknown)=>new Response(JSON.stringify(value)) +test("Evo settings require known boolean values and no returned secret",async()=>{const original=globalThis.fetch;try{globalThis.fetch=async()=>json(settings);assert.deepEqual(await getEvoBgpSettings(base),settings);for(const bad of [{}, {...settings,enabled:undefined},{...settings,secretConfigured:0},{...settings,apiKey:"fixture-only"},{...settings,baseUrl:"https://evo.test/v1"}]){globalThis.fetch=async()=>json(bad);await assert.rejects(getEvoBgpSettings(base),/не подтверждены/)}}finally{globalThis.fetch=original}}) +test("Evo PUT normalizes explicit fields and requires matching receipt/secret flag without echoing key",async()=>{const original=globalThis.fetch;try{let calls=0;globalThis.fetch=async(_url,init)=>{calls++;assert.deepEqual(JSON.parse(String(init?.body)),{baseUrl:settings.baseUrl,enabled:false,apiKey:"fixture-only"});return json({...settings,ok:true,secretConfigured:true,savedFields:["baseUrl","enabled","apiKey"]})};await putEvoBgpSettings(base,{baseUrl:"evo.example.test/",enabled:false,apiKey:"Bearer fixture-only"});assert.equal(calls,1) +for(const bad of [{...settings},{...settings,ok:false,savedFields:["enabled"]},{...settings,ok:true,enabled:true,savedFields:["enabled"]},{...settings,ok:true,savedFields:["enabled","enabled"]}]){globalThis.fetch=async()=>json(bad);await assert.rejects(putEvoBgpSettings(base,{enabled:false}),/не подтверждено/)} +for(const patch of [{},{baseUrl:"https://evo.test/v1"},{baseUrl:"ftp://evo.test"},{apiKey:"fixture\nonly"}]){calls=0;globalThis.fetch=async()=>{calls++;return json({})};await assert.rejects(putEvoBgpSettings(base,patch));assert.equal(calls,0)} +globalThis.fetch=async()=>json({...settings,ok:true,savedFields:["apiKey"]});assert.equal((await putEvoBgpSettings(base,{apiKey:null})).secretConfigured,false) +}finally{globalThis.fetch=original}}) +test("Evo test requires literal confirmation and never retries unknown POST",async()=>{const original=globalThis.fetch;try{globalThis.fetch=async()=>json({ok:true});await testEvoBgpConnection(base);for(const bad of [{},{ok:false},{ok:"true"}]){let calls=0;globalThis.fetch=async()=>{calls++;return json(bad)};await assert.rejects(testEvoBgpConnection(base),/не подтверждено/);assert.equal(calls,1)}}finally{globalThis.fetch=original}}) diff --git a/shared/api/evobgp-settings.ts b/shared/api/evobgp-settings.ts new file mode 100644 index 00000000..f80202bf --- /dev/null +++ b/shared/api/evobgp-settings.ts @@ -0,0 +1,24 @@ +import { evoBgpSettingsSchema,evoBgpSettingsPatchSchema,evoBgpSaveReceiptSchema,evoBgpTestDraftSchema,type EvoBgpSavePayload,type EvoBgpTestDraft } from "@mmapp/contracts/evobgp" +import {requestJson} from "./http-client" +import {confirmEvoBgpConnection} from "@/lib/evobgp-test-receipt" +function initWithTimeout(init?:RequestInit):RequestInit {return {...init,signal:AbortSignal.any([...(init?.signal?[init.signal]:[]),AbortSignal.timeout(30_000)])}} +export async function getEvoBgpSettings(base:string,init?:RequestInit){ + const parsed=evoBgpSettingsSchema.safeParse(await requestJson(base,"/api/evobgp/settings",{...initWithTimeout(init),method:"GET"})) + if(!parsed.success)throw new Error("Настройки EvoBGP не подтверждены сервером") + return parsed.data +} +export async function putEvoBgpSettings(base:string,patch:EvoBgpSavePayload,init?:RequestInit){ + const input=evoBgpSettingsPatchSchema.safeParse(patch) + if(!input.success)throw new Error(input.error.issues[0]?.message??"Укажите допустимые настройки EvoBGP") + const body=input.data + const parsed=evoBgpSaveReceiptSchema.safeParse(await requestJson(base,"/api/evobgp/settings",{...initWithTimeout(init),method:"PUT",body:JSON.stringify(body)})) + if(!parsed.success)throw new Error("Сохранение EvoBGP не подтверждено сервером") + const data=parsed.data,fields=Object.keys(body).filter(field=>(body as Record)[field]!==undefined) + if(data.savedFields.length!==fields.length||fields.some(field=>!data.savedFields.includes(field as typeof data.savedFields[number]))||body.baseUrl!==undefined&&data.baseUrl!==body.baseUrl||body.enabled!==undefined&&data.enabled!==body.enabled||body.apiKey!==undefined&&data.secretConfigured!==Boolean(body.apiKey))throw new Error("Сохранение EvoBGP не подтверждено: сервер вернул другие настройки") + return data +} +export async function testEvoBgpConnection(base:string,draft:EvoBgpTestDraft={},init?:RequestInit):Promise{ + const input=evoBgpTestDraftSchema.safeParse(draft) + if(!input.success)throw new Error(input.error.issues[0]?.message??"Укажите допустимые параметры EvoBGP") + confirmEvoBgpConnection(await requestJson(base,"/api/evobgp/test",{...initWithTimeout(init),method:"POST",body:JSON.stringify(input.data)})) +}