From cbc59ef850109958b8627f1637b002678ebb0f7b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 15 Nov 2026 17:54:34 +0700 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BE=D1=82=D0=B7?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/mcp.test.ts | 35 +++++++ backend/src/routes/mcp.ts | 10 +- components/mcp/mcp-workspace.tsx | 149 ++++++++++++++++++++++++++--- packages/contracts/src/mcp.ts | 1 + shared/api/http-client.ts | 16 +++- tests/e2e/mcp-auth-context.spec.ts | 59 ++++++++++++ 6 files changed, 250 insertions(+), 20 deletions(-) diff --git a/backend/src/routes/mcp.test.ts b/backend/src/routes/mcp.test.ts index a21f6e15..5af9a652 100644 --- a/backend/src/routes/mcp.test.ts +++ b/backend/src/routes/mcp.test.ts @@ -180,6 +180,41 @@ try { assert.deepEqual(listed.json().profiles, [{ ...profile, state: { reason: profile.state.reason, candidateId: "wan-b", failureCount: 3 } }]) assert.ok(!listed.body.includes("NEVER_PRIVATE_")) } finally { pool.query = originalQuery } + const monitoredOperation = "58d8e404-7209-4fd3-88b3-91f9bed9425a" + const monitoringHeaders = { authorization: "Bearer " + await token(["mm:mcp:read"]) } + const monitoringUrl = `/api/mcp/operations?operationId=${monitoredOperation}&limit=1` + assert.equal((await app.inject({ url: monitoringUrl })).statusCode, 401) + let operationVisible = true + let stepReads = 0 + pool.query = (async (sql: string, values: unknown[]) => { + if (sql.includes("FROM mcp_operation_steps")) { + stepReads++ + assert.match(sql, /SELECT step_index,server_id,state,finished_at/) + assert.match(sql, /LIMIT 101$/) + assert.deepEqual(values, [monitoredOperation]) + return { rows: Array.from({ length: 101 }, (_, step_index) => ({ step_index, state: "verified" })) } + } + assert.match(sql, /c\.owner_id=\$2/) + assert.match(sql, /r\.id=\$3::uuid/) + if (sql.startsWith("SELECT count")) { + assert.deepEqual(values.slice(0, 3), [false, "human-admin", monitoredOperation]) + return { rows: [{ total: operationVisible ? 1 : 0 }] } + } + assert.match(sql, /FROM mcp_operations r/) + assert.deepEqual(values, [false, "human-admin", monitoredOperation, 2]) + return { rows: operationVisible ? [{ id: monitoredOperation, state: "partial" }] : [] } + }) as typeof pool.query + try { + const result = await app.inject({ url: monitoringUrl, headers: monitoringHeaders }) + assert.equal(result.statusCode, 200, result.body) + assert.equal(result.json().items[0].steps.length, 100) + assert.equal(result.json().items[0].stepsTruncated, true) + assert.equal(result.json().items[0].state, "partial") + operationVisible = false + assert.deepEqual((await app.inject({ url: monitoringUrl, headers: monitoringHeaders })).json(), { items: [], total: 0, nextCursor: null }) + assert.equal(stepReads, 1, "An inaccessible operation must not load its steps") + assert.equal((await app.inject({ url: monitoringUrl.replace("/operations", "/plans"), headers: monitoringHeaders })).statusCode, 400) + } finally { pool.query = originalQuery } env.authRequired = false assert.equal((await app.inject({ url: "/api/mcp/catalog" })).statusCode, 503) assert.equal((await app.inject({ method: "POST", url: "/api/mcp/policy/preview", payload, headers: admin })).statusCode, 503) diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index 63972d69..b4645630 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -238,6 +238,10 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options const owner = table === "drift" ? "EXISTS(SELECT 1 FROM mcp_operations o JOIN mcp_connections c ON c.id=o.connection_id WHERE o.id=r.operation_id AND c.owner_id=$2)" : "EXISTS(SELECT 1 FROM mcp_connections c WHERE c.id=r.connection_id AND c.owner_id=$2)" const clauses = [`($1::boolean OR ${owner})`] const bind = (value: unknown) => { values.push(value); return "$" + values.length } + if (query.operationId) { + if (table !== "operations") throw new McpError("INVALID_TARGET", "ID операции используется только в журнале операций", 400) + clauses.push(`r.id=${bind(query.operationId)}::uuid`) + } if (query.connectionId && table !== "drift") clauses.push(`r.connection_id=${bind(query.connectionId)}::uuid`) if (query.status && ["requests", "plans", "operations"].includes(table)) clauses.push(`r.${table === "requests" ? "status" : "state"}=${bind(query.status)}`) if (query.tool && table === "requests") clauses.push(`r.tool=${bind(query.tool)}`) @@ -254,8 +258,12 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options const count = await pool.query<{ total: number }>(`SELECT count(*)::int AS total FROM ${relation} r WHERE ${clauses.join(" AND ")}`, values) const { rows } = await pool.query(`SELECT ${table === "requests" ? MCP_REQUEST_MONITOR_PROJECTION : "r.*"} FROM ${relation} r WHERE ${clauses.join(" AND ")} ORDER BY r.${dateField} DESC,r.id DESC LIMIT ${bind(query.limit + 1)}`, values) const entries = rows.slice(0, query.limit) + const steps = table === "operations" && query.operationId && entries.length > 0 + ? (await pool.query(`SELECT step_index,server_id,state,finished_at FROM mcp_operation_steps + WHERE operation_id=$1 ORDER BY step_index LIMIT 101`, [query.operationId])).rows + : undefined const receipts = table === "operations" ? await humanArtifactReceipts(entries, actor, { query: (sql, values) => pool.query(sql, values), authorize: (id, actor, kind) => kind === "ipsec-existing-certificate-bundle" ? existingCertificateArtifact.metadata(id, actor, checkClaims) : kind === "ipsec-user-bundle" ? humanIpsecUserBundleMetadata(id, actor, checkClaims) : kind === "wireguard-export" ? humanWireguardExportMetadata(id, actor, checkClaims) : kind === "database-backup" ? humanDatabaseBackupMetadata(id, actor, checkClaims) : humanBackupDownloadMetadata(id, actor, checkClaims), checkClaims }) : new Map() - return { items: table === "plans" ? await Promise.all(entries.map(row => publicPlan(row as Awaited>))) : entries.map(row => table === "operations" ? { ...redact(row) as Record, artifactReceipts: (receipts.get(row.id) ?? []).map((receipt: { artifactId: string }) => ({ ...receipt, downloadPath: `/api/mcp/artifacts/${receipt.artifactId}` })) } : redact(row)), total: count.rows[0].total, nextCursor: rows.length > query.limit ? String(entries.at(-1)?.id) : null } + return { items: table === "plans" ? await Promise.all(entries.map(row => publicPlan(row as Awaited>))) : entries.map(row => table === "operations" ? { ...redact(row) as Record, ...(steps && { steps: steps.slice(0, 100), stepsTruncated: steps.length > 100 }), artifactReceipts: (receipts.get(row.id) ?? []).map((receipt: { artifactId: string }) => ({ ...receipt, downloadPath: `/api/mcp/artifacts/${receipt.artifactId}` })) } : redact(row)), total: count.rows[0].total, nextCursor: rows.length > query.limit ? String(entries.at(-1)?.id) : null } }) } app.get("/mcp/plans/:id", async request => { diff --git a/components/mcp/mcp-workspace.tsx b/components/mcp/mcp-workspace.tsx index 61543b67..d6d4c42e 100644 --- a/components/mcp/mcp-workspace.tsx +++ b/components/mcp/mcp-workspace.tsx @@ -1,11 +1,12 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react" +import Link from "next/link" import { useSearchParams } from "next/navigation" import { useTable, type ColumnDef } from "@tanstack/react-table" import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react" import { toast } from "sonner" -import { mcpConnectionCreateSchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp" +import { mcpConnectionCreateSchema, mcpPageQuerySchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp" import { routeAiControllerBlockReason, routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing" import { useDataSource } from "@/lib/data-source" import { can, getClaims, hasPermission } from "@/lib/auth" @@ -21,7 +22,7 @@ import { ObjectPermissionPreview, PolicyPreviewDecision } from "./object-permiss import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame" import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" import { Badge } from "@/components/reui/badge" -import { Button } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Checkbox } from "@/components/ui/checkbox" import { Textarea } from "@/components/ui/textarea" @@ -393,6 +394,10 @@ const recordQueryFilters = { function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFilters }) { const urlParams = useSearchParams(), [selected, setSelected] = useState(null) + const [startedOperation, setStartedOperation] = useState(null) + const operationId = kind === "operations" ? urlParams.get("operation") : null + const operationLink = new URLSearchParams(urlParams) + if (startedOperation) operationLink.set("operation", startedOperation) const fields: readonly string[] = recordQueryFilters[kind] const filterValue = (field: string) => urlParams.get(kind + "." + field) ?? "" const cursor = filterValue("cursor") @@ -405,19 +410,53 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil const queryKey = queryFields.toString() const load = useCallback((signal: AbortSignal) => api.records(kind, new URLSearchParams(queryKey), signal), [api, kind, queryKey]) const query = useQuery(load, `${kind}?${queryKey}`) + const journalConnection = kind === "requests" ? filterValue("connectionId") : "" + const navigationClass = buttonVariants({ variant: "outline", className: "self-start" }) + const selectRecord = useCallback((row: RecordRow) => { + if (kind !== "operations") { setSelected(row); return } + const url = new URL(window.location.href) + url.searchParams.set("operation", String(row.id)) + window.history.pushState(null, "", url.pathname + url.search) + }, [kind]) const columns = useMemo[]>(() => [ { accessorKey: "created_at", header: "Время", size: 170, cell: ({ row }) => date(row.original.created_at ?? row.original.createdAt ?? row.original.detected_at) }, { id: "action", header: "Действие", size: 260, cell: ({ row }) => String(row.original.tool ?? row.original.operationId ?? row.original.action ?? row.original.id) }, { id: "state", header: "Результат", cell: ({ row }) => {String(row.original.status ?? row.original.state ?? "событие")} }, { accessorKey: "connection_id", header: "Подключение", size: 250, cell: ({ row }) => {String(row.original.connection_id ?? row.original.connectionId ?? "—")} }, { accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") }, - { id: "details", header: "", cell: ({ row }) => }, - ], []) + { id: "details", header: "", cell: ({ row }) => }, + ], [selectRecord]) const pendingPlanRequests = useRef(new Set()) const [pendingPlans, setPendingPlans] = useState>(() => new Set()) const selectedPlanPending = kind === "plans" && !!selected && pendingPlans.has(String(selected.id)) + const planExpiry = kind === "plans" && typeof selected?.expiresAt === "string" ? Date.parse(selected.expiresAt) : null + const [planClock, setPlanClock] = useState(0) + useEffect(() => { + if (planExpiry === null || !Number.isFinite(planExpiry)) return + let timer: ReturnType + const update = () => { + clearTimeout(timer) + const now = Date.now() + setPlanClock(now) + if (now < planExpiry) timer = setTimeout(update, Math.min(planExpiry - now, 2_147_483_647)) + } + update() + document.addEventListener("visibilitychange", update) + return () => { + clearTimeout(timer) + document.removeEventListener("visibilitychange", update) + } + }, [planExpiry]) + const planExpired = planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= planClock) + const currentPlan = kind === "plans" && selected ? query.data?.items.find(item => item.id === selected.id) : null + const planChanged = kind === "plans" && !!selected && (!currentPlan || query.error || pretty(currentPlan) !== pretty(selected)) + const planActionDisabled = selectedPlanPending || planExpired || !!planChanged const submitPlan = async (action: "approve" | "reject" | "apply") => { - if (!selected) return + if (!selected || planChanged) return + if (planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= Date.now())) { + setPlanClock(Date.now()) + return + } const plan = selected const planId = String(plan.id) const pending = pendingPlanRequests.current @@ -426,8 +465,9 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil setPendingPlans(new Set(pending)) try { if (action === "apply") { - await api.apply(planId) - toast.success("Операция принята; результат появится в списке операций") + const operation = await api.apply(planId) + setStartedOperation(operation.id) + toast.success("Запрос принят. Откройте выполнение операции для проверки результата.") } else { await api.decision(planId, action === "approve") } @@ -440,9 +480,20 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil setPendingPlans(new Set(pending)) } } + const closeDetails = () => { + setSelected(null) + if (operationId) { + const url = new URL(window.location.href) + url.searchParams.delete("operation") + window.history.replaceState(null, "", url.pathname + url.search) + } + } return ( + {startedOperation && Открыть выполнение операции} + {journalConnection && Планы и операции подключения} + {journalConnection && can("mm:mcp:admin") && Административный аудит подключения}

Последнее успешное обновление: {date(query.updatedAt)}{query.error && query.data ? " · показаны ранее полученные данные" : ""}

@@ -450,7 +501,7 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil {fields.includes("status") && setFilter("status", event.target.value)} />}
- +
Найдено: {query.data?.total ?? "—"}
@@ -458,10 +509,65 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
- { if (!open) setSelected(null) }}>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}Секретные поля скрыты. Подтверждение относится к этому содержимому.{kind === "plans" && selected ? :
{pretty(selected)}
}{kind === "operations" && selected && }{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && }{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") &&
}
+ { if (!open) closeDetails() }}> + + + Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"} + {kind === "plans" ? "Подтверждение относится к показанному плану." : "Данные журнала с маскировкой секретных полей."} + + {operationId ? : kind === "plans" && selected ? :
{pretty(selected)}
} + {planExpired &&

Срок плана истёк. Подготовьте новый план.

} + {planChanged && !planExpired &&

Текущая версия плана недоступна или изменилась. Откройте план заново.

} + {kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && } + {kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") &&
} +
+
) } + +const operationStates: Record = { + pending: "Ожидает выполнения", running: "Выполняется", applied: "Применена", failed: "Ошибка выполнения", + blocked: "Заблокирована", cancelled: "Отменена", partial: "Выполнена частично", unknown: "Исход не подтверждён", +} +const stepStates: Record = { intent: "Записано намерение", applied: "Применён", acknowledged: "Получено подтверждение приёма", verified: "Результат проверен" } + +function OperationMonitor({ api, id }: { api: McpApi; id: string }) { + const recoveryLink = new URLSearchParams(useSearchParams()) + recoveryLink.delete("operation") + recoveryLink.set("reconcile", id) + const load = useCallback((signal: AbortSignal) => api.records("operations", new URLSearchParams({ operationId: id, limit: "1" }), signal), [api, id]) + const query = useQuery(load, id) + const operation = query.data?.items.find(item => item.id === id) + const state = String(operation?.state ?? "") + const steps = Array.isArray(operation?.steps) ? operation.steps as RecordRow[] : [] + return <> + +
+ {id} + +
+

+ {operation ? operationStates[state] ?? state : query.data ? "Операция отсутствует в доступном журнале" : query.error ? "Состояние недоступно" : "Получаем состояние операции…"} + {query.error && operation && " · последнее полученное состояние"} +

+ {operation && <> + {(state === "partial" || state === "unknown") &&
+

Перед обратным изменением сверьте фактическое состояние. Повтор может повторно изменить конфигурацию.

+ {can("mm:mcp:admin") && Перейти к сверке} +
} + {steps.length > 0 &&
+

Шаги выполнения

+
    + {steps.map(step =>
  1. {stepStates[String(step.state)] ?? String(step.state)}{step.server_id != null && ` · сервер ${step.server_id}`}{step.finished_at != null && ` · ${date(step.finished_at)}`}
  2. )} +
+ {operation.stepsTruncated === true &&

Показаны первые 100 шагов.

} +
} +
{pretty(operation)}
+ {!query.error && } + } + +} function RecordFilters({ fields, value, update }: { fields: readonly string[]; value: (field: string) => string; update: (field: string, value: string) => void }) { return (
@@ -554,13 +660,27 @@ function Permissions({ api, operations }: { api: McpApi; operations: McpOperatio
} -function OperationRecovery({ api }: { api: McpApi }) { - const [id, setId] = useState(""), [result, setResult] = useState(null), [error, setError] = useState(""), [busy, setBusy] = useState(false) +function OperationRecovery({ api, operationId }: { api: McpApi; operationId: string }) { + const [id, setId] = useState(operationId), [result, setResult] = useState(null), [error, setError] = useState(""), [busy, setBusy] = useState(false) + const target = id.trim() + const validId = mcpPageQuerySchema.shape.operationId.safeParse(target).success const run = async (recover: boolean) => { + if (busy || !validId) return setBusy(true); setError(""); setResult(null) - try { setResult(recover ? await api.recoverOperation(id) : await api.reconcileOperation(id)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } + try { setResult(recover ? await api.recoverOperation(target) : await api.reconcileOperation(target)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } } - return { setId(event.target.value); setResult(null) }} />
{can("mm:mcp:admin") && }
{result !== null &&
{pretty(result)}
}
+ return
+ + + { setId(event.target.value); setResult(null); setError("") }} /> + {target && !validId &&

Укажите UUID операции из журнала.

} +
+ + +
+ {result !== null &&
{pretty(result)}
} +
+
} function ManagementChannels({ api }: { api: McpApi }) { const channels = useQuery(useCallback(signal => api.managementChannels(signal), [api])) @@ -646,9 +766,10 @@ function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: b const params = useSearchParams(), initial = params.get("tab") as Tab | null const oauthRequestId = params.get("oauthRequest") const [tab, setTab] = useState(initial && initial in tabs ? initial : "overview") + const recoveryId = params.get("reconcile") ?? "" const api = useMemo(() => mcpApi(backendUrl), [backendUrl]) const [operations, setOperations] = useState([]), [error, setError] = useState("") useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live]) const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) } - return <>

MCP

Доступ нейросетей, разрешения и контроль изменений

{!live ?

Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.

: <>{oauthRequestId && }
{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => {title})}
{can("mm:mcp:admin") && }
{can("mm:mcp:admin") &&
{can("mm:settings:admin") && }
}
}
+ return <>

