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.
204 lines
8.9 KiB
Python
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()
|