"""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)