From c88e1400bf00e48248412b296fc5b11a957fe5b5 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 22:56:31 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20OSPF=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/route-optimizer/page.tsx | 70 ++++++++++--------- backend/package.json | 2 +- backend/src/lib/permissions.test.ts | 5 ++ backend/src/lib/permissions.ts | 16 +++++ .../src/plugins/auth.ospf-optimizer.test.ts | 27 +++++++ docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-10.md | 4 ++ tests/e2e/route-optimizer-ospf-apply.spec.ts | 48 +++++++++++++ 8 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 backend/src/plugins/auth.ospf-optimizer.test.ts create mode 100644 tests/e2e/route-optimizer-ospf-apply.spec.ts diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index b2fd974e..2dc4e302 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState, useMemo, useRef } from "react" import Link from "next/link" -import {ospfCatalogSchema} from "@/lib/ospf-response" +import {can} from "@/lib/auth" +import {ospfCatalogSchema,ospfOptimizeSchema} from "@/lib/ospf-response" import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema,optimizerOspfPreviewSchema,type OptimizerOspfPreview} from "@/lib/route-optimizer-response" import { PageHeader } from "@/components/page-header" import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid" @@ -450,8 +451,9 @@ function RouteOptimizerContent() { const [selectedVrf, setSelectedVrf] = useState("main") const [ospfServerId, setOspfServerId] = useState("") const [ospfApplying, setOspfApplying] = useState(false) - const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null) - const [ospfApplyError, setOspfApplyError] = useState("") + const [applyResult, setApplyResult] = useState<{owner:string;serverName:string;optimizedCount:number;receivedAt:string}|null>(null) + const [applyError,setApplyError]=useState({owner:"",message:""}) + const writeLock=useRef(false),activeContext=useRef("") const [metaState,setMetaState]=useState<{owner:string;data:{interfaces:number;areas:number;serverName:string}|null;error:string}>({owner:"",data:null,error:""}) const [previewState,setPreviewState]=useState<{owner:string;data:OptimizerOspfPreview|null;error:string;pending:boolean}>({owner:"",data:null,error:"",pending:false}) const [previewTick,setPreviewTick]=useState(0) @@ -461,6 +463,9 @@ function RouteOptimizerContent() { const ospfPreview=previewState.owner===previewKey?previewState.data:null const ospfPreviewError=previewState.owner===previewKey?previewState.error:"" const ospfPreviewLoading=useLiveData && Boolean(ospfServerId) && (previewState.owner!==previewKey || previewState.pending) + useEffect(()=>{activeContext.current=previewKey},[previewKey]) + const ospfApplyResult=applyResult?.owner===previewKey?applyResult:null + const ospfApplyError=applyError.owner===previewKey?applyError.message:"" const previewReady=Boolean(ospfPreview) && !ospfPreviewLoading && !ospfPreviewError const load = useCallback(async (override?: OptimizerSettings) => { @@ -608,28 +613,25 @@ function RouteOptimizerContent() { } async function applyOspfOptimization() { - if (!ospfServerId || loading || error || !data || !previewReady) return - setOspfApplying(true) - setOspfApplyError("") - setOspfApplyResult(null) + if(writeLock.current || !can("mm:network:write") || !useLiveData || !ospfServerId || loading || error || !data || !previewReady || ospfMetaError || !ospfPreview?.changedCount || !liveServers.some(server=>String(server.id)===ospfServerId&&server.enabled))return + const owner=previewKey,target=ospfServerId,weight=settings.pingWeight + const current=()=>reader.current.alive && activeContext.current===owner + writeLock.current=true;setOspfApplying(true);setApplyError({owner,message:""}) try { - const res = await apiFetch<{ serverName: string; optimizedCount: number }>( - `/api/servers/${ospfServerId}/ospf/optimize`, - { - method: "POST", - body: JSON.stringify({ pingWeight: settings.pingWeight }), - }, - ) - setOspfApplyResult({ - serverName: res.serverName, - optimizedCount: res.optimizedCount, - }) - await load() - setPreviewTick(value=>value+1) - } catch (e) { - setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации") - } finally { - setOspfApplying(false) + const raw=await apiFetch("/api/servers/"+target+"/ospf/optimize",{method:"POST",body:JSON.stringify({pingWeight:weight})}) + const parsed=ospfOptimizeSchema.safeParse(raw) + if(!parsed.success || String(parsed.data.serverId)!==target || parsed.data.pingWeight!==weight)throw new Error("Применение OSPF не подтверждено: недостоверный ответ или другой контекст") + if(current())setApplyResult({owner,serverName:parsed.data.serverName,optimizedCount:parsed.data.optimizedCount,receivedAt:new Date().toISOString()}) + }catch(error){ + if(current())setApplyError({owner,message:error instanceof Error?error.message:"Ошибка применения OSPF"}) + }finally{ + writeLock.current=false + if(reader.current.alive){ + setOspfApplying(false) + setPreviewState(previous=>({...previous,pending:true})) + setPreviewTick(value=>value+1) + void load() + } } } @@ -684,7 +686,7 @@ function RouteOptimizerContent() { Симулировать все ({totalSwitches}) )} - @@ -693,7 +695,7 @@ function RouteOptimizerContent() { />
-
+
{/* Status row */}
@@ -748,7 +750,7 @@ function RouteOptimizerContent() { {/* Settings */} -
@@ -889,14 +891,16 @@ function RouteOptimizerContent() { />
+ {ospfApplying &&

Ожидание подтверждения OSPF API…

} + {!can("mm:network:write") &&

Доступен просмотр OSPF. Для применения требуется право изменения сети.

} {ospfApplyResult && ( -
- Применено на {ospfApplyResult.serverName}: изменено интерфейсов — {ospfApplyResult.optimizedCount}. +
+ Последнее подтверждение API: {ospfApplyResult.serverName}, изменено интерфейсов — {ospfApplyResult.optimizedCount}. Получено {new Date(ospfApplyResult.receivedAt).toLocaleTimeString("ru-RU")}.
)} {ospfApplyError && ( -
+
Ошибка OSPF-оптимизации: {ospfApplyError}
)} @@ -1043,7 +1047,7 @@ function RouteOptimizerContent() { /> ))} -
+
) diff --git a/backend/package.json b/backend/package.json index 2ab459f0..cbed88f1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,7 @@ "db:studio": "drizzle-kit studio", "db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts", "facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts", - "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", + "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts", "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts", diff --git a/backend/src/lib/permissions.test.ts b/backend/src/lib/permissions.test.ts index f677503a..19927d8d 100644 --- a/backend/src/lib/permissions.test.ts +++ b/backend/src/lib/permissions.test.ts @@ -61,4 +61,9 @@ assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true) assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true) assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false) +assert.equal(permissionForRequest("GET","/api/servers/1/ospf"),"mm:network:read") +assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize/preview?x=1"),"mm:network:read") +assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize"),"mm:network:write") +assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize/preview/extra"),"mm:network:write") +assert.equal(permissionForRequest("PUT","/api/servers/1"),"mm:servers:write") console.log("permissions.test.ts: ok") diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index a87ee718..1c72e434 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -42,6 +42,22 @@ type Rule = { } const RULES: Rule[] = [ + { + methods: ["GET"], + match: p => /^\/api\/servers\/\d+\/ospf(?:\/|$)/.test(p), + permission: "mm:network:read", + }, + { + methods: ["POST"], + match: p => /^\/api\/servers\/\d+\/ospf\/optimize\/preview$/.test(p), + permission: "mm:network:read", + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: p => /^\/api\/servers\/\d+\/ospf(?:\/|$)/.test(p), + permission: "mm:network:write", + }, + { methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], match: (p) => diff --git a/backend/src/plugins/auth.ospf-optimizer.test.ts b/backend/src/plugins/auth.ospf-optimizer.test.ts new file mode 100644 index 00000000..5c9151fd --- /dev/null +++ b/backend/src/plugins/auth.ospf-optimizer.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {SignJWT} from "jose" +process.env.AUTH_REQUIRED="true" +process.env.AUTH_JWT_SECRET="optimizer-test-secret" +process.env.AUTH_ISSUER="https://auth.test.local" +process.env.NODE_ENV="test" +const {default:authPlugin,requireAuth}=await import("./auth.js") +const app=Fastify() +await app.register(authPlugin) +app.addHook("preHandler",requireAuth) +let reads=0,writes=0 +app.post("/api/servers/:id/ospf/optimize/preview",async()=>{reads++;return {ok:true}}) +app.post("/api/servers/:id/ospf/optimize",async()=>{writes++;return {ok:true}}) +const secret=new TextEncoder().encode("optimizer-test-secret") +for(const permission of ["mm:network:read","mm:network:write","mm:servers:write"]){ + const token=await new SignJWT({sub:"reader",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(secret) + const headers={authorization:"Bearer "+token} + const preview=await app.inject({method:"POST",url:"/api/servers/1/ospf/optimize/preview",headers}) + const apply=await app.inject({method:"POST",url:"/api/servers/1/ospf/optimize",headers}) + assert.equal(preview.statusCode,permission==="mm:servers:write"?403:200) + assert.equal(apply.statusCode,permission==="mm:network:write"?200:403) +} +assert.equal(reads,2);assert.equal(writes,1) +assert.equal((await app.inject({method:"POST",url:"/api/servers/1/ospf/optimize"})).statusCode,401) +await app.close() +console.log("OSPF optimizer RBAC: signed JWT read/write/servers-only, no DB/RouterOS") diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 8cd9cf4a..8e4c3639 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.31; строгий OSPF preview оптимизатора, далее подтверждение apply | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.32; подтверждённое OSPF apply и RBAC, далее backend provenance | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.32 — подтверждение применения OSPF и права (2026-09-30) + +- Apply валидирует существующий ospfOptimizeSchema, serverId и вес запроса. Успех по count/name без списка applied больше не принимается. Последний receipt привязан к контексту и времени получения браузером; отсутствие применения отображается «—». +- Синхронный ref lock и disabled fieldset блокируют повтор/смену устройства/параметров до ответа. Проверяются network:write, enabled inventory и готовность preview. После любого результата перечитываются preview/catalog и исходный расчёт: ошибка может сопровождаться частичным применением RouterOS. Late unmounted контекст не публикует результат. +- RBAC конкретных server OSPF endpoints поставлен перед общим server matcher: GET/POST preview требуют network:read, изменения network:write. Общие server rights прежние. Изолированный HTTP тест с настоящими signed JWT проверяет reader/writer/servers-only/401 без БД и RouterOS. +- Проверки: 16 E2E apply/preview (success/HTTP/foreign/weight/count/missing/repeat/readonly), readonly повторно с завершением metadata, TS/lint, frontend/backend production build и backend auth regression прошли. Первичный readonly teardown оставлял metadata запрос; ожидание добавлено, повтор прошёл без proxy ошибки. +- Далее 10.33: происхождение cost и метрик backend OSPF планировщика; legacy defaults ещё не позволяют считать preview полностью наблюдаемым. Затем source/URL и visual acceptance. Реальный RouterOS/DB/JWT integration не проверен; signed JWT тест проверяет middleware со stub handlers. Последняя квота: 76% окна, 43% недели использовано. Локальный коммит без push. + ## Блок 10.31 — OSPF preview в оптимизаторе (2026-09-30) - Preview требует точных serverId/pingWeight, идентификаторов интерфейсов, уникальности и согласованных interfaces/changes/counts. Старый или чужой контекст не публикуется и блокирует apply. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 696adde0..949b4e89 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -176,3 +176,7 @@ Live таймерный успех убран; demo симуляция явно ## 10.31. Строгий preview OSPF Контекст serverId/веса, полный список изменений/counts/ids; metadata strict catalog, повтор/last preview/late context. 12 профильных + ранее 16 существующих (24 разных), TS/lint прошли. Apply receipt и происхождение backend модельных значений ещё требуют проверки; далее 10.32. + +## 10.32. Подтверждённое OSPF применение и RBAC + +Strict receipt/server/weight, ref lock, readonly/disabled контекст и reread любого результата. 16 E2E, readonly повтор, TS/lint, обе production сборки и auth tests прошли. Specific OSPF rights согласованы: preview read, apply write; signed JWT со stub handlers без RouterOS/DB. Далее backend provenance: legacy cost/metric defaults ещё не приняты как observed данные. diff --git a/tests/e2e/route-optimizer-ospf-apply.spec.ts b/tests/e2e/route-optimizer-ospf-apply.spec.ts new file mode 100644 index 00000000..517ba6e4 --- /dev/null +++ b/tests/e2e/route-optimizer-ospf-apply.spec.ts @@ -0,0 +1,48 @@ +import {test,expect,type Page} from "@playwright/test" +const row=(changed=true)=>({id:"i",interface:"ether1",currentCost:changed?10:20,optimalCost:20,score:50,pingMs:10,dlMbps:100,ulMbps:50}) +async function setup(page:Page,mode:string,delayed=false){ + let writes=0,previews=0,applied=false,started=false,release!:()=>void + const gate=new Promise(resolve=>{release=resolve}) + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",async r=>{ + const p=new URL(r.request().url()).pathname + if(p.endsWith("/ospf/optimize")){ + writes++;started=true;if(delayed)await gate + const receipt={serverId:1,serverName:"Роутер 1",pingWeight:60,optimizedCount:1,applied:[{interface:"ether1",from:10,to:20}],interfaces:[row()]} + if(mode==="success")applied=true + return r.fulfill(mode==="http"?{status:503,json:{error:"Ошибка записи"}}:{json:mode==="foreign"?{...receipt,serverId:2}:mode==="weight"?{...receipt,pingWeight:20}:mode==="count"?{...receipt,applied:[]}:mode==="missing"?{}:receipt}) + } + if(p.endsWith("/preview")){previews++;const item=row(!applied);return r.fulfill({json:{serverId:1,serverName:"Роутер 1",pingWeight:60,interfacesTotal:1,changedCount:applied?0:1,unchangedCount:applied?1:0,interfaces:[item],changes:applied?[]:[item]}})} + return r.fulfill({json:p==="/api/auth/config"?{required:mode==="readonly"}:p==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,type:"home-router",country:"RU",enabled:true,status:null})):p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:{}}) + }) + await page.goto("/route-optimizer") + const button=page.getByRole("button",{name:"Оптимизировать OSPF",exact:true}) + await expect(page.getByRole("table").filter({hasText:"Speed (dl/ul)"}).locator("tr[data-row-id]")).toHaveCount(1) + return {button,writes:()=>writes,previews:()=>previews,started:()=>started,release:()=>release()} +} +for(const mode of ["success","http","foreign","weight","count","missing"])test("Оптимизатор: подтверждение OSPF "+mode,async({page})=>{ + const state=await setup(page,mode),before=state.previews() + await expect(state.button).toBeEnabled();await state.button.click() + if(mode==="success")await expect(page.getByRole("status").filter({hasText:"Последнее подтверждение API"})).toBeVisible() + else{await expect(page.getByRole("alert").filter({hasText:mode==="http"?"Ошибка записи":"Применение OSPF не подтверждено"})).toBeVisible();await expect(page.getByText(/Последнее подтверждение API/)).toHaveCount(0)} + await expect.poll(state.previews).toBeGreaterThan(before) + if(mode==="success"){await expect(page.getByText("0 из 1 изменятся",{exact:true})).toBeVisible();await expect(state.button).toBeDisabled()} + else await expect(state.button).toBeEnabled() + expect(state.writes()).toBe(1) +}) +test("Оптимизатор: повтор и смена устройства заблокированы до receipt",async({page})=>{ + const state=await setup(page,"success",true) + await state.button.evaluate(button=>{(button as HTMLButtonElement).click();(button as HTMLButtonElement).click()}) + await expect.poll(state.started).toBe(true);await expect(page.locator("select")).toBeDisabled() + await expect(page.getByRole("button",{name:"Настройки оптимизатора",exact:true})).toBeDisabled() + expect(state.writes()).toBe(1);state.release() + await expect(page.getByRole("status").filter({hasText:"Последнее подтверждение API"})).toBeVisible() +}) +test("Оптимизатор: readonly preview доступен без применения",async({page})=>{ + const claims={sub:"test",email:"reader@example.test",name:"Reader",apps:["mm"],permissions:["mm:network:read"],exp:4102444800} + await page.addInitScript(value=>localStorage.setItem("mmapp_token","test."+btoa(JSON.stringify(value))+".test"),claims) + const state=await setup(page,"readonly");await expect(state.button).toBeDisabled() + await expect(page.getByText("Доступен просмотр OSPF. Для применения требуется право изменения сети.",{exact:true})).toBeVisible() + await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible() + expect(state.writes()).toBe(0) +})