import os import unittest import httpx import asyncio from google.cloud import firestore import subprocess import time import uuid import secrets from policy.caller_context import derive_caller_context from contracts.common import CallerContext from server import is_call_emma_local_mode_enabled # This test requires a running Firestore emulator and the server to be started. # It makes REAL HTTP requests to the server, which in turn talks to the emulator. class TestPhase2CRealIntegration(unittest.IsolatedAsyncioTestCase): _server_process = None _expected_caller: CallerContext = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.created_conversation_ids = [] self.created_correlation_ids = [] @classmethod def setUpClass(cls): # 1. Check environment prerequisites assert os.getenv("FIRESTORE_EMULATOR_HOST"), "FIRESTORE_EMULATOR_HOST must be set" # 2. Define and start the MCP server with a fixed, isolated environment test_env = os.environ.copy() test_env["ENABLE_CALL_EMMA_TOOL"] = "true" test_env["OPAX_EMMA_TEST_CALLER_ID"] = "agent:perplexity" test_env["MCP_SECRET"] = secrets.token_hex(16) test_env["GOOGLE_CLOUD_PROJECT"] = "opax-phase2c-local" cls.mcp_secret = test_env["MCP_SECRET"] # Use the import path confirmed by the smoke test cls._server_process = subprocess.Popen( ["opax-mcp/.venv/bin/uvicorn", "opax-mcp.server:app", "--host", "127.0.0.1", "--port", "8088"], env=test_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Wait for the server to start up time.sleep(5) if cls._server_process.poll() is not None: raise RuntimeError("MCP test server exited during startup.") # 3. Create a real Firestore client for test verification # Client is created in asyncSetUp to share the event loop # 4. Derive the expected identity for assertions cls._expected_caller = derive_caller_context(test_env["OPAX_EMMA_TEST_CALLER_ID"]) @classmethod def tearDownClass(cls): if cls._server_process: cls._server_process.terminate() try: cls._server_process.wait(timeout=5) except subprocess.TimeoutExpired: cls._server_process.kill() cls._server_process.wait(timeout=5) async def asyncSetUp(self): self._firestore_client = firestore.AsyncClient(project="opax-phase2c-local") async def asyncTearDown(self): # Clean up only the specific documents created during this test run for convo_id in self.created_conversation_ids: messages_ref = self._firestore_client.collection("emma_conversations").document(convo_id).collection("messages") messages = [doc async for doc in messages_ref.stream()] for msg in messages: await msg.reference.delete() await self._firestore_client.collection("emma_conversations").document(convo_id).delete() for corr_id in self.created_correlation_ids: query = self._firestore_client.collection("emma_audit_events").where("details.correlation_id", "==", corr_id) docs = [doc async for doc in query.stream()] for doc in docs: await doc.reference.delete() if self._firestore_client: self._firestore_client.close() async def _call_tool(self, name, args, headers=None): json_payload = { "jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": "tools/call", "params": { "name": name, "arguments": args } } default_headers = {"api-key": self.mcp_secret} if headers: default_headers.update(headers) async with httpx.AsyncClient() as client: response = await client.post("http://127.0.0.1:8088/", json=json_payload, headers=default_headers) return response.json() def test_00_local_mode_gate_requires_both_flags(self): """Tests the is_call_emma_local_mode_enabled helper directly.""" original_enable = os.environ.get("ENABLE_CALL_EMMA_TOOL") original_host = os.environ.get("FIRESTORE_EMULATOR_HOST") try: # Case 1: Both missing if "ENABLE_CALL_EMMA_TOOL" in os.environ: del os.environ["ENABLE_CALL_EMMA_TOOL"] if "FIRESTORE_EMULATOR_HOST" in os.environ: del os.environ["FIRESTORE_EMULATOR_HOST"] self.assertFalse(is_call_emma_local_mode_enabled()) # Case 2: Host missing os.environ["ENABLE_CALL_EMMA_TOOL"] = "true" self.assertFalse(is_call_emma_local_mode_enabled()) # Case 3: Enable flag missing if "ENABLE_CALL_EMMA_TOOL" in os.environ: del os.environ["ENABLE_CALL_EMMA_TOOL"] os.environ["FIRESTORE_EMULATOR_HOST"] = "localhost:8686" self.assertFalse(is_call_emma_local_mode_enabled()) # Case 4: Both present os.environ["ENABLE_CALL_EMMA_TOOL"] = "true" self.assertTrue(is_call_emma_local_mode_enabled()) finally: if original_enable is not None: os.environ["ENABLE_CALL_EMMA_TOOL"] = original_enable else: os.environ.pop("ENABLE_CALL_EMMA_TOOL", None) if original_host is not None: os.environ["FIRESTORE_EMULATOR_HOST"] = original_host else: os.environ.pop("FIRESTORE_EMULATOR_HOST", None) async def test_01_new_request_creates_conversation(self): """Tests a new request creates a conversation and messages with the correct identity.""" test_prompt = f"This is an integration test. Run ID: {uuid.uuid4()}" response = await self._call_tool("call_emma", {"prompt": test_prompt}) self.assertIn("result", response) result = response["result"] self.assertIn("conversation_id", result) self.assertIn("correlation_id", result) conversation_id = result["conversation_id"] correlation_id = result["correlation_id"] # Track IDs for cleanup self.created_conversation_ids.append(conversation_id) self.created_correlation_ids.append(correlation_id) # Verify deterministic test model response self.assertEqual(result.get("model"), "local-test-model") self.assertIn("deterministic local reply", result.get("reply_text", "")) self.assertNotIn(test_prompt, result.get("reply_text", "")) # Verify conversation document exists convo_doc = await self._firestore_client.collection("emma_conversations").document(conversation_id).get() self.assertTrue(convo_doc.exists) self.assertEqual(convo_doc.to_dict().get("owner_id"), self._expected_caller.owner_id) self.assertEqual(convo_doc.to_dict().get("workspace_id"), self._expected_caller.workspace_id) # Verify messages subcollection contains two messages (user and assistant) messages_ref = self._firestore_client.collection("emma_conversations").document(conversation_id).collection("messages") messages = [doc async for doc in messages_ref.stream()] self.assertEqual(len(messages), 2) # Verify audit log was written audit_ref = self._firestore_client.collection("emma_audit_events") query = audit_ref.where("details.correlation_id", "==", correlation_id) audit_docs = [doc async for doc in query.stream()] self.assertEqual(len(audit_docs), 1) self.assertEqual(audit_docs[0].to_dict()["details"]["status"], "success") async def test_02_client_identity_is_ignored(self): """Tests that a client-supplied user_id in the arguments is ignored.""" malicious_args = { "prompt": "test", "client_context": { "user_id": "user:chris.christiansen@vauco.no" } } response = await self._call_tool("call_emma", malicious_args) self.assertIn("result", response) conversation_id = response["result"]["conversation_id"] correlation_id = response["result"]["correlation_id"] # Track IDs for cleanup self.created_conversation_ids.append(conversation_id) self.created_correlation_ids.append(correlation_id) # Verify deterministic test model response self.assertEqual(response["result"].get("model"), "local-test-model") self.assertIn("deterministic local reply", response["result"].get("reply_text", "")) # Verify the created conversation belongs to the SERVER-INJECTED identity, not the client-supplied one convo_doc = await self._firestore_client.collection("emma_conversations").document(conversation_id).get() self.assertTrue(convo_doc.exists) self.assertEqual(convo_doc.to_dict().get("owner_id"), self._expected_caller.owner_id) if __name__ == "__main__": unittest.main()