From ceb21132090316bb0bad282bee36f7cca72f6f54 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 15:56:51 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B8=D0=B7=D0=BE=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20GRE=20=D0=B8=20=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/gre/page.tsx | 89 ++++++++++++------- .../data-grids/gre-tunnels-data-grid.tsx | 11 +-- docs/ux-ui-progress.md | 9 +- docs/ux-ui-stage-9.md | 8 ++ playwright.config.ts | 2 +- playwright.gre-context.config.ts | 12 +++ tests/e2e/gre-context.spec.ts | 81 +++++++++++++++++ tests/e2e/gre-readiness.spec.ts | 70 +++++++++++++++ 8 files changed, 244 insertions(+), 38 deletions(-) create mode 100644 playwright.gre-context.config.ts create mode 100644 tests/e2e/gre-context.spec.ts create mode 100644 tests/e2e/gre-readiness.spec.ts diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index 807dace4..2ff0349e 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -232,7 +232,12 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures]) const [hasLoaded, setHasLoaded] = useState(false) const loadEpoch = useRef(0) - useEffect(() => () => { loadEpoch.current += 1 }, []) + const mounted = useRef(true) + const operationLock = useRef(false) + useEffect(() => { + mounted.current = true + return () => { mounted.current = false; loadEpoch.current += 1 } + }, []) const [pageTab, setPageTab] = useState("tunnels") const [tabFilter, setTabFilter] = useState("all") @@ -266,8 +271,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const setT = (k: K, v: (typeof defaultTunnelForm)[K]) => setTForm((f) => ({ ...f, [k]: v })) - const loadLive = useCallback(async () => { - if (!isLive) return + const loadLive = useCallback(async (afterOperation = false) => { + if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return const epoch = ++loadEpoch.current setDataLoading(true) try { @@ -347,7 +352,22 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId - const mutationsLocked = isLive && (mutateBusy || dataLoading || !hasLoaded || liveStale || failures.length > 0 || selectedTunnelError || historyRestoring) + const mutationsLocked = !isLive || mutateBusy || dataLoading || !hasLoaded || liveStale || historyRestoring + function canManage(serverId: string) { + return !mutationsLocked && !failedServerIds.includes(serverId) && liveServers.some(s => s.id === serverId && s.enabled) + } + function beginOperation(serverId: string) { + if (!mounted.current || operationLock.current || !canManage(serverId)) return false + operationLock.current = true + loadEpoch.current += 1 + setMutateBusy(true) + return true + } + function endOperation() { + operationLock.current = false + if (mounted.current) setMutateBusy(false) + } + const canCreate = selectedServerId === ALL_SERVERS_ID ? liveServers.some(s => canManage(s.id)) : canManage(selectedServerId) useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) @@ -370,24 +390,26 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on } }, [isLive, historyServerId, apiFetch]) - const restoreRevision = useCallback(async (id: string) => { - if (!isLive || !historyServerId) return + const restoreRevision = async (id: string) => { + if (!historyServerId || !beginOperation(historyServerId)) throw new Error("Устройство не готово к восстановлению GRE") setHistoryRestoring(true) try { const res = await apiFetch<{ ok: boolean }>( `/api/gre/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: historyServerId }) }, ) + if (!mounted.current) return if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление") toast.success("Версия применена на роутер") - await loadLive() - await loadRevisions() + await loadLive(true) + if (mounted.current) await loadRevisions() } catch (err) { - throw err + if (mounted.current) throw err } finally { - setHistoryRestoring(false) + if (mounted.current) setHistoryRestoring(false) + endOperation() } - }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions]) + } function tunnelWriteBody(form: typeof defaultTunnelForm) { return { @@ -428,8 +450,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on setFormError("Для IPsec нужен PSK не короче 8 символов") return } + if (!beginOperation(tForm.serverId)) { setFormError("Устройство не готово к изменению GRE; повторите опрос"); return } formLock.current = true - setMutateBusy(true) try { if (tunnelMode === "edit" && editingTunnel) { const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", { @@ -440,6 +462,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on name: editingTunnel.name, }), }) + if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение туннеля") toast.success(`Туннель ${tForm.name} обновлён`) } else { @@ -447,6 +470,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on method: "POST", body: JSON.stringify(tunnelWriteBody(tForm)), }) + if (!mounted.current) return const confirmed = typeof result === "object" && result !== null && "name" in result && result.name === tForm.name.trim() && ( ("ok" in result && result.ok === true) || ("id" in result && typeof result.id === "string" && result.id.length > 0 && @@ -459,21 +483,20 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on } setTunnelOpen(false) setEditingTunnel(null) - await loadLive() + await loadLive(true) } catch (err) { - setFormError(err instanceof Error ? err.message : "Не удалось сохранить туннель") + if (mounted.current) setFormError(err instanceof Error ? err.message : "Не удалось сохранить туннель") } finally { formLock.current = false - setMutateBusy(false) + endOperation() } } async function toggleTunnel(t: GreTunnel) { - if (!isLive || mutationsLocked || toggleLock.current) return + if (toggleLock.current || !beginOperation(t.serverId)) return toggleLock.current = true setToggleError(null) setToggleStatus(`Ожидание изменения ${t.name}…`) - setMutateBusy(true) try { const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", { method: "PATCH", @@ -485,42 +508,44 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on remoteAddress: t.remoteAddress, }), }) + if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение туннеля") toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`) - await loadLive() + await loadLive(true) } catch (err) { - setToggleError(`${t.name}: ${err instanceof Error ? err.message : "Не удалось изменить туннель"}`) + if (mounted.current) setToggleError(`${t.name}: ${err instanceof Error ? err.message : "Не удалось изменить туннель"}`) } finally { toggleLock.current = false - setToggleStatus(null) - setMutateBusy(false) + if (mounted.current) setToggleStatus(null) + endOperation() } } async function confirmDeleteTunnel() { const t = pendingDelete - if (!t || !isLive || mutationsLocked || deleteLock.current) return + if (!t || deleteLock.current || !beginOperation(t.serverId)) return deleteLock.current = true setDeleteError(null) - setMutateBusy(true) try { const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", { method: "DELETE", body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }), }) + if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление туннеля") toast.success(`Удалён ${t.name}`) setPendingDelete(null) - await loadLive() + await loadLive(true) } catch (err) { - setDeleteError(err instanceof Error ? err.message : "Не удалось удалить туннель") + if (mounted.current) setDeleteError(err instanceof Error ? err.message : "Не удалось удалить туннель") } finally { deleteLock.current = false - setMutateBusy(false) + endOperation() } } function openCreateTunnel() { + if (isLive && !canCreate) return setFormError(null) setTunnelMode("create") setEditingTunnel(null) @@ -532,6 +557,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on } function openEditTunnel(t: GreTunnel) { + if (isLive && !canManage(t.serverId)) return setFormError(null) setTunnelMode("edit") setEditingTunnel(t) @@ -627,13 +653,13 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on setHistoryOpen(true) void loadRevisions() }} - disabled={!isLive || !historyServerId || dataLoading} + disabled={!isLive || !historyServerId || dataLoading || mutateBusy || historyRestoring} title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"} > История - @@ -759,6 +785,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on onToggle={(t) => { void toggleTunnel(t) }} onDelete={setPendingDelete} mutationsLocked={mutationsLocked} + canManage={canManage} unknownServerIds={unknownServerIds} emptyTitle={isLive && !hasLoaded ? dataLoading ? "Загрузка GRE…" : "Данные GRE неизвестны" : incomplete ? "Нет подтверждённых строк" : undefined} emptyDescription={incomplete ? "Повторите опрос; отсутствие строк не подтверждено" : undefined} @@ -894,7 +921,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on @@ -1043,7 +1070,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on {formError &&

{formError}

} }>Отмена - @@ -1123,7 +1150,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on Отмена - { e.preventDefault(); void confirmDeleteTunnel() }}> + { e.preventDefault(); void confirmDeleteTunnel() }}> Удалить diff --git a/components/data-grids/gre-tunnels-data-grid.tsx b/components/data-grids/gre-tunnels-data-grid.tsx index 9a030e86..e13216c7 100644 --- a/components/data-grids/gre-tunnels-data-grid.tsx +++ b/components/data-grids/gre-tunnels-data-grid.tsx @@ -105,6 +105,7 @@ interface GreTunnelsDataGridProps { onToggle?: (tunnel: GreTunnel) => void onDelete?: (tunnel: GreTunnel) => void mutationsLocked?: boolean + canManage?: (serverId: string) => boolean unknownServerIds?: Set emptyTitle?: string emptyDescription?: string @@ -119,7 +120,7 @@ function GreTunnelsDataGrid({ onToggle, onDelete, mutationsLocked = false, - unknownServerIds, emptyTitle, emptyDescription, + unknownServerIds, emptyTitle, emptyDescription, canManage, }: GreTunnelsDataGridProps) { const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers]) const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools]) @@ -316,18 +317,18 @@ function GreTunnelsDataGrid({ onCodePreview(t)}> Просмотр кода - onEdit?.(t)}> + onEdit?.(t)}> Редактировать - onToggle?.(t)}> + onToggle?.(t)}> {t.enabled ? "Выключить" : "Включить"} onDelete?.(t)} > Удалить туннель @@ -341,7 +342,7 @@ function GreTunnelsDataGrid({ meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, }, ], - [onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, unknownServerIds, poolMap, serverMap], + [onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, canManage, unknownServerIds, poolMap, serverMap], ) const table = useTable({ diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 95cc1731..1461b652 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,19 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.58 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.59 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.59 — готовность и изоляция операций GRE (2026-09-30) + +- Запись разрешена только после успешного чтения inventory/GRE на включённом устройстве без его failure. Частичный отказ не блокирует исправное устройство; меню и выбор сервера показывают недоступность отказавшего/выключенного. Проверка повторяется непосредственно перед запросом. +- Общий синхронный lock объединяет создание/редактирование, переключение, удаление и восстановление до последующего опроса. Ручное обновление заблокировано во время записи; после API-ошибки возможен повтор, после подтверждённого удаления и отказа чтения диалог закрывается, старые строки остаются неизвестными. Позднее завершение старого контекста не вызывает toast/опрос/ошибку в новом. Уже отправленная операция не отменяется сменой страницы. +- Проверено 25 различных E2E: 11 истории, три формы, пять readiness/delete/toggle и шесть поздних ответов в отдельной source-конфигурации. TypeScript и lint чистые. Проверки обнаружили и устранили незавершённое освобождение lock в delete/toggle; повтор прошёл. +- Далее — временные отметки и экспорт GRE, достоверность live-пулов, desktop/mobile приёмка. JWT/RouterOS остаются внешней приёмкой. Локальный коммит без push. + ## Блок 9.58 — достоверность чтения GRE (2026-09-30) - Добавлена схема GRE с IPsec/PSK, уникальными составными serverId/id, уникальными failures и запретом одновременного успеха/отказа устройства. Inventory валидируется и принимает null мониторинга; rail показывает неизвестный статус. Ошибочный ответ не заменяет подтверждённые данные. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 99e9af1b..3c22789c 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -329,3 +329,11 @@ Live-копирование читает актуальный целевой rul - 22 E2E прошли: восемь сценариев данных плюс ссылки/формы/удаление/переключение. TypeScript и lint чистые. Удалены остатки неиспользуемых UI-хелперов; таблица использует стабильный составной ID. - На время частичного отказа изменения пока блокируются для всего GRE. Следующий блок — readiness каждого устройства, общий lock записи и поздние результаты операций. Live-пулы/экспорт требуют дальнейшей проверки. Локальный коммит без push. + +## Блок 9.59 — готовность и изоляция операций GRE (2026-09-30) + +- Запись разрешена только после успешного чтения inventory/GRE на включённом устройстве без его failure. Частичный отказ не блокирует исправное устройство; меню и выбор сервера показывают недоступность отказавшего/выключенного. Проверка повторяется непосредственно перед запросом. +- Общий синхронный lock объединяет создание/редактирование, переключение, удаление и восстановление до последующего опроса. Ручное обновление заблокировано во время записи; после API-ошибки возможен повтор, после подтверждённого удаления и отказа чтения диалог закрывается, старые строки остаются неизвестными. Позднее завершение старого контекста не вызывает toast/опрос/ошибку в новом. Уже отправленная операция не отменяется сменой страницы. +- Проверено 25 различных E2E: 11 истории, три формы, пять readiness/delete/toggle и шесть поздних ответов в отдельной source-конфигурации. TypeScript и lint чистые. Проверки обнаружили и устранили незавершённое освобождение lock в delete/toggle; повтор прошёл. +- Далее — временные отметки и экспорт GRE, достоверность live-пулов, desktop/mobile приёмка. JWT/RouterOS остаются внешней приёмкой. Локальный коммит без push. + diff --git a/playwright.config.ts b/playwright.config.ts index 367d5a7c..810cf386 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: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/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.gre-context.config.ts b/playwright.gre-context.config.ts new file mode 100644 index 00000000..7b9a9155 --- /dev/null +++ b/playwright.gre-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: "gre-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/gre-context.spec.ts b/tests/e2e/gre-context.spec.ts new file mode 100644 index 00000000..51dbd288 --- /dev/null +++ b/tests/e2e/gre-context.spec.ts @@ -0,0 +1,81 @@ +import { test, expect, type Page } from "@playwright/test" +const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" } +const payload = (port: string) => ({ tunnels: [{ id: "*old", serverId: "1", name: port === "8101" ? "Новый источник" : "Старый источник", localAddress: "192.0.2.1", remoteAddress: "192.0.2.2", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }] }) +async function setup(page: Page) { + let oldReads = 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 (r.request().method() === "OPTIONS") return r.fulfill({ status: 204, headers }) + if (path === "/api/gre/tunnels" && url.port === "8000") oldReads++ + return r.fulfill({ headers, json: path === "/api/gre/tunnels" ? payload(url.port) : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/settings") + await page.getByRole("link", { name: /^GRE/ }).click() + await expect(page.locator("tr[data-row-id]")).toContainText("Старый источник") + return () => oldReads +} +async function change(page: Page, kind: string) { + await page.goBack() + await expect(page).toHaveURL(/settings/) + if (kind === "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: /^GRE/ }).click() + if (kind === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник") + else await expect(page.locator('tr[data-row-id]').filter({ hasText: 'Старый источник' })).toHaveCount(0) +} +async function flush(page: Page) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) +} +for (const fails of [false, true]) { + test(`GRE: позднее чтение старого backend ${fails}`, async ({ page }) => { + await setup(page) + let release: (() => void) | undefined, started = false + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/api/gre/tunnels", 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: "Старая ошибка чтения" } } : { headers, json: { tunnels: [] } }) + }) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => started).toBe(true) + await change(page, "backend") + const response = page.waitForResponse(r => r.url().includes(":8000/api/gre/tunnels")) + release!() + await (await response).finished() + await flush(page) + await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник") + await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить GRE" })).toHaveCount(0) + }) +} +for (const kind of ["backend", "mode"]) for (const fails of [false, true]) { + test(`GRE: позднее удаление после смены ${kind}/${fails}`, async ({ page }) => { + const reads = await setup(page) + let release: (() => void) | undefined, started = false + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/api/gre/tunnels", async r => { + if (r.request().method() !== "DELETE") return r.fallback() + started = true + await gate + return r.fulfill(fails ? { headers, status: 403, json: { error: "Старое удаление запрещено" } } : { headers, json: { ok: true } }) + }) + await page.getByRole("button", { name: "Действия: Старый источник", exact: true }).click() + await page.getByRole("menuitem", { name: "Удалить туннель", exact: true }).click() + await page.getByRole("alertdialog").getByRole("button", { name: "Удалить", exact: true }).click() + await expect.poll(() => started).toBe(true) + const before = reads() + await change(page, kind) + const response = page.waitForResponse(r => r.url().includes(":8000/api/gre/tunnels") && r.request().method() === "DELETE") + release!() + await (await response).finished() + await flush(page) + expect(reads()).toBe(before) + await expect(page.getByText("Удалён Старый источник", { exact: true })).toHaveCount(0) + await expect(page.getByRole("alertdialog")).toHaveCount(0) + if (kind === "backend") await expect(page.getByRole("button", { name: "Добавить туннель", exact: true })).toBeEnabled() + }) +} diff --git a/tests/e2e/gre-readiness.spec.ts b/tests/e2e/gre-readiness.spec.ts new file mode 100644 index 00000000..b0a954e9 --- /dev/null +++ b/tests/e2e/gre-readiness.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "@playwright/test" +const tunnel = (id: string) => ({ id: "*1", serverId: id, name: "GRE " + id, localAddress: "192.0.2.1", remoteAddress: "192.0.2.2", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }) + +test("GRE: readiness отказавшего и выключенного устройства", async ({ page }) => { + let phase = 0, writes = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + if (!["GET", "HEAD"].includes(r.request().method())) writes++ + const path = new URL(r.request().url()).pathname + return r.fulfill({ json: path === "/api/servers" ? [1, 2, 3].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: id !== 3, country: "RU" })) : path === "/api/gre/tunnels" ? phase === 0 ? { tunnels: [tunnel("1"), tunnel("2"), tunnel("3")] } : { tunnels: [tunnel("1"), tunnel("3")], failures: [{ serverId: "2", error: "Таймаут тест" }] } : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/gre") + await expect(page.locator("tr[data-row-id]")).toHaveCount(3) + phase = 1 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Таймаут тест" })).toBeVisible() + for (const id of ["2", "3", "1"]) { + await page.getByRole("button", { name: "Действия: GRE " + id, exact: true }).click() + const menu = page.getByRole("menu", { name: "Действия: GRE " + id }) + const edit = menu.getByRole("menuitem", { name: "Редактировать", exact: true }) + if (id === "1") await expect(edit).toBeEnabled() + else await expect(edit).toBeDisabled() + await page.keyboard.press("Escape") + await expect(menu).toHaveCount(0) + } + await page.getByRole("button", { name: "Добавить туннель", exact: true }).click() + const selector = page.getByRole("dialog").getByLabel("Сервер (MikroTik)") + await expect(selector.locator('option[value="1"]')).toBeEnabled() + await expect(selector.locator('option[value="2"]')).toBeDisabled() + await expect(selector.locator('option[value="3"]')).toBeDisabled() + expect(writes).toBe(0) +}) + +test("GRE: общий замок до опроса после подтверждённого удаления", async ({ page }) => { + let reads = 0, writes = 0, release: (() => void) | undefined, phase = 0 + 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 (r.request().method() === "DELETE") { writes++; await gate; phase = 1; return r.fulfill({ json: { ok: true } }) } + if (path === "/api/gre/tunnels") { + reads++ + return phase === 1 ? r.fulfill({ status: 503, json: { error: "Опрос после удаления недоступен" } }) : r.fulfill({ json: { tunnels: phase === 2 ? [] : [tunnel("1")] } }) + } + return r.fulfill({ json: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/gre") + await page.getByRole("button", { name: "Действия: GRE 1" }).click() + await page.getByRole("menuitem", { name: "Удалить туннель", exact: true }).click() + const dialog = page.getByRole("alertdialog") + const submit = dialog.getByRole("button", { name: "Удалить", exact: true }) + await submit.evaluate(el => { (el as HTMLButtonElement).click(); (el as HTMLButtonElement).click() }) + await expect.poll(() => writes).toBe(1) + const before = reads + const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true }) + await expect(refresh).toBeDisabled() + await refresh.evaluate(el => (el as HTMLButtonElement).click()) + expect(reads).toBe(before) + release!() + await expect(dialog).toHaveCount(0) + await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить GRE" })).toContainText("Опрос после удаления недоступен") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await page.getByRole("button", { name: "Действия: GRE 1" }).click() + await expect(page.getByRole("menuitem", { name: "Удалить туннель", exact: true })).toBeDisabled() + await page.keyboard.press("Escape") + phase = 2 + await page.getByRole("button", { name: "Повторить загрузку GRE" }).click() + await expect(page.locator("tr[data-row-id]")).toHaveCount(0) + expect(writes).toBe(1) +})