fix(mcp): включить проверяемый контроллер маршрутизации
This commit is contained in:
1 parent
6569dbfc60
commit
1f0704add0
25 files changed
+1041
-291
No files matched your search
@@ -0,0 +1,439 @@
|
||||
import { test, expect, type Page } from "@playwright/test"
|
||||
const id = "fa58fa40-4bcb-4616-9316-cb69463b3150"
|
||||
const at = "2026-10-17T00:00:00Z"
|
||||
const operation = { id: "logs.query", toolName: "mm_logs_query", title: "Поиск логов", section: "logs", version: 1, effect: "read", risk: "ordinary", scope: "servers", requiredPermissions: ["mm:servers:read"], implementation: "available", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }
|
||||
const connection = { id, name: "Диагностика WAN", ownerId: "ui-admin", policy: { id, version: 1, status: "active", mode: "approval", expiresAt: "2099-01-01T00:00:00Z", permissions: ["mm:servers:read"], scope: { serverIds: [1], operationIds: ["logs.query", "routing.profiles.save"], allowGlobal: false }, dangerousPolicy: "approval", autonomousDangerousOperationIds: [], limits: { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 } }, createdAt: at, updatedAt: at, lastUsedAt: null, generationValid: true }
|
||||
async function setup(page: Page, fail = false) {
|
||||
await page.addInitScript(() => { localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "ui-admin", apps: ["mm"], permissions: ["mm:settings:admin", "mm:servers:read", "mm:network:admin"], exp: 4102444800 })) + ".signature") })
|
||||
const calls: string[] = []
|
||||
await page.route("**/api/**", route => {
|
||||
const path = new URL(route.request().url()).pathname, method = route.request().method()
|
||||
calls.push(method + " " + path)
|
||||
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS", "access-control-allow-headers": "Authorization,Content-Type" }
|
||||
if (method === "OPTIONS") return route.fulfill({ headers, status: 204 })
|
||||
const json = (body: unknown) => route.fulfill({ headers, json: body })
|
||||
if (path === "/api/auth/config") return json({ required: false })
|
||||
if (path === "/api/mcp/catalog") return json({ stage: "runtime", executionAvailable: true, operations: [operation] })
|
||||
if (path === "/api/mcp/coverage") return json({ version: 1, routes: [{ method: "POST", path: "/api/geoip/update", status: "pending", operations: [], reason: "Нужен отдельный сервисный адаптер" }] })
|
||||
if (path === "/api/mcp/overview") return fail ? route.fulfill({ headers, status: 503, json: { error: "Аудит временно недоступен" } }) : json({ connections: 1, calls: 7, errors: 1, approvals: 1, running: 0, enabled: true, observedAt: at })
|
||||
if (path === "/api/mcp/connections") return json(method === "POST" ? { connection, token: "test-one-time-secret" } : { connections: [connection] })
|
||||
if (path === "/api/mcp/devices/catalog") return json({ servers: [{ id: 1, name: "CHR lab", host: "192.0.2.1", enabled: true }], resources: [{ id: "bridge", title: "Bridge", read: true, create: true, update: true, delete: true, fields: [{ key: "name", type: "string", requiredOnCreate: true }, { key: "comment", type: "string", requiredOnCreate: false }, { key: "disabled", type: "boolean", requiredOnCreate: false }] }] })
|
||||
if (path === "/api/mcp/devices/1/bridge/plan") return json({ planId: id, state: "awaiting-approval" })
|
||||
if (path === "/api/mcp/devices/1/bridge") return json({ items: [{ ".id": "*A", name: "lab-bridge", comment: "baseline", disabled: "true" }], total: 1, nextOffset: null })
|
||||
if (path.endsWith("/check")) return json({ ok: true, checks: { enabled: true, active: true, protocolHandshake: true }, checkedAt: at, protocolProbe: { status: "passed", elapsedMs: 12, credentialType: "temporary-no-operations", clientCredentialsVerified: false } })
|
||||
if (path.endsWith("/grants")) return json({ grants: [] })
|
||||
if (path === "/api/mcp/routing-profiles/plan") return json({ planId: id, state: "awaiting-approval" })
|
||||
if (path === "/api/mcp/routing-profiles") return json({ profiles: [{ id, connection_id: id, config: { name: "Community WAN", community: "65000:10", family: "ipv6", table: "main" }, state: { pausedReason: "Допуск контроллера истёк" }, enabled: false, grant_expires_at: at }] })
|
||||
if (path === "/api/mcp/protections") return json({ protections: [] })
|
||||
if (path === "/api/mcp/settings") return json({ enabled: true, requestsRetentionDays: 30, auditRetentionDays: 180 })
|
||||
if (path === "/api/mcp/management-channels") return json(method === "POST" ? { id, serverId: 1, expiresAt: at, verification: "identity-and-reachability", independence: "human-attestation" } : { channels: [] })
|
||||
if (path === `/api/mcp/operations/${id}/recover`) return json({ planId: id, state: "awaiting-approval" })
|
||||
if (path === `/api/mcp/operations/${id}/reconcile`) return json({ operationId: id, state: "applied", results: [], automaticRetry: false })
|
||||
if (path === "/api/mcp/oauth/clients") return json({ clients: [] })
|
||||
if (path.startsWith("/api/mcp/")) return json({ items: [], total: 0, nextCursor: null })
|
||||
if (path === "/api/servers") return json([])
|
||||
return json({})
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
test("MCP приостанавливает опрос по сигналу скрытия документа и возобновляет при показе", async ({ page }) => {
|
||||
const calls = await setup(page)
|
||||
const overviewCalls = () => calls.filter(call => call === "GET /api/mcp/overview").length
|
||||
await page.clock.install()
|
||||
await page.goto("/mcp?tab=overview")
|
||||
await expect.poll(overviewCalls).toBeGreaterThan(0)
|
||||
|
||||
const setDocumentHidden = (hidden: boolean) => page.evaluate(value => {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value })
|
||||
document.dispatchEvent(new Event("visibilitychange"))
|
||||
}, hidden)
|
||||
|
||||
await setDocumentHidden(true)
|
||||
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1_000))
|
||||
const before = overviewCalls()
|
||||
await page.clock.runFor(6_000)
|
||||
expect(overviewCalls()).toBe(before)
|
||||
|
||||
const resumed = page.waitForRequest(request =>
|
||||
request.method() === "GET" && new URL(request.url()).pathname === "/api/mcp/overview",
|
||||
)
|
||||
await setDocumentHidden(false)
|
||||
await resumed
|
||||
await expect.poll(overviewCalls).toBeGreaterThan(before)
|
||||
})
|
||||
|
||||
test("MCP журнал: заполненная квота объясняет удержание и показывает admin counters",async({page})=>{
|
||||
await setup(page)
|
||||
await page.route("**/api/mcp/overview",route=>route.fulfill({json:{connections:1,calls:7,errors:0,approvals:0,running:0,enabled:true,observedAt:at,pollStorageCapacity:{status:"observed",owner:{used:8,limit:8,remaining:0},global:{exhausted:true,usage:{used:128,limit:128,remaining:0}},retentionDays:14}}}))
|
||||
await page.goto("/mcp")
|
||||
const capacity=page.getByRole("region",{name:"Журнал полных опросов"})
|
||||
await expect(capacity.getByText("8 / 8",{exact:false})).toBeVisible()
|
||||
await expect(capacity.getByText("128 / 128",{exact:false})).toBeVisible()
|
||||
await expect(capacity.getByRole("alert")).toContainText("Личная и общая квоты журнала заполнены")
|
||||
await expect(capacity).toContainText("Счётчик учитывает резерв под выполнение и журналы, ожидающие архивирования")
|
||||
await expect(capacity).toContainText("Архив сохраняет все исходные записи и продолжает занимать место")
|
||||
await expect(capacity).toContainText("14 дн.")
|
||||
await capacity.screenshot({path:"test-results/mcp-poll-capacity-full.png"})
|
||||
})
|
||||
test("MCP журнал: non-admin global usage скрыт, ошибка обновления не сохраняет зелёный статус",async({page})=>{
|
||||
await setup(page)
|
||||
let failed=false
|
||||
await page.route("**/api/mcp/overview",route=>route.fulfill(failed?{status:503,json:{error:"Аудит временно недоступен"}}:{json:{connections:1,calls:0,errors:0,approvals:0,running:0,enabled:true,observedAt:at,pollStorageCapacity:{status:"observed",owner:{used:1,limit:8,remaining:7},global:{exhausted:false,usage:null},retentionDays:14}}}))
|
||||
await page.goto("/mcp")
|
||||
const capacity=page.getByRole("region",{name:"Журнал полных опросов"})
|
||||
await expect(capacity).toContainText("Квота не заполнена")
|
||||
await expect(capacity).not.toContainText("128")
|
||||
failed=true;await capacity.getByRole("button",{name:"Обновить статус"}).click()
|
||||
await expect(capacity.getByRole("status")).toContainText("Свободное место не подтверждено")
|
||||
await expect(capacity).not.toContainText("Квота не заполнена")
|
||||
})
|
||||
test("MCP журнал: на мобильном общая квота блокирует запуск при свободной личной",async({page})=>{
|
||||
await setup(page);await page.setViewportSize({width:390,height:844})
|
||||
await page.route("**/api/mcp/overview",route=>route.fulfill({json:{connections:1,calls:0,errors:0,approvals:0,running:0,enabled:true,observedAt:at,pollStorageCapacity:{status:"observed",owner:{used:1,limit:8,remaining:7},global:{exhausted:true,usage:null},retentionDays:14}}}))
|
||||
await page.goto("/mcp")
|
||||
const capacity=page.getByRole("region",{name:"Журнал полных опросов"})
|
||||
await expect(capacity.getByRole("alert")).toContainText("Общая квота журнала заполнена")
|
||||
await expect(capacity).toContainText("Осталось: 7")
|
||||
expect(await capacity.evaluate(element=>element.scrollWidth<=element.clientWidth)).toBe(true)
|
||||
await capacity.screenshot({path:"test-results/mcp-poll-capacity-mobile.png"})
|
||||
})
|
||||
test("Route AI: самостоятельная проба имеет отдельное назначение и не разрешает Community", async ({ page }) => {
|
||||
await setup(page)
|
||||
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Authorization,Content-Type" }
|
||||
const config = { name: "Проверка назначения", serverId: 1, community: "65000:10", family: "ipv4", table: "main", probeVrf: "main", candidates: [{ id: "probe", wanInterface: "wan", downstreamInterface: "down", jumpHostId: 2, exitNodeId: 3, gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*r" }, controlProbe: { targetInterface: "exit", expectedHops: [{ serverId: 3, interface: "exit" }] } }] }
|
||||
await page.route("**/api/mcp/routing-profiles", route => route.fulfill({ headers, json: { profiles: [{ id, connection_id: id, config, enabled: false }] } }))
|
||||
await page.route(`**/api/mcp/routing-profiles/${id}/check`, route => {
|
||||
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers })
|
||||
return route.fulfill({ headers, json: { proofPurpose: route.request().postDataJSON().proofPurpose, status: "verified", returnPathVerified: false } })
|
||||
})
|
||||
await page.goto("/mcp?tab=routing")
|
||||
await page.getByLabel("Профиль для проверки пути").selectOption(id)
|
||||
await page.getByLabel("Проверяемый путь").selectOption("probe")
|
||||
const purpose = page.getByLabel("Назначение проверки")
|
||||
await expect(purpose).toHaveValue("community")
|
||||
for (const value of ["community", "control-path"] as const) {
|
||||
await purpose.selectOption(value)
|
||||
const sent = page.waitForRequest(request => request.method() === "POST" && request.url().endsWith(`/api/mcp/routing-profiles/${id}/check`))
|
||||
await page.getByRole("button", { name: "Проверить контрольный путь", exact: true }).click()
|
||||
expect((await sent).postDataJSON()).toEqual({ candidateId: "probe", proofPurpose: value })
|
||||
await expect(page.locator("pre[role=status]").filter({ hasText: '"proofPurpose"' })).toContainText(`"${value}"`)
|
||||
}
|
||||
await expect(page.getByText("Результат не разрешает переключение Community", { exact: false })).toBeVisible()
|
||||
})
|
||||
|
||||
test("Route AI: автовосстановление блокирует активацию, но позволяет сохранить выключенный профиль", async ({ page }) => {
|
||||
await setup(page)
|
||||
const config = { name: "Профиль восстановления", serverId: 1, community: "65000:10", family: "ipv4", table: "main", probeVrf: "main", autoRecovery: true, candidates: [{ id: "probe", wanInterface: "wan", downstreamInterface: "down", jumpHostId: 2, exitNodeId: 3, gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*r" }, controlProbe: { targetInterface: "exit", expectedHops: [{ serverId: 3, interface: "exit" }] } }] }
|
||||
await page.route("**/api/mcp/routing-profiles", route => route.fulfill({ json: { profiles: [{ id, connection_id: id, config, enabled: true }] } }))
|
||||
await page.goto("/mcp?tab=routing")
|
||||
await page.getByRole("cell", { name: config.name, exact: true }).click()
|
||||
await page.getByRole("button", { name: "Изменить профиль", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog"), save = dialog.getByRole("button", { name: "Подготовить план профиля", exact: true })
|
||||
await expect(dialog.getByRole("status")).toContainText("автоматическое восстановление требует отдельного подтверждения")
|
||||
await expect(save).toBeDisabled()
|
||||
await dialog.getByRole("checkbox", { name: "Разрешить работу контроллера после применения плана", exact: true }).uncheck()
|
||||
await expect(save).toBeEnabled()
|
||||
const sent = page.waitForRequest(request => request.method() === "POST" && request.url().endsWith("/api/mcp/routing-profiles/plan"))
|
||||
await save.click()
|
||||
const input = (await sent).postDataJSON()
|
||||
expect(input.enabled).toBe(false)
|
||||
expect(input.profile.autoRecovery).toBe(true)
|
||||
})
|
||||
test("MCP: доступ, проверка подключения, выдача токена и состояние Route AI", async ({ page }) => {
|
||||
test.setTimeout(90000)
|
||||
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
|
||||
const calls = await setup(page)
|
||||
await page.goto("/mcp")
|
||||
await expect(page.getByRole("heading", { name: "MCP", exact: true })).toBeVisible({ timeout: 60000 })
|
||||
await page.getByRole("tab", { name: "Подключения", exact: true }).click()
|
||||
await expect(page.getByText("Диагностика WAN", { exact: true }).first()).toBeVisible()
|
||||
await page.getByLabel("Подключение для проверки").selectOption(id)
|
||||
await page.getByRole("button", { name: "Проверить готовность доступа" }).click()
|
||||
await expect.poll(() => calls.includes(`POST /api/mcp/connections/${id}/check`)).toBe(true)
|
||||
const readiness = page.locator("pre").filter({ hasText: "protocolProbe" })
|
||||
await expect(readiness).toContainText('"protocolHandshake": true')
|
||||
await expect(readiness).toContainText('"clientCredentialsVerified": false')
|
||||
await page.getByRole("button", { name: "Выдать доступ", exact: true }).click()
|
||||
await page.getByLabel("Название", { exact: true }).fill("Новый AI клиент")
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByLabel("ID разрешённых серверов через запятую").fill("1")
|
||||
await page.getByRole("checkbox", { name: "Поиск логов", exact: true }).check()
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Сохранить доступ", exact: true }).click()
|
||||
await expect(page.getByLabel("Одноразовый токен MCP")).toHaveValue("test-one-time-secret")
|
||||
await page.getByRole("button", { name: "Закрыть и скрыть токен" }).click()
|
||||
await expect(page.getByLabel("Одноразовый токен MCP")).toHaveCount(0)
|
||||
await page.getByRole("tab", { name: "Route AI", exact: true }).click()
|
||||
await expect(page.getByText("Допуск контроллера истёк", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: "test-results/mcp-route-ai.png", fullPage: true })
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
test("MCP: ошибки остаются видимыми, узкий экран не переполняется", async ({ page }) => {
|
||||
await setup(page, true)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto("/mcp")
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Аудит временно недоступен" })).toBeVisible()
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
})
|
||||
test("Route AI: редактор создаёт план с конкретными путями и SLA", async ({ page }) => {
|
||||
const calls = await setup(page)
|
||||
await page.goto("/mcp?tab=routing")
|
||||
await page.getByRole("button", { name: "Создать профиль", exact: true }).click()
|
||||
await page.getByLabel("Подключение контроллера").selectOption(id)
|
||||
await page.getByLabel("Название профиля").fill("WAN Community")
|
||||
await page.getByLabel("ID домашнего сервера").fill("1")
|
||||
await page.getByLabel("Community", { exact: true }).fill("65000:20")
|
||||
await page.getByLabel("Таблица маршрутизации", { exact: true }).fill("customer-routes")
|
||||
await page.getByLabel("VRF диагностических проб", { exact: true }).fill("probe-only")
|
||||
await page.getByRole("checkbox", { name: "Привязать профиль к конкретному BGP-подключению RouterOS", exact: true }).check()
|
||||
await page.getByLabel("ID BGP-подключения RouterOS", { exact: true }).fill("*A")
|
||||
await page.getByLabel("Экземпляр BGP", { exact: true }).fill("edge-instance")
|
||||
await page.getByLabel("Входящая цепочка BGP", { exact: true }).fill("customer-in")
|
||||
await page.getByLabel("Транспортная VRF BGP", { exact: true }).fill("bgp-transport")
|
||||
await expect(page.getByRole("status").filter({ hasText: "контрольную пробу с исходным адресом" })).toContainText("Профиль можно сохранить")
|
||||
await page.getByRole("checkbox", { name: "Разрешить работу контроллера после применения плана", exact: true }).check()
|
||||
await expect(page.getByRole("button", { name: "Подготовить план профиля", exact: true })).toBeDisabled()
|
||||
await page.getByLabel("WAN-интерфейс дома").fill("ether1")
|
||||
await page.getByLabel("ID JumpHost").fill("2")
|
||||
await page.getByLabel("ID ExitNode").fill("3")
|
||||
await page.getByLabel("Интерфейс JumpHost к ExitNode").fill("gre1")
|
||||
await page.getByLabel("Адрес gateway").fill("192.0.2.1")
|
||||
await page.getByLabel("ID шлюза на устройстве").fill("*1")
|
||||
await page.getByLabel("Максимум RTT, мс").fill("80")
|
||||
await page.getByRole("checkbox", { name: "Включить контроль пути 1", exact: true }).check()
|
||||
await page.getByLabel("Контрольный интерфейс ExitNode пути 1").fill("loopback")
|
||||
await page.getByLabel("Исходный IP контрольной пробы пути 1").fill("192.0.2.10")
|
||||
await page.getByLabel("Интерфейс hop 1 пути 1").fill("wan-in")
|
||||
await page.getByLabel("Интерфейс hop 2 пути 1").fill("loopback")
|
||||
await page.screenshot({ path: "test-results/mcp-route-profile-editor.png", fullPage: true })
|
||||
const sent = page.waitForRequest(request => request.method() === "POST" && request.url().includes("/api/mcp/routing-profiles/plan"))
|
||||
await page.getByRole("button", { name: "Подготовить план профиля" }).click()
|
||||
const body = (await sent).postDataJSON()
|
||||
expect(body.profile.candidates[0].jumpHostId).toBe(2)
|
||||
expect(body.profile.maxPingMs).toBe(80)
|
||||
expect(body.profile.table).toBe("customer-routes")
|
||||
expect(body.profile.probeVrf).toBe("probe-only")
|
||||
expect(body.profile.bgpContext).toEqual({ connectionId: "*A", instance: "edge-instance", inputChain: "customer-in", vrf: "bgp-transport" })
|
||||
expect(body.profile.candidates[0].controlProbe).toEqual({ targetInterface: "loopback", sourceAddress: "192.0.2.10", expectedHops: [{ serverId: 2, interface: "wan-in" }, { serverId: 3, interface: "loopback" }] })
|
||||
expect(body.enabled).toBe(true)
|
||||
await expect(page.getByText(/Создан план/)).toBeVisible()
|
||||
expect(calls).toContain("POST /api/mcp/routing-profiles/plan")
|
||||
})
|
||||
test("Route AI: редактирование сохраняет контексты и скрытые настройки профиля", async ({ page }) => {
|
||||
await setup(page)
|
||||
const config = {
|
||||
name: "Scoped community", serverId: 1, community: "65000:20", family: "ipv4", table: "customer-routes", probeVrf: "probe-only",
|
||||
bgpContext: { connectionId: "*B", instance: "edge-instance", inputChain: "customer-in", vrf: "bgp-transport" },
|
||||
pingFreshnessSec: 180, bandwidthFreshnessSec: 7200, hysteresis: 12, confirmationSamples: 4, maxSwitchesPerHour: 2,
|
||||
candidates: [{ id: "edge", wanInterface: "ether1", jumpHostId: 2, exitNodeId: 3, downstreamInterface: "gre1", gateway: "192.0.2.1", gatewayRef: { resource: "gre", id: "*1" }, wanRemoteInterface: "wan-in" }],
|
||||
}
|
||||
await page.route("**/api/mcp/routing-profiles", route => route.fulfill({ json: { profiles: [{ id, connection_id: id, config, enabled: true }] } }))
|
||||
await page.goto("/mcp?tab=routing")
|
||||
await page.getByRole("cell", { name: config.name, exact: true }).click()
|
||||
await page.getByRole("button", { name: "Изменить профиль", exact: true }).click()
|
||||
const enableController = page.getByRole("checkbox", { name: "Разрешить работу контроллера после применения плана", exact: true })
|
||||
await expect(enableController).toBeChecked()
|
||||
await expect(page.getByRole("button", { name: "Подготовить план профиля", exact: true })).toBeDisabled()
|
||||
await enableController.uncheck()
|
||||
await expect(page.getByLabel("VRF диагностических проб", { exact: true })).toHaveValue("probe-only")
|
||||
await expect(page.getByLabel("Транспортная VRF BGP", { exact: true })).toHaveValue("bgp-transport")
|
||||
await page.getByLabel("Название профиля", { exact: true }).fill("Renamed community")
|
||||
const sent = page.waitForRequest(request => request.method() === "POST" && request.url().endsWith("/api/mcp/routing-profiles/plan"))
|
||||
await page.getByRole("button", { name: "Подготовить план профиля", exact: true }).click()
|
||||
const body = (await sent).postDataJSON()
|
||||
expect(body.profileId).toBe(id)
|
||||
expect(body.profile).toMatchObject({ ...config, name: "Renamed community" })
|
||||
expect(body.enabled).toBe(false)
|
||||
})
|
||||
|
||||
test("Route AI: неполная BGP-привязка не отправляется, снятие привязки явно удаляет контекст", async ({ page }) => {
|
||||
const calls = await setup(page)
|
||||
const config = { name: "BGP scope", serverId: 1, community: "65000:20", family: "ipv4", table: "main", probeVrf: "main", bgpContext: { connectionId: "*B", instance: "edge-instance", inputChain: "bgp-in", vrf: "main" }, candidates: [{ id: "edge", wanInterface: "ether1", jumpHostId: 2, exitNodeId: 3, downstreamInterface: "gre1", gateway: "192.0.2.1", gatewayRef: { resource: "gre", id: "*1" } }] }
|
||||
await page.route("**/api/mcp/routing-profiles", route => route.fulfill({ json: { profiles: [{ id, connection_id: id, config, enabled: false }] } }))
|
||||
await page.goto("/mcp?tab=routing")
|
||||
await page.getByRole("cell", { name: config.name, exact: true }).click()
|
||||
await page.getByRole("button", { name: "Изменить профиль", exact: true }).click()
|
||||
await page.getByLabel("Экземпляр BGP", { exact: true }).fill("")
|
||||
await page.getByRole("button", { name: "Подготовить план профиля", exact: true }).click()
|
||||
await expect(page.getByRole("dialog").getByRole("alert")).toBeVisible()
|
||||
expect(calls.filter(call => call === "POST /api/mcp/routing-profiles/plan")).toHaveLength(0)
|
||||
await page.getByRole("checkbox", { name: "Привязать профиль к конкретному BGP-подключению RouterOS", exact: true }).uncheck()
|
||||
const sent = page.waitForRequest(request => request.method() === "POST" && request.url().endsWith("/api/mcp/routing-profiles/plan"))
|
||||
await page.getByRole("button", { name: "Подготовить план профиля", exact: true }).click()
|
||||
const body = (await sent).postDataJSON()
|
||||
expect(body.profile).not.toHaveProperty("bgpContext")
|
||||
expect(body.profile.probeVrf).toBe("main")
|
||||
expect(body.enabled).toBe(false)
|
||||
})
|
||||
|
||||
test("MCP: независимое управление скрывает пароль после регистрации", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.goto("/mcp?tab=settings")
|
||||
await expect(page.getByText("/api/geoip/update", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Ожидают реализации: 1", { exact: true })).toBeVisible()
|
||||
await page.getByLabel("ID сервера независимого канала").fill("1")
|
||||
await page.getByLabel("IP независимого управления").fill("192.0.2.25")
|
||||
await page.getByLabel("Пользователь независимого канала").fill("lab")
|
||||
await page.getByLabel("Пароль независимого канала").fill("fixture-no-real-secret")
|
||||
await page.getByLabel("Подтверждение независимости пути").fill("Отдельный management-интерфейс и отдельная сеть лабораторного стенда")
|
||||
const request = page.waitForRequest(request => request.method() === "POST" && request.url().endsWith("/api/mcp/management-channels"))
|
||||
await page.getByRole("button", { name: "Проверить и зарегистрировать канал" }).click()
|
||||
expect((await request).postDataJSON().verifySsl).toBe(true)
|
||||
await expect(page.getByLabel("Пароль независимого канала")).toHaveValue("")
|
||||
})
|
||||
test("MCP: восстановление создаёт отдельный план без автоматического применения", async ({ page }) => {
|
||||
const calls = await setup(page)
|
||||
await page.goto("/mcp?tab=plans")
|
||||
await page.getByLabel("ID исходной операции").fill(id)
|
||||
await page.getByRole("button", { name: "Подготовить обратный план" }).click()
|
||||
await expect(page.getByRole("status").filter({ hasText: "awaiting-approval" })).toBeVisible()
|
||||
expect(calls.filter(call => call.endsWith("/apply"))).toEqual([])
|
||||
})
|
||||
test("MCP: фильтры обращений сохраняются в URL и применяются сервером", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.goto("/mcp?tab=requests&requests.tool=mm_logs_query&requests.serverId=1")
|
||||
await expect(page.getByLabel("Имя инструмента")).toHaveValue("mm_logs_query")
|
||||
await expect(page.getByLabel("Фильтр ID сервера")).toHaveValue("1")
|
||||
const filtered = page.waitForRequest(request => { const url = new URL(request.url()); return url.pathname === "/api/mcp/requests" && url.searchParams.get("userId") === "ui-admin" && url.searchParams.get("serverId") === "1" && url.searchParams.get("tool") === "mm_logs_query" })
|
||||
await page.getByLabel("Фильтр пользователя").fill("ui-admin")
|
||||
await filtered
|
||||
expect(new URL(page.url()).searchParams.get("requests.userId")).toBe("ui-admin")
|
||||
await page.reload()
|
||||
await expect(page.getByLabel("Фильтр пользователя")).toHaveValue("ui-admin")
|
||||
})
|
||||
|
||||
test("MCP: файл операции скачивается с JWT, безопасным путём и освобождением Blob URL", async ({ page }) => {
|
||||
await setup(page)
|
||||
const artifactPath = `/api/mcp/artifacts/${id}`
|
||||
const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "Authorization,Content-Type", "access-control-expose-headers": "Content-Disposition" }
|
||||
let downloads = 0
|
||||
await page.addInitScript(() => {
|
||||
const original = URL.revokeObjectURL
|
||||
Object.assign(window, { artifactUrlRevoked: false })
|
||||
URL.revokeObjectURL = url => { Object.assign(window, { artifactUrlRevoked: true }); original(url) }
|
||||
})
|
||||
await page.route("**/api/mcp/operations?*", route => route.fulfill({ headers, json: { items: [{ id, connection_id: id, state: "applied", artifactReceipts: [
|
||||
{ artifactId: id, humanDownloadOnly: true, downloadPath: artifactPath },
|
||||
{ artifactId: id, humanDownloadOnly: true, downloadPath: "https://unsafe.example/private" },
|
||||
{ artifactId: "../unsafe", humanDownloadOnly: true },
|
||||
] }], total: 1, nextCursor: null } }))
|
||||
await page.route(`**${artifactPath}`, async route => {
|
||||
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers })
|
||||
downloads++
|
||||
expect(route.request().headers().authorization).toMatch(/^Bearer header\./)
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
return route.fulfill({ body: Buffer.from([0, 255, 128, 1]), headers: { ...headers, "content-type": "application/octet-stream", "content-disposition": 'attachment; filename="backup.rsc"' } })
|
||||
})
|
||||
await page.goto("/mcp?tab=plans")
|
||||
const operations = page.locator("[data-slot=frame]").filter({ has: page.getByText("Выполнение операций", { exact: true }) })
|
||||
await operations.getByRole("button", { name: "Открыть", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: "Детали операции", exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Скачать файл", exact: true })).toHaveCount(1)
|
||||
const downloaded = page.waitForEvent("download")
|
||||
await dialog.getByRole("button", { name: "Скачать файл", exact: true }).click()
|
||||
await expect(dialog.getByRole("button", { name: "Скачивание…", exact: true })).toBeDisabled()
|
||||
expect((await downloaded).suggestedFilename()).toBe("backup.rsc")
|
||||
await expect(dialog.getByRole("status")).toContainText("Файл передан браузеру")
|
||||
await expect.poll(() => page.evaluate(() => Reflect.get(window, "artifactUrlRevoked"))).toBe(true)
|
||||
expect(downloads).toBe(1)
|
||||
expect(new URL(page.url()).pathname).toBe("/mcp")
|
||||
})
|
||||
|
||||
test("MCP: квитанция backup БД скачивает dump через общий путь артефактов", async ({ page }) => {
|
||||
await setup(page)
|
||||
const artifactPath = `/api/mcp/artifacts/${id}`
|
||||
const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "Authorization,Content-Type", "access-control-expose-headers": "Content-Disposition" }
|
||||
await page.route("**/api/mcp/operations?*", route => route.fulfill({ json: { items: [{ id, connection_id: id, operationId: "database.backup", state: "applied", databaseContentReturned: false, artifactReceipts: [{ artifactId: id, humanDownloadOnly: true, downloadPath: artifactPath }] }], total: 1, nextCursor: null } }))
|
||||
await page.route(`**${artifactPath}`, route => {
|
||||
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers })
|
||||
return route.fulfill({ body: Buffer.from("PGDMPfixture"), headers: { ...headers, "content-type": "application/octet-stream", "content-disposition": 'attachment; filename="mmapp-database.dump"' } })
|
||||
})
|
||||
await page.goto("/mcp?tab=plans")
|
||||
await page.locator("[data-slot=frame]").filter({ has: page.getByText("Выполнение операций", { exact: true }) }).getByRole("button", { name: "Открыть", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toContainText('"databaseContentReturned": false')
|
||||
const downloaded = page.waitForEvent("download")
|
||||
await dialog.getByRole("button", { name: "Скачать файл", exact: true }).click()
|
||||
expect((await downloaded).suggestedFilename()).toBe("mmapp-database.dump")
|
||||
await expect(dialog.getByRole("status")).toContainText("Файл передан браузеру")
|
||||
})
|
||||
|
||||
test("MCP: ошибки файла скрывают тело ответа; без JWT скачивание не начинается", async ({ page }) => {
|
||||
await setup(page)
|
||||
let status = 403, requests = 0
|
||||
const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "Authorization,Content-Type" }
|
||||
await page.route("**/api/mcp/operations?*", route => route.fulfill({ headers, json: { items: [{ id, connection_id: id, state: "applied", artifactReceipts: [{ artifactId: id, humanDownloadOnly: true }] }], total: 1, nextCursor: null } }))
|
||||
await page.route(`**/api/mcp/artifacts/${id}`, route => { if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }); requests++; return route.fulfill({ status, headers, json: { error: "NEVER_RENDER_PROVIDER_SECRET" } }) })
|
||||
await page.goto("/mcp?tab=plans")
|
||||
await page.locator("[data-slot=frame]").filter({ has: page.getByText("Выполнение операций", { exact: true }) }).getByRole("button", { name: "Открыть", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog"), button = dialog.getByRole("button", { name: "Скачать файл", exact: true })
|
||||
for (const [code, message] of [[403, "Доступ к файлу отозван"], [404, "срок хранения истёк"], [409, "Файл изменился"]] as const) {
|
||||
status = code
|
||||
await button.click()
|
||||
await expect(dialog.getByRole("alert")).toContainText(message)
|
||||
await expect(dialog).not.toContainText("NEVER_RENDER_PROVIDER_SECRET")
|
||||
await expect(button).toBeEnabled()
|
||||
}
|
||||
await page.evaluate(() => localStorage.removeItem("mmapp_token"))
|
||||
await button.click()
|
||||
await expect(dialog.getByRole("alert")).toContainText("Войдите в портал")
|
||||
expect(requests).toBe(3)
|
||||
})
|
||||
|
||||
test("MCP: изменение интерфейса готовит план только с изменёнными свойствами", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.goto("/mcp?tab=devices")
|
||||
await page.getByLabel("Подключение для управления интерфейсами").selectOption(id)
|
||||
await page.getByLabel("Сервер интерфейсов").selectOption("1")
|
||||
await page.getByLabel("Тип объектов устройства").selectOption("bridge")
|
||||
await page.getByText("lab-bridge", { exact: true }).click()
|
||||
await page.getByRole("textbox", { name: "comment", exact: true }).fill("reviewed change")
|
||||
const sent = page.waitForRequest(request => request.method() === "POST" && request.url().includes("/api/mcp/devices/1/bridge/plan"))
|
||||
await page.getByRole("button", { name: "Подготовить план изменения", exact: true }).click()
|
||||
expect((await sent).postDataJSON()).toEqual({ connectionId: id, action: "update", id: "*A", fields: { comment: "reviewed change" } })
|
||||
await expect(page.getByRole("status").filter({ hasText: "Создан план" })).toBeVisible()
|
||||
await page.screenshot({ path: "test-results/mcp-device-editor.png", fullPage: true })
|
||||
})
|
||||
|
||||
test("MCP: архив БД загружает человек, подготовка плана не запускает restore", async ({ page }) => {
|
||||
const calls = await setup(page)
|
||||
await page.route("**/api/mcp/connections", route => route.fulfill({ json: { connections: [{ ...connection, policy: { ...connection.policy, permissions: ["mm:settings:admin"], scope: { serverIds: [1], operationIds: ["database.restore"], allowGlobal: true } } }] } }))
|
||||
await page.route("**/api/mcp/database/**", route => {
|
||||
calls.push(route.request().method() + " " + new URL(route.request().url()).pathname)
|
||||
if (route.request().url().endsWith("/archives")) return route.fulfill({ json: { archiveRef: id, sha256: "a".repeat(64), sizeBytes: 12, expiresAt: "2099-01-01T00:00:00Z" } })
|
||||
if (route.request().url().endsWith("/plan")) return route.fulfill({ json: { planId: id, state: "awaiting-approval" } })
|
||||
return route.fulfill({ json: { operationId: id, state: "applied", accessRevoked: true, automaticRetry: false } })
|
||||
})
|
||||
await page.goto("/mcp?tab=settings")
|
||||
await page.getByLabel("Подключение для восстановления").selectOption(id)
|
||||
await page.getByLabel("Архив PostgreSQL, до 64 МиБ").setInputFiles({ name: "test.dump", mimeType: "application/octet-stream", buffer: Buffer.from("PGDMPfixture") })
|
||||
const uploaded = page.waitForRequest(request => request.url().endsWith("/api/mcp/database/archives"))
|
||||
await page.getByRole("button", { name: "Загрузить архив", exact: true }).click()
|
||||
expect((await uploaded).headers()["content-type"]).toBe("application/octet-stream")
|
||||
await expect(page.getByRole("button", { name: "Подготовить план восстановления БД" })).toBeEnabled()
|
||||
const plan = page.waitForRequest(request => request.url().endsWith("/api/mcp/database/plan"))
|
||||
await page.getByRole("button", { name: "Подготовить план восстановления БД" }).click()
|
||||
expect((await plan).postDataJSON()).toEqual({ connectionId: id, archiveRef: id, expectedSha256: "a".repeat(64) })
|
||||
await expect(page.getByRole("status").filter({ hasText: "awaiting-approval" })).toBeVisible()
|
||||
expect(calls.some(call => call.endsWith("/apply"))).toBe(false)
|
||||
await page.getByLabel("ID операции восстановления БД").fill(id)
|
||||
await page.getByRole("button", { name: "Прочитать внешнюю квитанцию" }).click()
|
||||
await expect(page.getByRole("status").filter({ hasText: '"accessRevoked": true' })).toBeVisible()
|
||||
})
|
||||
|
||||
test("MCP: обзор плана показывает исходное состояние, зависимости и шаги до подтверждения", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.route(url => url.pathname === "/api/mcp/plans", route => route.fulfill({ json: { items: [{ id, operationId: "routing.community.switch", state: "awaiting-approval", createdAt: at, expiresAt: "2099-01-01T00:00:00Z", changes: { target: { scope: "servers", serverIds: [1, 2] }, objects: [{ kind: "gre", id: "*A", serverId: 1 }], before: { gateway: "192.0.2.1" }, steps: [{ kind: "service", service: "routing.community", input: { gateway: "192.0.2.2" } }], warnings: ["Изменится gateway выбранного Community"] } }], total: 1, nextCursor: null } }))
|
||||
await page.goto("/mcp?tab=plans")
|
||||
await page.getByText("routing.community.switch", { exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: "Исходное состояние" })).toBeVisible()
|
||||
await expect(dialog.getByRole("heading", { name: "Запланированные шаги" })).toBeVisible()
|
||||
await expect(dialog.getByText("Изменится gateway выбранного Community", { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByText("Серверы: 1, 2", { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Подтвердить план", exact: true })).toBeVisible()
|
||||
await dialog.screenshot({ path: "test-results/mcp-plan-review.png" })
|
||||
})
|
||||
Reference in new issue
Block a user