diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 5055c6a0..6277fb77 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -1,7 +1,7 @@ "use client" -import { useMemo, useState, useEffect } from "react" -import { ospfCatalogSchema, ospfServersSchema, type OspfCatalog } from "@/lib/ospf-response" +import { useMemo, useState, useEffect, useRef } from "react" +import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" @@ -12,6 +12,7 @@ import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" +import { can } from "@/lib/auth" import { toast } from "sonner" import { RefreshCwIcon, WandSparklesIcon, GripVerticalIcon, @@ -116,10 +117,6 @@ interface BackendInterface { helloInterval: number; deadInterval: number; useBfd: boolean } -interface BackendInstance { - id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string - name: string; routerId: string; version: number; disabled: boolean; inactive: boolean; redistribute: string -} interface BackendBfdSession { id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string @@ -138,23 +135,6 @@ function isRefInterfaceName(name: string): boolean { return /^\(ref\s+\*.+\)$/.test(name.trim()) } -interface BackendOspfOptimizeResponse { - serverId: number - serverName: string - pingWeight: number - optimizedCount: number - applied: Array<{ interface: string; from: number; to: number }> - interfaces: Array<{ - id: string - interface: string - currentCost: number - optimalCost: number - score: number - pingMs: number - dlMbps: number - ulMbps: number - }> -} // ─── backend → frontend mappers ─────────────────────────────────────────────── @@ -679,6 +659,7 @@ function InterfacesTab({ onLiveDataRefresh, dataReady, dataIncomplete, + onOperationBusy, }: { items: OspfItem[] isLive: boolean @@ -687,12 +668,17 @@ function InterfacesTab({ onLiveDataRefresh: () => void dataReady: boolean dataIncomplete: boolean + onOperationBusy: (busy: boolean) => void }) { const [items, setItems] = useState(initialItems) const [dragging, setDragging] = useState(null) const [dragOver, setDragOver] = useState(null) const [optimizing, setOptimizing] = useState(false) const [liveOptimalCost, setLiveOptimalCost] = useState>({}) + const [operationError, setOperationError] = useState(null) + const operationLock = useRef(false) + const mounted = useRef(true) + useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) // Sync with live data when it changes useEffect(() => { @@ -738,14 +724,17 @@ function InterfacesTab({ const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID async function handleLiveOptimize() { - if (!canOptimizeLive) { + if (!mounted.current || operationLock.current || !canOptimizeLive || !can("mm:servers:write")) { toast.info("Выберите конкретный сервер для оптимизации OSPF") return } const ra = readStoredRouteOptimizerSettings() + operationLock.current = true + onOperationBusy(true) + setOperationError(null) setOptimizing(true) try { - const data = await requestJson( + const response = await requestJson( backendUrl, `/api/servers/${filterServerId}/ospf/optimize`, { @@ -753,17 +742,23 @@ function InterfacesTab({ body: JSON.stringify({ pingWeight: ra.pingWeight }), }, ) + if (!mounted.current) return + const data = ospfOptimizeSchema.parse(response) + if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF") const byKey: Record = {} data.interfaces.forEach((row) => { byKey[`${data.serverId}-${row.id}`] = row.optimalCost }) setLiveOptimalCost(byKey) - toast.success(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`) + toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`) onLiveDataRefresh() } catch (err) { - toast.error(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`) + if (!mounted.current) return + setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`) + onLiveDataRefresh() } finally { - setOptimizing(false) + operationLock.current = false + if (mounted.current) { setOptimizing(false); onOperationBusy(false) } } } @@ -790,8 +785,9 @@ function InterfacesTab({ return (
+ {operationError && Оптимизация OSPF не подтверждена{operationError}}
- {needsOptimize && ( @@ -799,8 +795,8 @@ function InterfacesTab({ Оптимизировать )} - {canOptimizeLive && ( - @@ -1155,6 +1151,9 @@ function OspfPageContent() { const [fetchedAt, setFetchedAt] = useState(null) const [liveError, setLiveError] = useState(null) const [fetchTick, setFetchTick] = useState(0) + const operationBusy = useRef(false) + const [writeBusy, setWriteBusy] = useState(false) + function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) } // Reset server filter when switching live ↔ mock useEffect(() => { @@ -1193,7 +1192,7 @@ function OspfPageContent() { const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData]) const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId)))) - const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !liveData.failures.length) + const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write")) // Derive frontend types from backend data or use mocks const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => { @@ -1280,6 +1279,7 @@ function OspfPageContent() { const host = s.label.replace(/^mt-/, "") return { id: s.id, + selectable: !writeBusy, name: host, host, site: s.site, @@ -1289,7 +1289,7 @@ function OspfPageContent() { : undefined, } }) - ), [ospfServers, serverCounts, isLive, unknownIds]) + ), [ospfServers, serverCounts, isLive, unknownIds, writeBusy]) // Graph always shows full topology (highlight is handled by node click inside tab) // KPIs reflect the current filter @@ -1302,7 +1302,7 @@ function OspfPageContent() { preserveSelection items={ospfRailItems} selectedId={filterServerId} - onSelect={setFilterServerId} + onSelect={id => { if (!operationBusy.current) setFilterServerId(id) }} showAll allCount={ospfServers.length} loading={isLive && loading && ospfServers.length === 0} @@ -1312,7 +1312,7 @@ function OspfPageContent() { actions={ <> - @@ -1324,7 +1324,7 @@ function OspfPageContent() {
{TABS.map(t => ( -
)} {isLive && fetchedAt &&

Ответ каталога получен . Время получения браузером, не измерения RouterOS.

} - {isLive && (liveError || Boolean(liveData?.failures.length)) && {liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}} + {isLive && (liveError || Boolean(liveData?.failures.length)) && {liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}} {incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) &&

Данные выбранного раздела OSPF пока неизвестны.

} {mode === "mock" && ( @@ -1384,14 +1384,15 @@ function OspfPageContent() { /> {activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && ( - setFetchTick(t => t + 1)} + onLiveDataRefresh={() => { setLoading(true); setFetchTick(t => t + 1) }} dataReady={dataReady} dataIncomplete={incomplete} + onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }} /> )} {activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && ( diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index d09ad0a5..ec6b8170 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,18 +26,25 @@ | 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.10, данные OSPF; далее операции | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.11, операции OSPF; далее URL/свежесть | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.11 — защита оптимизации OSPF (2026-09-30) + +- Синхронный lock исключает повторный POST. Во время операции заблокированы Refresh, смена устройства и вкладки; после API-ответа повторное чтение блокирует новую запись до подтверждённого каталога. Право UI — mm:servers:write, соответствующее существующему backend-маршруту /servers/:id/ospf/optimize; политика backend не изменена. +- Подтверждение проверяет serverId, параметры/диапазоны, уникальные интерфейсы и согласованность applied/optimizedCount. Ошибка/чужой/невалидный ответ не выдаёт success; сохраняет постоянный alert и запускает чтение, поскольку составная операция могла применить часть costs. Транзакционность не добавлена и не обещана. +- Mounted guards отклоняют поздний read/write после смены backend/live/mock: нет toast/ошибки/опроса в новом контексте. Уже отправленный RouterOS-запрос сменой страницы не отменяется. +- 21 OSPF E2E прошёл: семь данных, шесть operations/read-права и восемь source read/write. TypeScript чистый, lint — только два исходных warning stateClass/fmtMs; BackendInstance удалён. Далее — URL и временные отметки каждого устройства/подтверждения API, поля и layout. Квота: 88% окна использовано; локальный коммит без push. + ## Блок 10.10 — достоверность данных OSPF (2026-09-30) - Экран использует /ospf/catalog и inventory, проверяет все пять списков/поля/ID/failures и включённый контекст. Partial сохраняет набор отказавшего устройства, successful empty очищает; общий/ошибочный ответ сохраняет кэш. Mode/backendUrl изолированы key/cleanup. - Начальный отказ не выглядит как пустая настройка/нули. KPI неизвестны, строки neighbors/BFD/interfaces/routes явно отмечены при сбое. Топология не считается текущей при неполном опросе; сводка исправного выбранного устройства остаётся доступной. Rail включает включённые устройства без OSPF-строк. - Постоянный alert/повтор и отметка браузерного получения; повтор Refresh блокируется во время чтения. Оптимизация пока доступна только после полного успешного каталога. Времена успешного опроса каждого устройства, права/lock/поздний write ещё требуют следующего блока. - Удалён придуманный live optimal cost из порядка интерфейсов; opt показан только после ответа оптимизации. Убрано ложное mock «применены»: локальное изменение обозначено предпросмотром; кнопка сброса не выдаётся за серверный опрос. -- 17 E2E прошли (семь OSPF data + routing/BGP), TypeScript чистый, lint без ошибок с двумя исходными warning stateClass/fmtMs. Далее — операции OSPF, URL/свежесть, unknown state/counters и layout. Квота: 84% окна использовано; локальный коммит без push. +- 17 E2E прошли (семь OSPF data + routing/BGP), TypeScript чистый, lint без ошибок с двумя исходными warning stateClass/fmtMs и неиспользуемым BackendInstance (удалён в 10.11). Далее — операции OSPF, URL/свежесть, unknown state/counters и layout. Квота: 84% окна использовано; локальный коммит без push. ## Блок 10.9 — строгий каталог OSPF (2026-09-30) diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 83c03a54..c0fc05bb 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -80,3 +80,7 @@ server/session/tab и возврат/reload сохраняют контекст; ## Блок 10.10 — OSPF данные Каталог/inventory валидируются, кэш отказавшего устройства сохраняется, состояния/KPI неизвестны, топология не подменяет полный опрос. Убраны фиктивные live costs/mock применение. 17 E2E/TypeScript/lint прошли (два старых warning). Далее — операции/URL/свежесть/поля/layout. + +## Блок 10.11 — OSPF операции + +Lock/readiness, существующее mm:servers:write, валидация подтверждения и постоянная ошибка с повторным чтением. Поздний read/write не действует в другом источнике. 21 OSPF E2E/TypeScript/lint прошли (два старых warning). Далее — URL/времена/поля/layout. diff --git a/lib/ospf-response.ts b/lib/ospf-response.ts index 3dcc043d..3d4250d1 100644 --- a/lib/ospf-response.ts +++ b/lib/ospf-response.ts @@ -15,3 +15,10 @@ export const ospfCatalogSchema = z.object({ }) export const ospfServersSchema = vxlanServersSchema export type OspfCatalog = z.infer + +export const ospfOptimizeSchema = z.object({ + serverId: z.number().int().positive(), serverName: z.string(), pingWeight: z.number().int().min(0).max(100), + optimizedCount: z.number().int().nonnegative(), + applied: z.array(z.object({ interface: z.string().min(1), from: z.number().int().min(1).max(65535), to: z.number().int().min(1).max(65535) })), + interfaces: z.array(z.object({ id: z.string().trim().min(1), interface: z.string(), currentCost: z.number().int().min(1).max(65535), optimalCost: z.number().int().min(1).max(65535), score: z.number().finite(), pingMs: z.number().finite().nonnegative(), dlMbps: z.number().finite().nonnegative(), ulMbps: z.number().finite().nonnegative() })), +}).refine(data => data.optimizedCount === data.applied.length && new Set(data.interfaces.map(row => row.id)).size === data.interfaces.length && new Set(data.applied.map(row => row.interface)).size === data.applied.length && data.applied.every(row => data.interfaces.some(iface => iface.interface === row.interface && iface.currentCost === row.from && iface.optimalCost === row.to))) diff --git a/playwright.config.ts b/playwright.config.ts index e5c3523f..2345ae78 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/playwright.ospf-context.config.ts b/playwright.ospf-context.config.ts new file mode 100644 index 00000000..9dba8361 --- /dev/null +++ b/playwright.ospf-context.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" + +const server = base.webServer +if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер") +export default defineConfig({ + ...base, + use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, + testIgnore: [], + testMatch: "ospf-context.spec.ts", + webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } }, +}) diff --git a/tests/e2e/ospf-context.spec.ts b/tests/e2e/ospf-context.spec.ts new file mode 100644 index 00000000..b37b1036 --- /dev/null +++ b/tests/e2e/ospf-context.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test" +const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" } +const device = { id: 1, name: "Роутер 1", host: "192.0.2.1", enabled: true, country: "RU", status: null } +const iface = { id: "*1", serverId: 1, serverName: "Роутер 1", serverSite: "", serverCountry: "RU", instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", cost: 20, type: "broadcast", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: false } +const catalog = { neighbors: [], interfaces: [iface], instances: [], bfdSessions: [], routes: [], failures: [] } +const result = { serverId: 1, serverName: "Роутер 1", pingWeight: 60, optimizedCount: 1, applied: [{ interface: "ether1", from: 20, to: 10 }], interfaces: [{ id: "*1", interface: "ether1", currentCost: 20, optimalCost: 10, score: 1, pingMs: 10, dlMbps: 100, ulMbps: 100 }] } + +for (const change of ["backend", "mode"]) for (const operation of ["read", "write"]) for (const fails of [false, true]) { + test('OSPF: поздний ' + operation + ' после смены ' + change + '/' + fails, async ({ page }) => { + let newReads = 0 + await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } })) + await page.route("**/api/**", r => { + const url = new URL(r.request().url()), path = url.pathname + if (path === "/api/ospf/catalog" && url.port === "8101") newReads++ + return r.fulfill({ headers, json: path === "/api/ospf/catalog" ? { ...catalog, interfaces: [{ ...iface, interface: url.port === "8101" ? "Новый интерфейс" : "Старый интерфейс" }] } : path === "/api/servers" ? [device] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/settings") + await page.getByRole("link", { name: /^OSPF/ }).click() + await expect(page.getByText("Старый интерфейс", { exact: true })).toBeVisible() + await page.getByRole("option").filter({ hasText: "Роутер 1" }).click() + let release: (() => void) | undefined, started = false + const gate = new Promise(resolve => { release = resolve }) + const endpoint = operation === "read" ? "/api/ospf/catalog" : "/api/servers/1/ospf/optimize" + await page.route("**" + endpoint, async r => { + if (new URL(r.request().url()).port !== "8000") return r.fallback() + started = true + await gate + return r.fulfill(fails ? { headers, status: 503, json: { error: "Старая ошибка OSPF" } } : { headers, json: operation === "read" ? { ...catalog, interfaces: [] } : result }) + }) + await page.getByRole("button", { name: operation === "read" ? "Обновить" : "Оптимизировать OSPF", exact: true }).click() + await expect.poll(() => started).toBe(true) + await page.goBack() + await expect(page).toHaveURL(/settings/) + if (change === "backend") { + const input = page.getByPlaceholder("http://localhost:8000") + await input.fill("http://localhost:8101") + await input.press("Enter") + } else await page.getByRole("button", { name: "Моковые", exact: true }).click() + await page.getByRole("link", { name: /^OSPF/ }).click() + if (change === "backend") await expect(page.getByText("Новый интерфейс", { exact: true })).toBeVisible() + else await expect(page.getByText("Моковые данные", { exact: true })).toBeVisible() + const before = newReads + const response = page.waitForResponse(r => r.url().includes(":8000" + endpoint)) + release!() + await (await response).finished() + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + if (change === "backend") await expect(page.getByText("Новый интерфейс", { exact: true })).toBeVisible() + await expect(page.getByText("Старая ошибка OSPF", { exact: false })).toHaveCount(0) + await expect(page.getByText("API подтвердил оптимизацию OSPF", { exact: false })).toHaveCount(0) + expect(newReads).toBe(before) + }) +} diff --git a/tests/e2e/ospf-operations.spec.ts b/tests/e2e/ospf-operations.spec.ts new file mode 100644 index 00000000..2dd07507 --- /dev/null +++ b/tests/e2e/ospf-operations.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test" +const device = { id: 1, name: "Роутер 1", host: "192.0.2.1", enabled: true, country: "RU", status: null } +const iface = { id: "*1", serverId: 1, serverName: "Роутер 1", serverSite: "", serverCountry: "RU", instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", cost: 20, type: "broadcast", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: false } +const catalog = { neighbors: [], interfaces: [iface], instances: [], bfdSessions: [], routes: [], failures: [] } +const result = { serverId: 1, serverName: "Роутер 1", pingWeight: 60, optimizedCount: 1, applied: [{ interface: "ether1", from: 20, to: 10 }], interfaces: [{ id: "*1", interface: "ether1", currentCost: 20, optimalCost: 10, score: 1, pingMs: 10, dlMbps: 100, ulMbps: 100 }] } + +for (const outcome of ["success", "http", "foreign", "malformed", "poll-failure"]) { + test("OSPF: оптимизация, общий lock и результат " + outcome, async ({ page }) => { + let writes = 0, polled = false, reads = 0 + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", async r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/servers/1/ospf/optimize") { + writes++ + expect(r.request().method()).toBe("POST") + expect(r.request().postDataJSON().pingWeight).toBeGreaterThanOrEqual(0) + await gate + polled = true + return r.fulfill(outcome === "http" ? { status: 503, json: { error: "Применение не завершено" } } : { json: outcome === "foreign" ? { ...result, serverId: 2 } : outcome === "malformed" ? { ...result, optimizedCount: 99 } : result }) + } + if (path === "/api/ospf/catalog") { + reads++ + if (polled && outcome === "poll-failure") return r.fulfill({ status: 503, json: { error: "Опрос после записи недоступен" } }) + return r.fulfill({ json: catalog }) + } + return r.fulfill({ json: path === "/api/servers" ? [device] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ospf") + await page.getByRole("option").filter({ hasText: "Роутер 1" }).click() + const optimize = page.getByRole("button", { name: "Оптимизировать OSPF", exact: true }) + await expect(optimize).toBeEnabled() + await expect(page.getByText("opt 10", { exact: true })).toHaveCount(0) + await optimize.evaluate(el => { (el as HTMLButtonElement).click(); (el as HTMLButtonElement).click() }) + await expect.poll(() => writes).toBe(1) + await expect(page.getByRole("button", { name: "Оптимизация OSPF…", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeDisabled() + await page.getByRole("button", { name: "Обновить", exact: true }).evaluate(el => (el as HTMLButtonElement).click()) + expect(reads).toBe(1) + release!() + await expect.poll(() => reads).toBe(2) + if (["http", "foreign", "malformed"].includes(outcome)) { + await expect(page.getByRole("alert").filter({ hasText: "Оптимизация OSPF не подтверждена" })).toBeVisible() + await expect(page.getByText("API подтвердил оптимизацию OSPF", { exact: false })).toHaveCount(0) + } else await expect(page.getByText("API подтвердил оптимизацию OSPF: 1 интерфейсов", { exact: true })).toBeVisible() + if (outcome === "poll-failure") { + await expect(page.getByRole("alert").filter({ hasText: "Опрос после записи недоступен" })).toBeVisible() + await expect(optimize).toBeDisabled() + } else await expect(optimize).toBeEnabled() + expect(writes).toBe(1) + }) +} +test("OSPF: read-права не разрешают оптимизацию устройства", async ({ page }) => { + const payload = { sub: "test", email: "test@example.test", name: "Reader", apps: ["mm"], permissions: ["mm:network:read", "mm:servers:read"], exp: 4102444800 } + await page.addInitScript(value => localStorage.setItem("mmapp_token", "test." + btoa(JSON.stringify(value)) + ".test"), payload) + let writes = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (r.request().method() === "POST") writes++ + return r.fulfill({ json: path === "/api/auth/config" ? { required: true } : path === "/api/servers" ? [device] : path === "/api/ospf/catalog" ? catalog : {} }) + }) + await page.goto("/ospf") + await page.getByRole("option").filter({ hasText: "Роутер 1" }).click() + await expect(page.getByRole("button", { name: "Оптимизировать OSPF", exact: true })).toBeDisabled() + expect(writes).toBe(0) +})