Files
MikrotikManager/tests/e2e/vxlan-links.spec.ts
T

235 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from "@playwright/test"
async function setup(page: Page, url: string, failed = 0) {
const control = { payload: undefined as unknown, malformed: false, partial: false, failed, posts: [] as { path: string; body: unknown }[] }
const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", sessions:0, wanUplinks: [] }]
const tunnels = ["1", "2"].flatMap(serverId => ["*/2", "other"].map(id => ({ id, serverId, name: "vx-same", vni: Number(serverId) * 100 + (id === "other" ? 1 : 0), port: 0, dstPort: 8472, vtepIp: "192.0.2.1", remoteVteps: ["192.0.2.22"], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "down" })))
servers.push({ ...servers[0], id: 2, name: "Второй сервер", host: "192.0.2.2" });
const probes = ["p1", "p/2"].map(id => ({ id, srcServerId: "1", name: "Проверка сети", target: "192.0.2.10", filter: "main", rtt: null, loss: 100, status: "down", series: [0], enabled: true, showOnDashboard: false }))
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const request = route.request()
const path = new URL(request.url()).pathname
if (!["GET", "HEAD"].includes(request.method())) control.posts.push({ path, body: request.postDataJSON() })
if (path === "/api/vxlan" && control.payload !== undefined) return route.fulfill({ json: control.payload })
if (path === "/api/vxlan" && control.failed) return route.fulfill({ status: control.failed, json: { error: "Мониторинг недоступен" } })
if (path === "/api/vxlan" && control.malformed) return route.fulfill({ json: {} })
const json = path === "/api/auth/config" ? { required: false }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
: path === "/api/servers" ? servers
: path === "/api/uptime/overview" ? { probes:probes.map(p=>({...p,observations:{samples:[{sampledAt:"2026-09-20T00:00:00Z",rtt:null,loss:100,status:"down"}]}})),rangeStartAt:"2026-09-19T23:00:00Z",rangeEndAt:"2026-09-20T00:00:00Z",resources: [] }
: path === "/api/uptime/speed-probes" ? { probes: [] }
: path === "/api/uptime/speed-test/runs" ? { runs: [] }
: path === "/api/bgp/catalog" ? {sessions:[],failures:[]}
: path === "/api/vxlan" ? { tunnels: control.partial ? tunnels.filter(t => t.serverId === "1") : tunnels, failures: control.partial ? [{ serverId: "2", serverName: "Второй сервер", error: "offline" }] : [] }
: path === "/api/filters/gre-tunnels" ? { tunnels: [],failures:[] }
: path === "/api/wireguard" ? { interfaces: [], peers: [],failures:[] }
: path === "/api/traffic/servers" ? { servers: [] }
: path === "/api/events" ? { events: [] }
: path === "/api/internet-path/latest" ? { snapshot: { collectionComplete:true,sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} } }
: {}
return route.fulfill({ json })
})
await page.goto(url)
return control
}
test("VXLAN: переход из очереди выбирает сервер и точный ID без записи", async ({ page }) => {
const control = await setup(page, "/dashboard")
const link = page.getByRole("region", { name: "Требует внимания", exact: true }).locator('a[href="/vxlan?server=2&tunnel=*%2F2"]')
await expect(link).toBeVisible()
await link.click()
const context = page.getByRole("status", { name: "VXLAN по ссылке" })
await expect(context).toContainText("Второй сервер")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByRole("status", { name: "VXLAN по ссылке" })).toContainText("VNI 200")
await page.screenshot({ path: "test-results/vxlan-link-desktop.png", animations: "disabled" })
await page.getByRole("button", { name: "Действия: vx-same", exact: true }).click()
await page.getByRole("menuitem", { name: "Экспорт .rsc", exact: true }).click()
await expect(page.getByRole("dialog")).toContainText("Второй сервер")
await expect(page.getByRole("dialog")).toContainText("vni=200")
await page.keyboard.press("Escape")
expect(control.posts).toEqual([])
await context.getByRole("link", { name: "К дашборду", exact: true }).click()
await expect(link).toBeVisible()
})
test("VXLAN: прямая ссылка, сброс, назад, reload и ошибка обновления", async ({ page }) => {
const control = await setup(page, "/vxlan?server=2&tunnel=*%2F2&keep=1")
const context = page.getByRole("status", { name: "VXLAN по ссылке" })
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
await context.getByRole("link", { name: "Все туннели сервера", exact: true }).click()
await expect(page).toHaveURL(/server=2&keep=1$/)
await expect(rows).toHaveCount(2)
await page.goBack()
await expect(rows).toHaveCount(1)
await page.reload()
await expect(context).toContainText("Второй сервер")
control.failed = 503
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(context).toContainText("сохранённые данные могут быть устаревшими")
await expect(rows).toHaveCount(1)
control.failed = 0
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(context).toContainText("Список и сводка ограничены")
expect(control.posts).toEqual([])
await page.setViewportSize({ width: 390, height: 844 })
await context.scrollIntoViewIfNeeded()
const box = await context.boundingBox()
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
await page.screenshot({ path: "test-results/vxlan-link-mobile.png", animations: "disabled" })
})
test("VXLAN: ручной выбор сервера снимает выбор туннеля и сохраняет query", async ({ page }) => {
const control = await setup(page, "/vxlan?server=2&tunnel=*%2F2&keep=1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await page.getByRole("option").filter({ hasText: "Источник" }).click()
await expect(page).toHaveURL(/server=1&keep=1$/)
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
await expect(page.getByRole("status", { name: "VXLAN по ссылке" })).toHaveCount(0)
await page.goBack()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByRole("status", { name: "VXLAN по ссылке" })).toContainText("VNI 200")
expect(control.posts).toEqual([])
})
for (const query of ["server=missing&tunnel=*%2F2", "server=2&tunnel=missing", "tunnel=*%2F2"]) {
test("VXLAN: нет подмены объекта " + query, async ({ page }) => {
const control = await setup(page, "/vxlan?" + query)
await expect(page.getByRole("status", { name: "VXLAN по ссылке" })).toContainText("Туннель не получен для указанного сервера")
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
expect(control.posts).toEqual([])
})
}
for (const status of [403, 500]) {
test("VXLAN: ошибка API " + status, async ({ page }) => {
const control = await setup(page, "/vxlan?server=2&tunnel=*%2F2", status)
await expect(page.getByRole("status", { name: "VXLAN по ссылке" })).toContainText("Не удалось обновить VXLAN")
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
expect(control.posts).toEqual([])
})
}
for (const initiallyFailed of [true, false]) {
test(`VXLAN: повреждённый ответ сохраняет состояние ${initiallyFailed}`, async ({ page }) => {
const control = await setup(page, "/vxlan", initiallyFailed ? 503 : 0)
const rows = page.locator("tr[data-row-id]")
if (initiallyFailed) {
await expect(page.getByText("Ошибка загрузки:", { exact: false })).toBeVisible()
await expect(page.getByLabel("Сводка VXLAN")).toContainText("—")
} else await expect(rows).toHaveCount(4)
control.failed = 0
control.malformed = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByText("Ошибка загрузки: Некорректный ответ загрузки VXLAN", { exact: true })).toBeVisible()
await expect(rows).toHaveCount(initiallyFailed ? 0 : 4)
await expect(page.getByText("На опрошенных серверах нет VXLAN-интерфейсов", { exact: true })).toHaveCount(0)
control.malformed = false
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(rows).toHaveCount(4)
await expect(page.getByText("Ошибка загрузки:", { exact: false })).toHaveCount(0)
})
}
test("VXLAN: частичный сбой сохраняет туннели и неизвестный статус", async ({ page }) => {
const control = await setup(page, "/vxlan?server=2")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
control.partial = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не все устройства ответили" })).toContainText("Второй сервер")
await expect(rows).toHaveCount(2)
await expect(rows.first()).toContainText("Неизвестно")
await expect(page.getByLabel("Сводка VXLAN")).toContainText("—")
control.partial = false
await page.getByRole("button", { name: "Повторить опрос" }).click()
await expect(page.getByText("Не все устройства ответили", { exact: true })).toHaveCount(0)
await expect(rows.first()).toContainText("DOWN")
})
test("VXLAN: первый частичный сбой не выглядит пустым устройством", async ({ page }) => {
const control = await setup(page, "/vxlan?server=2")
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
control.partial = true
await page.reload()
await expect(page.getByText("Не все устройства ответили", { exact: true })).toBeVisible()
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await expect(page.getByText("На опрошенных серверах нет VXLAN-интерфейсов", { exact: true })).toHaveCount(0)
await expect(page.getByLabel("Сводка VXLAN")).toContainText("—")
})
test("VXLAN: повреждённые записи не заменяют последний список", async ({ page }) => {
const control = await setup(page, "/vxlan")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(4)
const valid = { id: "id", serverId: "1", name: "vx", vni: 1, port: 0, dstPort: 8472, vtepIp: "", remoteVteps: [], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" }
for (const payload of [
{ tunnels: [{ ...valid, remoteVteps: null }] },
{ tunnels: [{ ...valid, vni: 16777216 }] },
{ tunnels: [{ ...valid, status: "unknown" }] },
{ tunnels: [valid, valid] },
{ tunnels: [], failures: [{ serverId: "1", error: null }] },
{ tunnels: [valid], failures: [{ serverId: "1", error: "Конфликт" }] },
{ tunnels: [], failures: [{ serverId: "1", error: "Сбой" }, { serverId: "1", error: "Повтор" }] },
{ tunnels: [{ ...valid, arpMode: "enabled", arpProxy: true }] },
{ tunnels: [{ ...valid, mtu: 0 }] },
{ tunnels: [{ ...valid, mtu: "1400" }] },
]) {
control.payload = payload
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByText("Ошибка загрузки: Некорректный ответ загрузки VXLAN", { exact: true })).toBeVisible()
await expect(rows).toHaveCount(4)
}
control.payload = undefined
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByText("Ошибка загрузки:", { exact: false })).toHaveCount(0)
})
test("VXLAN: меню экспорта сохраняется при обновлении и использует последний MTU", async ({ page }) => {
const control = await setup(page, "/vxlan?server=1")
const tunnel = { id: "stable", serverId: "1", name: "vx-actions", vni: 100, port: 0, dstPort: 4789, vtepIp: "192.0.2.1", remoteVteps: [], l2mtu: 65535, mtu: 1400, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" }
control.payload = { tunnels: [tunnel] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await page.getByRole("button", { name: "Действия: vx-actions" }).click()
await expect(page.getByRole("menuitem", { name: "Экспорт .rsc" })).toBeVisible()
control.payload = { tunnels: [{ ...tunnel, mtu: 1380 }] }
await page.getByRole("button", { name: "Обновить", exact: true }).evaluate(button => (button as HTMLButtonElement).click())
await expect(page.locator("tr[data-row-id]")).toContainText("1380")
await page.getByRole("menuitem", { name: "Экспорт .rsc" }).click()
await expect(page.getByRole("dialog").locator("pre")).toContainText("mtu=1380")
})
for (const oldFails of [false, true]) {
test(`VXLAN: поздний ответ старого устройства игнорируется ${oldFails}`, async ({ page }) => {
await setup(page, "/vxlan?server=1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
let oldRelease: (() => void) | undefined
let newRelease: (() => void) | undefined
const oldGate = new Promise<void>(resolve => { oldRelease = resolve })
const newGate = new Promise<void>(resolve => { newRelease = resolve })
let attempts = 0
await page.route("**/api/vxlan", async route => {
attempts++
if (attempts === 1) { await oldGate; return route.fulfill(oldFails ? { status: 500, json: { error: "Поздняя ошибка тест" } } : { json: { tunnels: [],failures:[] } }) }
await newGate
return route.fallback()
})
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => attempts).toBe(1)
await page.getByRole("option").filter({ hasText: "Второй сервер" }).click()
await expect.poll(() => attempts).toBeGreaterThanOrEqual(2)
const response = page.waitForResponse(r => r.url().endsWith("/api/vxlan"))
oldRelease!()
await (await response).finished()
await page.waitForTimeout(100)
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeDisabled()
await expect(page.getByText("Ошибка загрузки:", { exact: false })).toHaveCount(0)
newRelease!()
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
await expect(page).toHaveURL(/server=2/)
})
}