OSVauco/opax-mcp/test_phase2c_real_integration.py
Chris Christiansen d1864f1f55 feat(opax-mcp): add local gated call_emma integration
Phase 2C adds a local Firestore-emulator-only call_emma path with server-injected test identity, deterministic local Emma execution, conversation/message persistence, and append-only audit logging.

The integration test verifies feature gating, persistence, audit correlation, client identity isolation, and controlled local process cleanup.
2026-09-17 15:45:42 +00:00

204 lines
8.9 KiB
Python

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