Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged. Release notes: - Added browser-bound conversations that keep backend credentials server-side and isolate callers. - Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results. - Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection. - Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup. - Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints. - Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage. Reviewed-on: AquaPortal/s2s#1
190 lines
8.0 KiB
Python
190 lines
8.0 KiB
Python
"""Conversation ownership and real SDK transport against isolated HTTP fixtures."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
|
from vocero_api.core import config as settings
|
|
from vocero_api.main import app
|
|
from vocero_api.models.mcp import BackendLogin, IntegrationError
|
|
from vocero_api.providers import backend_login, mcp
|
|
from vocero_api.services import conversations
|
|
|
|
|
|
@pytest.fixture
|
|
def enabled(monkeypatch):
|
|
monkeypatch.setattr(settings, "S2S_MCP_URL", "http://mcp.test/mcp")
|
|
monkeypatch.setattr(settings, "S2S_LOGIN_BY_PHONE", "http://backend.test/phone-login")
|
|
conversations.store.clear()
|
|
yield
|
|
conversations.store.clear()
|
|
|
|
|
|
def login_payload(user="alice"):
|
|
return {"sessionToken": f"demo-secret-{user}", "user": {"id": user, "name": user.title()}}
|
|
|
|
|
|
def test_cookie_bound_conversations_keep_tokens_private_and_identity_per_tab(enabled, monkeypatch):
|
|
login = AsyncMock(side_effect=[BackendLogin.model_validate(login_payload(user)) for user in ("alice", "bob")])
|
|
monkeypatch.setattr(backend_login, "login", login)
|
|
with TestClient(app) as client:
|
|
first = client.post("/api/conversations", json={"phoneNumber": "+56000000001"})
|
|
cookie = client.cookies.get(conversations.COOKIE_NAME)
|
|
assert "HttpOnly" in first.headers["set-cookie"]
|
|
assert "SameSite=strict" in first.headers["set-cookie"]
|
|
assert "demo-secret" not in first.text
|
|
alice = first.json()["conversationId"]
|
|
bob = client.post("/api/conversations", json={"phoneNumber": "+56000000002"}).json()["conversationId"]
|
|
assert conversations.store.get(cookie, alice).backend_token == "demo-secret-alice"
|
|
assert conversations.store.get(cookie, bob).backend_token == "demo-secret-bob"
|
|
client.cookies.clear()
|
|
assert client.get("/api/mcp/tools", headers={conversations.CONVERSATION_HEADER: alice}).status_code == 401
|
|
client.cookies.set(conversations.COOKIE_NAME, cookie)
|
|
assert (
|
|
client.delete("/api/conversations/current", headers={conversations.CONVERSATION_HEADER: alice}).status_code
|
|
== 204
|
|
)
|
|
with pytest.raises(IntegrationError):
|
|
conversations.store.get(cookie, alice)
|
|
assert conversations.store.get(cookie, bob).profile.id == "bob"
|
|
|
|
|
|
def test_auth_failure_and_cross_origin_requests_never_create_a_conversation(enabled, monkeypatch):
|
|
login = AsyncMock(side_effect=IntegrationError(401, "Sesión no válida."))
|
|
monkeypatch.setattr(backend_login, "login", login)
|
|
with TestClient(app) as client:
|
|
assert (
|
|
client.post(
|
|
"/api/conversations", json={"phoneNumber": "+56000000001"}, headers={"Origin": "https://elsewhere.test"}
|
|
).status_code
|
|
== 403
|
|
)
|
|
login.assert_not_called()
|
|
response = client.post("/api/conversations", json={"phoneNumber": "+56000000001"})
|
|
assert response.status_code == 401
|
|
assert "set-cookie" not in response.headers
|
|
|
|
|
|
def test_store_expires_bounds_and_cancels_pending_work(enabled, monkeypatch):
|
|
async def scenario():
|
|
store = conversations.ConversationStore()
|
|
monkeypatch.setattr(settings, "S2S_MAX_CONVERSATIONS", 1)
|
|
item = store.create("browser", BackendLogin.model_validate(login_payload()))
|
|
with pytest.raises(IntegrationError, match="capacidad"):
|
|
store.create("other-browser", BackendLogin.model_validate(login_payload("bob")))
|
|
item.pending = asyncio.create_task(asyncio.sleep(100))
|
|
pending = item.pending
|
|
item.expires_at = 0
|
|
with pytest.raises(IntegrationError):
|
|
store.get("browser", item.id)
|
|
await asyncio.gather(pending, return_exceptions=True)
|
|
assert pending.cancelled()
|
|
assert item.backend_token == ""
|
|
assert item.closed.is_set()
|
|
store.create("browser", BackendLogin.model_validate(login_payload()))
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_backend_login_validates_exact_contract_without_leaking_credentials(enabled, monkeypatch, caplog):
|
|
original = httpx.AsyncClient
|
|
calls = []
|
|
|
|
def upstream(request):
|
|
calls.append(request)
|
|
return httpx.Response(200, json=login_payload())
|
|
|
|
monkeypatch.setattr(
|
|
backend_login.httpx, "AsyncClient", lambda **kwargs: original(transport=httpx.MockTransport(upstream), **kwargs)
|
|
)
|
|
result = asyncio.run(backend_login.login("+56000000001"))
|
|
assert result.sessionToken.get_secret_value() == "demo-secret-alice"
|
|
assert json.loads(calls[0].content) == {"phoneNumber": "+56000000001"}
|
|
assert "demo-secret" not in caplog.text
|
|
assert "demo-secret" not in repr(result)
|
|
|
|
|
|
@pytest.mark.parametrize("use_sse", [False, True])
|
|
def test_sdk_discovery_and_execution_preserve_nested_schema_results_and_per_user_headers(enabled, monkeypatch, use_sse):
|
|
calls = []
|
|
|
|
def upstream(request):
|
|
calls.append(request)
|
|
if request.method == "GET":
|
|
return httpx.Response(405)
|
|
message = json.loads(request.content)
|
|
method = message["method"]
|
|
if "id" not in message:
|
|
return httpx.Response(202)
|
|
if method == "initialize":
|
|
result = {
|
|
"protocolVersion": "2025-11-25",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": "test", "version": "1"},
|
|
}
|
|
elif method == "tools/list":
|
|
result = {
|
|
"tools": [
|
|
{
|
|
"name": "get_report",
|
|
"inputSchema": {"type": "object", "properties": {"request": {"type": "object"}}},
|
|
},
|
|
{"name": "delete_user", "inputSchema": {"type": "object"}},
|
|
]
|
|
}
|
|
else:
|
|
assert method == "tools/call"
|
|
assert message["params"]["arguments"] == {"request": {"report_id": "r1"}}
|
|
result = {
|
|
"content": [],
|
|
"structuredContent": {"result": {"status": "found", "caller": request.headers["authorization"]}},
|
|
}
|
|
body = {"jsonrpc": "2.0", "id": message["id"], "result": result}
|
|
if use_sse:
|
|
return httpx.Response(
|
|
200, text=f"event: message\ndata: {json.dumps(body)}\n\n", headers={"content-type": "text/event-stream"}
|
|
)
|
|
return httpx.Response(200, json=body)
|
|
|
|
monkeypatch.setattr(
|
|
mcp,
|
|
"http_client",
|
|
lambda token: httpx.AsyncClient(
|
|
transport=httpx.MockTransport(upstream), headers={"Authorization": f"Bearer {token}"}
|
|
),
|
|
)
|
|
|
|
async def scenario():
|
|
catalog = await mcp.discover("alice")
|
|
assert [tool.name for tool in catalog] == ["get_report"]
|
|
assert catalog[0].parameters["properties"]["request"] == {"type": "object"}
|
|
outputs = await asyncio.gather(
|
|
*(mcp.execute(user, "get_report", {"request": {"report_id": "r1"}}) for user in ("alice", "bob"))
|
|
)
|
|
assert [output["data"]["result"]["caller"] for output in outputs] == ["Bearer alice", "Bearer bob"]
|
|
|
|
asyncio.run(scenario())
|
|
assert all(request.headers.get("authorization") in {"Bearer alice", "Bearer bob"} for request in calls)
|
|
assert all("x-demo-session" not in request.headers and "x-api-key" not in request.headers for request in calls)
|
|
|
|
|
|
@pytest.mark.parametrize("status", [401, 403, 500])
|
|
def test_mcp_transport_errors_are_safe_and_preserve_authentication_status(enabled, monkeypatch, status):
|
|
monkeypatch.setattr(
|
|
mcp,
|
|
"http_client",
|
|
lambda token: httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda req: httpx.Response(status, text="secret-upstream-body"))
|
|
),
|
|
)
|
|
with pytest.raises(IntegrationError) as error:
|
|
asyncio.run(mcp.discover("secret-token"))
|
|
assert error.value.status == (status if status in {401, 403} else 502)
|
|
assert "secret" not in str(error.value)
|