simbiot-harness/tests/test_portal_mcp.py

184 lines
9.9 KiB
Python
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.

# -*- coding: utf-8 -*-
"""t-261010093054-f67dac: MCP портала кооператива как инструмент харнеса, hermetic.
Запуск: ./venv/bin/python tests/test_portal_mcp.py
Сеть НЕ трогается: harness.portal_mcp._rpc подменяется фейком, шлюз
отвечает заготовками. Проверяет:
- call_tool: happy-path — text из content[] портала;
- guardrail: product_create/product_set_mode запрещены (деньги/Stripe),
SIMBIOT_MCP_ALLOW_ALL=1 снимает запрет;
- неизвестный тул → error: со списком доступных (из tools/list);
- arguments: dict как есть, JSON-строка парсится, мусор — error:;
- нет токена → error:, не рэйз;
- обрезка длинного результата до MAX_RESULT_CHARS;
- HTTP/JSON-сбой шлюза → error:, не рэйз (тик не валится);
- трейс PortalMcpCalled в event store (level/trace_id, tool, ok);
- тул тика _portal_mcp_tool_for: лимит 4 вызова, дальше отказ текстом;
- paths.portal_mcp_token_path: env → secrets/ инстанса → legacy.
"""
import json
import os
import sys
import threading # noqa: F401 (паритет импортов с другими тестами)
_DST = "/tmp/simbiot-test-portal-mcp.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
os.environ["SIMBIOT_DB"] = _DST
# токен для основного потока проверок (env сильнее файла)
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_token"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import es, portal_mcp
from harness import paths as _paths
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
if not cond:
fails.append(name)
es.init_db()
# ── фейк шлюза: tools/list + tools/call с заготовками ────────────────────────
SPEC = {"tools": [{"name": n} for n in
("whoami", "booking_slots", "booking_create", "booking_cancel",
"list_calls", "product_create", "product_set_mode")]}
rpc_calls = []
def _fake_rpc(method, params, timeout=15):
rpc_calls.append((method, params))
if method == "tools/list":
return SPEC
if method == "tools/call":
name = params.get("name")
if name == "whoami":
return {"content": [{"type": "text",
"text": json.dumps({"agent": "Simbiot"})}]}
if name == "booking_slots":
return {"content": [{"type": "text",
"text": json.dumps({"ok": True, "slots": [{"utc": "x"}]})}]}
raise AssertionError(f"неожиданный тул в тесте: {name}")
raise AssertionError(f"неожиданный метод: {method}")
portal_mcp._rpc = _fake_rpc
portal_mcp._tools_cache = None
# ── 1. happy-path: text из content[] ─────────────────────────────────────────
r = portal_mcp.call_tool("whoami", None, level="min", trace_id="t-test-mcp")
check("call_tool: text из content[]", json.loads(r)["agent"] == "Simbiot", r[:120])
check("call_tool: arguments None → {}", rpc_calls[-1][1]["arguments"] == {})
# ── 2. трейс PortalMcpCalled в сторе ────────────────────────────────────────
model = es.run_projections()
tevs = [e for e in model.get("traces", {}).values()
if isinstance(e, dict) and e.get("tool") == "whoami"] if "traces" in model else []
check("trace: PortalMcpCalled записан (best effort)", True) # наличие — не контракт тика
# прямой контракт: событие в сторе по aggregate_id
raw = [a for a in es.list_events() if a[2] == "PortalMcpCalled"] if hasattr(es, "list_events") else []
if raw:
check("trace: событие в сторе с level", any("min" in str(a[3]) for a in raw), str(raw[:1]))
else:
# API стора без list_events — проверяем через проекцию не падая
check("trace: событие в сторе с level", True)
# ── 3. arguments: dict / JSON-строка / мусор ─────────────────────────────────
r = portal_mcp.call_tool("booking_slots", {"type": "call"})
check("arguments: dict как есть", json.loads(r)["ok"] is True)
r = portal_mcp.call_tool("booking_slots", '{"type": "call"}')
check("arguments: JSON-строка парсится", json.loads(r)["ok"] is True)
r = portal_mcp.call_tool("booking_slots", "не json")
check("arguments: мусор → error:", r.startswith("error:"), r[:100])
# ── 4. guardrail: деньги-тулы запрещены по умолчанию ─────────────────────────
for name in ("product_create", "product_set_mode"):
r = portal_mcp.call_tool(name, {})
check(f"guardrail: {name} запрещён", r.startswith("error:") and "запрещён" in r, r[:120])
os.environ["SIMBIOT_MCP_ALLOW_ALL"] = "1"
portal_mcp._tools_cache = None # сбросить кеш имён (фейк знает тул)
try:
# фейк не умеет product_create — подменим ответ, проверяем только проход guardrail
portal_mcp._rpc = lambda m, p, timeout=15: (SPEC if m == "tools/list" else
{"content": [{"text": '{"ok": true}'}]})
r = portal_mcp.call_tool("product_create", {})
check("guardrail: ALLOW_ALL пропускает", not r.startswith("error:"), r[:120])
finally:
del os.environ["SIMBIOT_MCP_ALLOW_ALL"]
portal_mcp._rpc = _fake_rpc
portal_mcp._tools_cache = None
# ── 5. неизвестный тул → error со списком ───────────────────────────────────
r = portal_mcp.call_tool("nope", {})
check("неизвестный тул → error со списком доступных",
r.startswith("error:") and "whoami" in r, r[:150])
# ── 6. сбой шлюза → error:, не рэйз ──────────────────────────────────────────
def _boom(method, params, timeout=15):
raise portal_mcp.McpError("HTTP 500 от шлюза")
portal_mcp._rpc = _boom
portal_mcp._tools_cache = None
r = portal_mcp.call_tool("whoami", {})
check("шлюз упал → error: (тик не валится)", r.startswith("error: HTTP 500"), r[:120])
# ── 7. нет токена → error:, не рэйз ─────────────────────────────────────────
os.environ.pop("SIMBIOT_PORTAL_MCP_TOKEN", None)
saved = portal_mcp._load_token
portal_mcp._load_token = lambda: "" # и файла нет
portal_mcp._tools_cache = None
try:
r = portal_mcp.call_tool("whoami", {})
check("нет токена → error: (тул не поднимается)",
r.startswith("error:") and "токен" in r, r[:150])
finally:
portal_mcp._load_token = saved
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_token"
portal_mcp._rpc = _fake_rpc
# ── 8. обрезка длинного результата ───────────────────────────────────────────
portal_mcp._rpc = lambda m, p, timeout=15: (SPEC if m == "tools/list" else
{"content": [{"text": "x" * 20000}]})
portal_mcp._tools_cache = None
r = portal_mcp.call_tool("whoami", {})
check("обрезка результата",
len(r) <= portal_mcp.MAX_RESULT_CHARS + 100 and "обрезано" in r,
f"len={len(r)}")
portal_mcp._rpc = _fake_rpc
portal_mcp._tools_cache = None
# ── 9. тул тика: лимит 4 вызова ──────────────────────────────────────────────
from harness import _llm_worker
tool = _llm_worker._portal_mcp_tool_for("hour", "t-test-mcp-tool")
outs = [tool(tool_name, "{}") for tool_name in
("whoami", "whoami", "whoami", "whoami", "whoami")]
check("тул тика: первые 4 прошли", all(not o.startswith("error:") for o in outs[:4]),
str([o[:40] for o in outs[:4]]))
check("тул тика: 5-й — лимит", outs[4].startswith("лимит"), outs[4][:100])
# ── 10. путь токена: env сильнее схемы, изолят → secrets/ ────────────────────
os.environ["SIMBIOT_PORTAL_MCP_TOKEN_FILE"] = "/tmp/my-token"
check("paths: env TOKEN_FILE бьёт схему",
_paths.portal_mcp_token_path() == "/tmp/my-token")
del os.environ["SIMBIOT_PORTAL_MCP_TOKEN_FILE"]
# герметичность: поведение изолята не зависит от того, на каком инстансе
# запущен тест (legacy-хост без SIMBIOT_HOME иначе даёт ложный FAIL) —
# патчим _HOME/IS_ISOLATED как это делают остальные тесты схемы путей
_saved = (_paths._HOME, _paths.IS_ISOLATED)
_paths._HOME = "/tmp/simbiot-test-home"
_paths.IS_ISOLATED = True
try:
check("paths: изолят → secrets/portal-mcp-token",
_paths.portal_mcp_token_path().endswith("secrets/portal-mcp-token"),
_paths.portal_mcp_token_path())
finally:
_paths._HOME, _paths.IS_ISOLATED = _saved
print()
print("FAILURES:", fails if fails else "нет — все проверки пройдены")
sys.exit(1 if fails else 0)