fix(ui): изолировать контекст и операции IPsec
This commit is contained in:
1 parent
f0071a495c
commit
921aba3054
7 files changed
+205
-36
No files matched your search
+55
-34
@@ -183,6 +183,9 @@ function IpsecPage() {
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [inventoryError, setInventoryError] = useState<string | null>(null)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const mounted = useRef(true)
|
||||
const operationLock = useRef(false)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
const readEpoch = useRef(0)
|
||||
useEffect(() => () => { readEpoch.current += 1 }, [])
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -215,8 +218,8 @@ function IpsecPage() {
|
||||
const historyEpoch = useRef(0)
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
const loadLive = useCallback(async (afterOperation = false) => {
|
||||
if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return
|
||||
const epoch = ++readEpoch.current
|
||||
setLoading(true)
|
||||
const [ipsecResult, inventoryResult] = await Promise.allSettled([
|
||||
@@ -262,7 +265,7 @@ function IpsecPage() {
|
||||
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
|
||||
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !historyServerId) return
|
||||
if (!isLive || !historyServerId || !mounted.current) return
|
||||
const epoch = ++historyEpoch.current
|
||||
setHistoryLoading(true)
|
||||
setHistoryError(null)
|
||||
@@ -281,21 +284,24 @@ function IpsecPage() {
|
||||
}
|
||||
}, [isLive, historyServerId, backendUrl])
|
||||
|
||||
const restoreRevision = useCallback(async (id: string) => {
|
||||
const restoreRevision = async (id: string) => {
|
||||
if (!isLive || !historyServerId) return
|
||||
beginOperation(historyServerId)
|
||||
setHistoryRestoring(true)
|
||||
try {
|
||||
const res = await restoreIpsecRevision(backendUrl, id, historyServerId)
|
||||
if (!mounted.current) return
|
||||
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
||||
toast.success("Версия применена на роутер")
|
||||
await loadLive()
|
||||
await loadLive(true)
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
throw err
|
||||
} finally {
|
||||
setHistoryRestoring(false)
|
||||
endOperation()
|
||||
if (mounted.current) setHistoryRestoring(false)
|
||||
}
|
||||
}
|
||||
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
|
||||
|
||||
const failedServerIds = new Set(failures.map(f => f.serverId))
|
||||
const scopeUnknown = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
|
||||
@@ -303,7 +309,18 @@ function IpsecPage() {
|
||||
const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError &&
|
||||
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
|
||||
const assertDeviceReady = (serverId: string) => {
|
||||
if (!canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
|
||||
if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
|
||||
}
|
||||
const beginOperation = (serverId: string) => {
|
||||
assertDeviceReady(serverId)
|
||||
operationLock.current = true
|
||||
readEpoch.current += 1
|
||||
setLoading(false)
|
||||
setBusy(true)
|
||||
}
|
||||
const endOperation = () => {
|
||||
operationLock.current = false
|
||||
if (mounted.current) setBusy(false)
|
||||
}
|
||||
const hasReadyDevice = displayServers.some(s => canManage(s.id))
|
||||
|
||||
@@ -377,8 +394,7 @@ function IpsecPage() {
|
||||
}, [displayServers, displayClients])
|
||||
|
||||
const handleInit = async (form: IpsecInitFormState) => {
|
||||
assertDeviceReady(form.serverId)
|
||||
setBusy(true)
|
||||
beginOperation(form.serverId)
|
||||
try {
|
||||
await initIpsecServer(backendUrl, {
|
||||
serverId: form.serverId,
|
||||
@@ -390,17 +406,17 @@ function IpsecPage() {
|
||||
clientDaysValid: 1825,
|
||||
createNatRule: form.createNatRule,
|
||||
})
|
||||
if (!mounted.current) return
|
||||
toast.success("IKEv2-сервер инициализирован")
|
||||
setInitOpen(false)
|
||||
await loadLive()
|
||||
await loadLive(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
endOperation()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async (form: IpsecUserFormState) => {
|
||||
assertDeviceReady(form.serverId)
|
||||
setBusy(true)
|
||||
beginOperation(form.serverId)
|
||||
try {
|
||||
const res = await createIpsecUser(backendUrl, {
|
||||
serverId: form.serverId,
|
||||
@@ -412,10 +428,12 @@ function IpsecPage() {
|
||||
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined,
|
||||
passphrase: form.passphrase.trim() || undefined,
|
||||
})
|
||||
if (!mounted.current) return
|
||||
toast.success(`Клиент «${form.name.trim()}» создан`)
|
||||
setUserOpen(false)
|
||||
setEditing(null)
|
||||
await loadLive()
|
||||
await loadLive(true)
|
||||
if (!mounted.current) return
|
||||
if (res.bundle) {
|
||||
certEpoch.current += 1
|
||||
certPending.current = false
|
||||
@@ -427,36 +445,37 @@ function IpsecPage() {
|
||||
setCertOpen(true)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
endOperation()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditUser = async (form: IpsecUserFormState) => {
|
||||
if (!editing) return
|
||||
assertDeviceReady(editing.serverId)
|
||||
setBusy(true)
|
||||
beginOperation(editing.serverId)
|
||||
try {
|
||||
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
|
||||
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
|
||||
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
|
||||
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
|
||||
})
|
||||
if (!mounted.current) return
|
||||
toast.success("Клиент обновлён")
|
||||
setUserOpen(false)
|
||||
setEditing(null)
|
||||
await loadLive()
|
||||
await loadLive(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
endOperation()
|
||||
}
|
||||
}
|
||||
|
||||
const loadCert = async (request: () => Promise<IpsecCertBundle>) => {
|
||||
const loadCert = async (serverId: string, request: () => Promise<IpsecCertBundle>) => {
|
||||
if (certPending.current) return
|
||||
certPending.current = true
|
||||
const epoch = ++certEpoch.current
|
||||
setCertBusy(true)
|
||||
setCertError(null)
|
||||
try {
|
||||
assertDeviceReady(serverId)
|
||||
const bundle = await request()
|
||||
if (epoch === certEpoch.current) setCertBundle(bundle)
|
||||
} catch (error) {
|
||||
@@ -475,7 +494,7 @@ function IpsecPage() {
|
||||
setCertBundle(null)
|
||||
setCertOpen(true)
|
||||
const passphrase = crypto.randomUUID()
|
||||
await loadCert(() => byName
|
||||
await loadCert(client.serverId, () => byName
|
||||
? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||
: exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase))
|
||||
}
|
||||
@@ -487,12 +506,12 @@ function IpsecPage() {
|
||||
setCertClient(null)
|
||||
setCertBundle(null)
|
||||
setCertOpen(true)
|
||||
await loadCert(() => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
|
||||
await loadCert(serverId, () => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
|
||||
}
|
||||
|
||||
const reexportCert = async (passphrase: string) => {
|
||||
if (!certClient && !certByName) return
|
||||
await loadCert(() => certByName
|
||||
await loadCert(certByName?.serverId ?? certClient!.serverId, () => certByName
|
||||
? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||
: exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase))
|
||||
}
|
||||
@@ -501,9 +520,9 @@ function IpsecPage() {
|
||||
if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return
|
||||
const target = pendingDelete
|
||||
const serverId = target.kind === "user" ? target.client.serverId : target.kind === "server" ? target.summary.serverId : target.serverId
|
||||
if (!canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
|
||||
if (operationLock.current || !canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
|
||||
beginOperation(serverId)
|
||||
deleteLock.current = true
|
||||
setBusy(true)
|
||||
setDeleteError(null)
|
||||
try {
|
||||
if (target.kind === "user") {
|
||||
@@ -515,16 +534,18 @@ function IpsecPage() {
|
||||
} else {
|
||||
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force })
|
||||
}
|
||||
if (!mounted.current) return
|
||||
toast.success("Удалено")
|
||||
setPendingDelete(null)
|
||||
setForceAllowed(false)
|
||||
await loadLive()
|
||||
await loadLive(true)
|
||||
} catch (error) {
|
||||
if (!mounted.current) return
|
||||
setDeleteError(error instanceof Error ? error.message : "Не удалось удалить")
|
||||
setForceAllowed(error instanceof ApiClientError && error.status === 409 && (target.kind === "peer" || target.kind === "cert"))
|
||||
} finally {
|
||||
deleteLock.current = false
|
||||
setBusy(false)
|
||||
endOperation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,8 +556,7 @@ function IpsecPage() {
|
||||
|
||||
const handleEditPeer = async (form: IpsecPeerFormState) => {
|
||||
if (!editingPeer) return
|
||||
assertDeviceReady(editingPeer.serverId)
|
||||
setBusy(true)
|
||||
beginOperation(editingPeer.serverId)
|
||||
try {
|
||||
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
|
||||
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
|
||||
@@ -547,12 +567,13 @@ function IpsecPage() {
|
||||
profile: form.profile.trim() || undefined,
|
||||
disabled: form.disabled,
|
||||
})
|
||||
if (!mounted.current) return
|
||||
toast.success("Peer обновлён")
|
||||
setPeerOpen(false)
|
||||
setEditingPeer(null)
|
||||
await loadLive()
|
||||
await loadLive(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
endOperation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,7 +694,7 @@ function IpsecPage() {
|
||||
<AlertTitle>{dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."}
|
||||
<Button size="sm" variant="outline" disabled={loading} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
|
||||
<Button size="sm" variant="outline" disabled={loading || busy || historyRestoring} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -850,7 +871,7 @@ function IpsecPage() {
|
||||
<IpsecInitSheet
|
||||
open={initOpen}
|
||||
onOpenChange={setInitOpen}
|
||||
servers={serverOptions}
|
||||
servers={serverOptions.filter(s => canManage(s.id) || busy)}
|
||||
busy={busy}
|
||||
defaultServerId={sheetServerId}
|
||||
onSubmit={handleInit}
|
||||
|
||||
@@ -25,12 +25,20 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.49 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.50 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.50 — замок и контекст операций IPsec (2026-09-30)
|
||||
|
||||
- Общий синхронный operationLock охватывает инициализацию, создание/редактирование клиентов/peer, удаление и восстановление до последующего чтения. Начало записи инвалидирует предыдущий опрос; внешний refresh при записи не отправляется. Восстановление и экспорт проверяют readiness устройства, ошибки остаются в своих панелях.
|
||||
- Mounted-guard отсекает поздние успехи записи после смены источника: нет старого toast, повторного чтения или открытия сертификата. Ошибка удаления после размонтирования также игнорируется. Уже отправленная операция RouterOS не отменяется и может завершиться на прежнем backend — отмена UI не является отменой сетевой операции.
|
||||
- Успешное удаление закрывает подтверждение даже при отказе последующего опроса; таблица сохраняет последние строки с явной ошибкой чтения и отключёнными действиями до повтора. Проверки не выдают это за неуспешное удаление.
|
||||
- 30 E2E прошли: 6 отдельных тестов backend/mode контекста, 23 регрессии IPsec/истории (включая GRE smoke) и новый сценарий замка/ошибки чтения. TypeScript и lint чистые. Отдельная конфигурация ipsec-context исключена из обычной и использует порт 3101 последовательно.
|
||||
- Далее — клиентская свежесть IPsec и профильные ПК/телефон экраны; затем оставшиеся VXLAN/GRE. Последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
|
||||
|
||||
## Блок 9.49 — readiness действий IPsec (2026-09-30)
|
||||
|
||||
- Кнопки клиентов и серверов используют readiness конкретного serverId: требуется успешное чтение, доступный inventory, включённое устройство, отсутствие его failure и текущей загрузки/операции. Сохранённые строки не превращаются в разрешение редактировать/удалять. Успешное устройство остаётся доступным при частичном отказе другого.
|
||||
|
||||
@@ -256,3 +256,11 @@ Live-копирование читает актуальный целевой rul
|
||||
- Формы создания получают только доступные устройства, уже выполняемый запрос сохраняет список. Submit и удаление дополнительно проверяют readiness перед API. Первоначальный/полный отказ блокирует создание. Refresh во время изменения/восстановления отключён. Серверные операции с неответившим устройством недоступны; просмотр истории не является разрешением восстановления.
|
||||
- Новый E2E проверяет начальный отказ, частичный отказ двух устройств, общий отказ и восстановление с нулём write-запросов; вместе с прежними операциями/таблицами прошло 15 E2E. TypeScript и lint чистые. Табличная IPsec-фикстура теперь содержит реальный inventory устройства вместо пустого списка.
|
||||
- Следующий блок: общий замок/контекст поздних операций, restore/export readiness и свежесть. Backend не менялся; последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
|
||||
|
||||
## Блок 9.50 — замок и контекст операций IPsec (2026-09-30)
|
||||
|
||||
- Общий синхронный operationLock охватывает инициализацию, создание/редактирование клиентов/peer, удаление и восстановление до последующего чтения. Начало записи инвалидирует предыдущий опрос; внешний refresh при записи не отправляется. Восстановление и экспорт проверяют readiness устройства, ошибки остаются в своих панелях.
|
||||
- Mounted-guard отсекает поздние успехи записи после смены источника: нет старого toast, повторного чтения или открытия сертификата. Ошибка удаления после размонтирования также игнорируется. Уже отправленная операция RouterOS не отменяется и может завершиться на прежнем backend — отмена UI не является отменой сетевой операции.
|
||||
- Успешное удаление закрывает подтверждение даже при отказе последующего опроса; таблица сохраняет последние строки с явной ошибкой чтения и отключёнными действиями до повтора. Проверки не выдают это за неуспешное удаление.
|
||||
- 30 E2E прошли: 6 отдельных тестов backend/mode контекста, 23 регрессии IPsec/истории (включая GRE smoke) и новый сценарий замка/ошибки чтения. TypeScript и lint чистые. Отдельная конфигурация ipsec-context исключена из обычной и использует порт 3101 последовательно.
|
||||
- Далее — клиентская свежесть IPsec и профильные ПК/телефон экраны; затем оставшиеся VXLAN/GRE. Последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts"],
|
||||
testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts"],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
|
||||
@@ -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: "ipsec-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" } },
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
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) => ({ servers: [], clients: [{ id: "1-*1", rosId: "*1", serverId: "1", serverName: "Роутер", name: port === "8101" ? "Новый источник" : "Старый источник", authMethod: "certificate", kind: "cert", online: false, disabled: false, managed: true }] })
|
||||
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/ipsec" && url.port === "8000") oldReads++
|
||||
return r.fulfill({ headers, json: path === "/api/ipsec" ? 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: /^IPsec/ }).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: /^IPsec/ }).click()
|
||||
if (kind === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник")
|
||||
else await expect(page.locator('tr[data-row-id="1-*1"]')).toHaveCount(0)
|
||||
}
|
||||
async function flush(page: Page) {
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
}
|
||||
for (const fails of [false, true]) {
|
||||
test(`IPsec: позднее чтение старого backend ${fails}`, async ({ page }) => {
|
||||
await setup(page)
|
||||
let release: (() => void) | undefined, started = false
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/api/ipsec", 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: { servers: [], clients: [] } })
|
||||
})
|
||||
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/ipsec"))
|
||||
release!()
|
||||
await (await response).finished()
|
||||
await flush(page)
|
||||
await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник")
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
for (const kind of ["backend", "mode"]) for (const fails of [false, true]) {
|
||||
test(`IPsec: позднее удаление после смены ${kind}/${fails}`, async ({ page }) => {
|
||||
const reads = await setup(page)
|
||||
let release: (() => void) | undefined, started = false
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/api/ipsec/users/1/*", async r => {
|
||||
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("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/ipsec/users/") && 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()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
test("IPsec: удаление держит замок до обновления и отделяет ошибку чтения", async ({ page }) => {
|
||||
const client = { id: "1-*1", rosId: "*1", serverId: "1", serverName: "Роутер", name: "Клиент тест", authMethod: "certificate", online: false, disabled: false, managed: true }
|
||||
let reads = 0, writes = 0, release: (() => void) | undefined, phase = 0
|
||||
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 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/ipsec") {
|
||||
reads++
|
||||
return phase === 1 ? r.fulfill({ status: 503, json: { error: "Опрос после удаления недоступен" } }) : r.fulfill({ json: { servers: [], clients: phase === 2 ? [] : [client] } })
|
||||
}
|
||||
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("/ipsec")
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows).toHaveCount(1)
|
||||
await page.getByRole("button", { 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)
|
||||
await expect(submit).toBeDisabled()
|
||||
release!()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toContainText("Опрос после удаления недоступен")
|
||||
await expect(rows).toHaveCount(1)
|
||||
await expect(rows.getByRole("button", { name: "Удалить клиента" })).toBeDisabled()
|
||||
phase = 2
|
||||
await page.getByRole("button", { name: "Повторить загрузку IPsec" }).click()
|
||||
await expect(rows).toHaveCount(0)
|
||||
await expect(page.getByText("Нет клиентов IKEv2", { exact: true })).toBeVisible()
|
||||
expect(writes).toBe(1)
|
||||
})
|
||||
Reference in new issue
Block a user