MCP

Доступ нейросетей, разрешения и контроль изменений

{!live ?

Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.

: <>{oauthRequestId && }
{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => {title})}
{can("mm:mcp:admin") && }
{can("mm:mcp:admin") &&
{can("mm:settings:admin") && }
}
}
} diff --git a/packages/contracts/src/mcp.ts b/packages/contracts/src/mcp.ts index b8bb3ab1..32fdb615 100644 --- a/packages/contracts/src/mcp.ts +++ b/packages/contracts/src/mcp.ts @@ -149,6 +149,7 @@ export const mcpSettingsSchema = z.object({ export const mcpSettingsPatchSchema = mcpSettingsSchema.partial().strict().refine(value => Object.keys(value).length > 0) export const mcpPageQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), + operationId: z.string().uuid().optional(), cursor: z.union([z.string().uuid(), z.string().regex(/^[1-9]\d{0,18}$/)]).optional(), connectionId: z.string().uuid().optional(), status: z.string().max(50).optional(), diff --git a/shared/api/http-client.ts b/shared/api/http-client.ts index 2df2a88c..f1dc8810 100644 --- a/shared/api/http-client.ts +++ b/shared/api/http-client.ts @@ -49,10 +49,16 @@ export function withAuthHeaders(init?: HeadersInit): Headers { return headers } -function handleUnauthorized(): never { +let portalHandoffStarted = false + +function handleUnauthorized(authorization: string | null): never { if (typeof window !== "undefined" && isAuthEnabled()) { - const ok = redirectToPortalLogin() - if (!ok) redirectToPortalLoginInteractive() + const token = getToken() + if (!portalHandoffStarted && (!token || authorization === `Bearer ${token}`)) { + const ok = redirectToPortalLogin() + if (!ok) redirectToPortalLoginInteractive() + portalHandoffStarted = true + } } throw new ApiClientError("Unauthorized", 401) } @@ -86,7 +92,7 @@ export async function requestJson( headers, }) - if (res.status === 401) handleUnauthorized() + if (res.status === 401) handleUnauthorized(headers.get("Authorization")) if (res.status === 204) return undefined as T @@ -116,7 +122,7 @@ export async function requestBlob( ...init, headers, }) - if (res.status === 401) handleUnauthorized() + if (res.status === 401) handleUnauthorized(headers.get("Authorization")) if (!res.ok) { throw new ApiClientError(await parseErrorMessage(res), res.status) } diff --git a/tests/e2e/mcp-auth-context.spec.ts b/tests/e2e/mcp-auth-context.spec.ts index c575442a..2b58f647 100644 --- a/tests/e2e/mcp-auth-context.spec.ts +++ b/tests/e2e/mcp-auth-context.spec.ts @@ -137,6 +137,65 @@ test('synthetic: bind OAuth consent to the displayed request and submit only onc expect(decisions).toHaveLength(1) }) +test('synthetic: concurrent unauthorized MCP reads hand off to Portal once', async ({ page, baseURL }) => { + await setup(page, ['mm:mcp:read']) + const portal = 'http://127.0.0.1:3165' + const operationPath = `/mcp?tab=plans&operation=${id}` + await page.route('**/api/auth/config', route => route.fulfill({ json: { required: true, portal_url: portal } })) + let revoked = false, refusals = 0 + let delayedRefusal: Promise | undefined + const headers = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'Authorization' } + await page.route(/\/api\/mcp\/(operations|plans|drift)\?/, async route => { + if (route.request().method() === 'OPTIONS') return route.fulfill({ status: 204, headers }) + const selected = new URL(route.request().url()).searchParams.get('operationId') === id + if (selected && delayedRefusal) { + const wait = delayedRefusal + delayedRefusal = undefined + await wait + return route.fulfill({ status: 401, headers, json: { error: 'Unauthorized' } }) + } + if (revoked) { + refusals++ + return route.fulfill({ status: 401, headers, json: { error: 'Unauthorized' } }) + } + return route.fulfill({ headers, json: { items: selected ? [{ id, state: 'applied', steps: [{ step_index: 0, state: 'verified' }] }] : [], total: selected ? 1 : 0, nextCursor: null } }) + }) + const handoffs: URL[] = [] + await page.route(url => url.origin === portal && url.pathname === '/', async route => { + handoffs.push(new URL(route.request().url())) + await expect.poll(() => refusals).toBeGreaterThan(1) + await route.fulfill({ contentType: 'text/html', body: '

Portal sign-in

' }) + }) + + await page.goto(operationPath) + const step = page.getByRole('region', { name: 'Шаги выполнения' }).getByText('Результат проверен') + await expect(step).toBeVisible() + const refresh = page.getByRole('button', { name: 'Обновить выполнение' }) + let releaseRefusal!: () => void + delayedRefusal = new Promise(resolve => { releaseRefusal = resolve }) + try { + await Promise.all([ + page.waitForRequest(request => request.method() === 'GET' && new URL(request.url()).searchParams.get('operationId') === id), + refresh.click(), + ]) + await page.evaluate(() => { + localStorage.setItem('mmapp_token', 'header.' + btoa(JSON.stringify({ sub: 'new-session', apps: ['mm'], permissions: ['mm:mcp:read'], exp: 4102444800 })) + '.signature') + }) + } finally { releaseRefusal() } + await expect(page.getByRole('alert').filter({ hasText: 'Unauthorized' })).toBeVisible() + expect(handoffs).toHaveLength(0) + + revoked = true + await refresh.click() + await expect(page.getByRole('heading', { name: 'Portal sign-in' })).toBeVisible() + expect(handoffs).toHaveLength(1) + const callback = new URL(handoffs[0].searchParams.get('return_to')!) + expect(callback.origin).toBe(new URL(baseURL!).origin) + expect(callback.pathname).toBe('/auth/callback') + expect(callback.searchParams.get('next')).toBe(operationPath) + await expect(step).toHaveCount(0) +}) + test('synthetic: deny OAuth consent without an available connection', async ({ page, baseURL }) => { await setup(page, ['mm:mcp:read']) const requestId = 'fa58fa40-4bcb-4616-9316-cb69463b3151'