140 lines
7.9 KiB
Python
140 lines
7.9 KiB
Python
import asyncio
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
from types import SimpleNamespace
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from app.mcp.models import BoundContext
|
|
from app.mcp.calculation_jobs import durable_binding, restore_binding, check_binding, should_queue, QUEUED_TOOLS
|
|
|
|
|
|
def binding():
|
|
return BoundContext(context_handle='secret-handle', project_id=uuid4(), subject='user', client_id='host', native_session_id='session', purpose='ordinary_chat', expires_at=datetime.now(timezone.utc))
|
|
|
|
|
|
def test_durable_binding_never_stores_expiring_secret_handle():
|
|
original = binding()
|
|
safe = durable_binding(original)
|
|
assert 'context_handle' not in safe
|
|
assert 'expires_at' not in safe
|
|
assert restore_binding(safe).subject == original.subject
|
|
|
|
|
|
def test_original_session_boundary_is_enforced():
|
|
original = binding()
|
|
row = {'binding': durable_binding(original)}
|
|
check_binding(original, row)
|
|
original.native_session_id = 'other'
|
|
with pytest.raises(HTTPException) as error:
|
|
check_binding(original, row)
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
def test_only_executed_expensive_mutations_are_queued():
|
|
tool = SimpleNamespace(name='save_simulation_scenario', mutating=True)
|
|
assert not should_queue(tool, SimpleNamespace(dry_run=True))
|
|
assert should_queue(tool, SimpleNamespace(dry_run=False))
|
|
assert 'analyze_valve_isolation' not in QUEUED_TOOLS
|
|
assert 'calculate_demand_allocation' not in QUEUED_TOOLS
|
|
assert len(QUEUED_TOOLS) == 12
|
|
|
|
|
|
def test_result_pages_keep_original_semantics_and_provenance(monkeypatch):
|
|
from unittest.mock import AsyncMock
|
|
from app.mcp import calculation_jobs as jobs
|
|
from app.mcp.calculation_models import CalculationResultInput
|
|
from app.mcp.models import QueryResult
|
|
original = binding()
|
|
result = QueryResult(source='run_burst_location', source_ref='calculation:one', queried_at=datetime.now(timezone.utc), semantics='analysis', data={'result': {'items': list(range(10))}}, evidence_id='evidence-one')
|
|
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')}
|
|
repo = SimpleNamespace(get=AsyncMock(return_value=row))
|
|
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
|
|
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
|
|
monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result))
|
|
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
|
|
args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'], data_path=['result', 'items'], limit=3, offset=2)
|
|
page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args))
|
|
assert page.data == [2, 3, 4]
|
|
assert page.semantics == 'analysis'
|
|
assert page.source == 'run_burst_location'
|
|
assert page.evidence_id == 'evidence-one'
|
|
assert page.truncated and page.page.has_more and page.page.total == 10
|
|
|
|
|
|
def test_pending_result_is_task_receipt_without_evidence(monkeypatch):
|
|
from unittest.mock import AsyncMock
|
|
from app.mcp import calculation_jobs as jobs
|
|
from app.mcp.calculation_models import CalculationResultInput
|
|
original = binding()
|
|
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'running', 'tool': 'run_simulation'}
|
|
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row)))
|
|
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
|
|
attach = AsyncMock()
|
|
monkeypatch.setattr(jobs, 'attach_calculation_evidence', attach)
|
|
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
|
|
result = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])))
|
|
assert result.semantics == 'task' and result.evidence_id is None
|
|
assert result.data['status'] == 'running'
|
|
attach.assert_not_awaited()
|
|
|
|
|
|
def test_mutation_submission_cannot_bypass_existing_approval_receipt(monkeypatch):
|
|
from contextlib import asynccontextmanager
|
|
from unittest.mock import AsyncMock
|
|
from app.mcp import calculation_jobs as jobs, query_service
|
|
from app.mcp.catalog import TOOLS
|
|
from app.mcp.extended_models import SensorCreateInput
|
|
from app.mcp.mutation_service import mutation_arguments
|
|
from app.infra.db.postgresql import mcp_queries
|
|
args = SensorCreateInput(context_handle='x' * 32, request_id=uuid4(), dry_run=False, expected_digest='a' * 64, name='approved', method='kmeans', sensor_count=2)
|
|
@asynccontextmanager
|
|
async def transaction():
|
|
yield
|
|
previous = {'parameters': {'tool': 'create_sensor_placement', 'arguments': mutation_arguments(args, preview=False)}, 'result': {'status': 'started'}}
|
|
receipt = SimpleNamespace(lock_receipt=AsyncMock(), receipt=AsyncMock(return_value=previous))
|
|
monkeypatch.setattr(mcp_queries, 'McpQueryRepository', lambda *a: receipt)
|
|
monkeypatch.setattr(query_service, 'authorize_query', AsyncMock())
|
|
queue = AsyncMock()
|
|
monkeypatch.setattr(jobs, 'CalculationJobRepository', queue)
|
|
scope = SimpleNamespace(connection=SimpleNamespace(transaction=transaction), project=SimpleNamespace(project_id=uuid4(), user_id=uuid4()))
|
|
with pytest.raises(HTTPException) as error:
|
|
asyncio.run(jobs.submit_calculation(scope, binding(), TOOLS['create_sensor_placement'], args, 'host-call'))
|
|
assert error.value.status_code == 409
|
|
assert 'already started' in str(error.value.detail)
|
|
queue.assert_not_called()
|
|
changed = args.model_copy(update={'name': 'changed'})
|
|
with pytest.raises(HTTPException) as conflict:
|
|
asyncio.run(jobs.submit_calculation(scope, binding(), TOOLS['create_sensor_placement'], changed, 'host-call'))
|
|
assert conflict.value.detail == 'request_id already has different parameters'
|
|
queue.assert_not_called()
|
|
|
|
|
|
def test_evidence_receipt_commits_in_root_operational_transaction(monkeypatch):
|
|
from contextlib import asynccontextmanager
|
|
from unittest.mock import AsyncMock
|
|
from app.mcp import calculation_jobs as jobs, query_service, contexts
|
|
from app.infra.db.postgresql import mcp_queries
|
|
from app.mcp.models import QueryResult
|
|
original = binding().model_copy(update={'task_id': uuid4(), 'run_id': uuid4()})
|
|
result = QueryResult(source='run_simulation', source_ref='original', queried_at=datetime.now(timezone.utc), semantics='analysis', data={})
|
|
events = []
|
|
async def commit():
|
|
events.append('commit-reads')
|
|
@asynccontextmanager
|
|
async def transaction():
|
|
events.append('begin-root-operational')
|
|
yield
|
|
events.append('commit-evidence')
|
|
task_repo = SimpleNamespace(transaction=transaction, lock=AsyncMock())
|
|
receipt_repo = SimpleNamespace(lock_receipt=AsyncMock(), receipt=AsyncMock(return_value=None), save_receipt=AsyncMock())
|
|
service = SimpleNamespace(repo=task_repo, record_data_snapshot=AsyncMock(return_value=(SimpleNamespace(evidence_id='saved'), None)))
|
|
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock(return_value=(original, SimpleNamespace(name='run_simulation'), None)))
|
|
monkeypatch.setattr(contexts, 'verify_task', AsyncMock())
|
|
monkeypatch.setattr(query_service, 'task_service', lambda scope: service)
|
|
monkeypatch.setattr(mcp_queries, 'McpQueryRepository', lambda *a: receipt_repo)
|
|
scope = SimpleNamespace(connection=SimpleNamespace(commit=commit), project=SimpleNamespace(project_id=original.project_id))
|
|
attached = asyncio.run(jobs.attach_calculation_evidence(scope, {'job_id': uuid4()}, result))
|
|
assert attached.evidence_id == 'saved'
|
|
assert events == ['commit-reads', 'begin-root-operational', 'commit-evidence']
|
|
receipt_repo.save_receipt.assert_awaited_once()
|