fix(evolution): проверять настройки и подтверждение сохранённых полей

This commit is contained in:
Denozordec committed 2026-10-03 13:11:45 +07:00
1 parent 2215007c91
commit 2b62e0e956
8 files changed
+73 -1

No files matched your search

+2
View File
@@ -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 и форма.
+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Б.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 проверено отдельно.
+2
View File
@@ -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 и форма.
+4
View File
@@ -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"
+19
View File
@@ -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<typeof evoBgpSettingsSchema>
export type EvoBgpSavePayload=z.input<typeof evoBgpSettingsPatchSchema>
export type EvoBgpTestDraft=z.input<typeof evoBgpTestDraftSchema>
+2
View File
@@ -11,3 +11,5 @@ export * from "./geoip.js"
export * from "./statistics.js"
export * from "./ospf-optimizer.js"
export * from "./evobgp.js"
+12
View File
@@ -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}})
+24
View File
@@ -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<unknown>(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<unknown>(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<string,unknown>)[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<void>{
const input=evoBgpTestDraftSchema.safeParse(draft)
if(!input.success)throw new Error(input.error.issues[0]?.message??"Укажите допустимые параметры EvoBGP")
confirmEvoBgpConnection(await requestJson<unknown>(base,"/api/evobgp/test",{...initWithTimeout(init),method:"POST",body:JSON.stringify(input.data)}))
}