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
192 lines
8.2 KiB
Python
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)
|