Files
vocero-client/vocero-api/tests/test_mcp_sessions.py
T
alvaro 4b0108ecf0 feat(vocero): add linked content and TALKFIRST greetings (#2)
Opening a report or podcast link now establishes an authenticated starting reference for the conversation. Users can select other accessible content or compare reports while retaining the original link, and TALKFIRST greetings can name the verified report or podcast.

Release notes:

- Added authenticated report and episode link resolution with bounded lookups and unavailable-content handling
- Added explicit conversation selection without restricting searches or changing the reference during comparisons
- Added contextual TALKFIRST greetings through both voice-proxy and HTTP reasoning paths
- Preserved backend permission checks, conversation isolation, cancellation, and the existing speech protocol

Reviewed-on: AquaPortal/s2s#2
2026-10-08 09:34:16 -04:00

192 lines
8.2 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 = []
original = httpx.AsyncClient
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"}}
user = request.headers["authorization"].removeprefix("Bearer ")
assert request.headers["x-vocero-podcast-id"] == f"report:{user}"
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.httpx, "AsyncClient", lambda **kwargs: original(transport=httpx.MockTransport(upstream), **kwargs)
)
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"}}, podcast_id=f"report:{user}")
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, podcast_id=None: 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)