fix(ui): подтверждать применение OSPF и согласовать права
This commit is contained in:
1 parent
bf2815b5a4
commit
c88e1400bf
8 files changed
+147
-35
No files matched your search
@@ -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<unknown>("/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() {
|
||||
<ZapIcon className="size-4" />Симулировать все ({totalSwitches})
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading || ospfApplying}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
{loading ? "Расчёт…" : "Обновить"}
|
||||
</Button>
|
||||
@@ -693,7 +695,7 @@ function RouteOptimizerContent() {
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<fieldset disabled={ospfApplying} className="min-w-0 flex flex-col gap-4">
|
||||
|
||||
{/* Status row */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap px-0.5">
|
||||
@@ -748,7 +750,7 @@ function RouteOptimizerContent() {
|
||||
{/* Settings */}
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="p-0">
|
||||
<button type="button" aria-expanded={showSettings} aria-label="Настройки оптимизатора"
|
||||
<button type="button" aria-expanded={showSettings} aria-label="Настройки оптимизатора" disabled={ospfApplying}
|
||||
className={cn("flex w-full items-center gap-3 px-4 py-3 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", showSettings && "border-b")}
|
||||
onClick={() => setShowSettings(v => !v)}
|
||||
>
|
||||
@@ -854,7 +856,7 @@ function RouteOptimizerContent() {
|
||||
size="sm"
|
||||
className="ml-auto h-7 text-xs"
|
||||
onClick={() => void applyOspfOptimization()}
|
||||
disabled={loading || Boolean(error) || !data || !ospfServerId || ospfApplying || !previewReady || Boolean(ospfMetaError)}
|
||||
disabled={!can("mm:network:write") || loading || Boolean(error) || !data || !ospfServerId || ospfApplying || !previewReady || Boolean(ospfMetaError) || !ospfPreview?.changedCount}
|
||||
>
|
||||
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
|
||||
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
|
||||
@@ -865,7 +867,7 @@ function RouteOptimizerContent() {
|
||||
<div className="text-muted-foreground">Router</div>
|
||||
<div className="md:col-span-2 font-mono truncate">{ospfMeta?.serverName ?? "—"}</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
apply: {ospfApplyResult?.optimizedCount ?? 0}
|
||||
apply: {ospfApplyResult?.optimizedCount ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -889,14 +891,16 @@ function RouteOptimizerContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ospfApplying && <p role="status" className="text-xs text-muted-foreground">Ожидание подтверждения OSPF API…</p>}
|
||||
{!can("mm:network:write") && <p className="text-xs text-muted-foreground">Доступен просмотр OSPF. Для применения требуется право изменения сети.</p>}
|
||||
{ospfApplyResult && (
|
||||
<div className="text-xs rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-600 dark:text-emerald-400">
|
||||
Применено на {ospfApplyResult.serverName}: изменено интерфейсов — {ospfApplyResult.optimizedCount}.
|
||||
<div role="status" className="text-xs rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-600 dark:text-emerald-400">
|
||||
Последнее подтверждение API: {ospfApplyResult.serverName}, изменено интерфейсов — {ospfApplyResult.optimizedCount}. Получено {new Date(ospfApplyResult.receivedAt).toLocaleTimeString("ru-RU")}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ospfApplyError && (
|
||||
<div className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
<div role="alert" className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
Ошибка OSPF-оптимизации: {ospfApplyError}
|
||||
</div>
|
||||
)}
|
||||
@@ -1043,7 +1047,7 @@ function RouteOptimizerContent() {
|
||||
/>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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 данные.
|
||||
@@ -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<void>(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)
|
||||
})
|
||||
Reference in new issue
Block a user