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() @pytest.mark.parametrize('job_budget', [3600, 7200]) def test_http_timeout_does_not_limit_queued_execution(monkeypatch, job_budget): from unittest.mock import AsyncMock from app.mcp import calculation_jobs as jobs, query_service from app.mcp.catalog import TOOLS tool = TOOLS['run_simulation'] args = tool.model.model_validate({ 'context_handle': 'x' * 32, 'timeout_seconds': 1, 'start_time': '2026-10-08T12:00:00+08:00', 'duration': 3600, 'output_node_ids': ['J1'], }) repo = SimpleNamespace(enqueue=AsyncMock(return_value={ 'job_id': uuid4(), 'status': 'queued', 'tool': tool.name, })) monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo) monkeypatch.setattr(query_service, 'authorize_query', AsyncMock()) scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()), project=SimpleNamespace(project_id=uuid4(), user_id=uuid4())) result = asyncio.run(jobs.submit_calculation( scope, binding(), tool, args, 'host-call', job_timeout_seconds=job_budget, )) assert result.data['status'] == 'queued' assert repo.enqueue.await_args.kwargs['timeout_seconds'] == job_budget assert 'timeout_seconds' not in repo.enqueue.await_args.kwargs['parameters'] def test_expired_result_is_safe_explicit_error(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': 'succeeded', 'result': None, 'result_expires_at': datetime(2020, 1, 1, tzinfo=timezone.utc)} repo = SimpleNamespace(get=AsyncMock(return_value=row)) monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo) 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())) with pytest.raises(HTTPException) as error: asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), CalculationResultInput(context_handle='x' * 32, job_id=row['job_id']))) assert error.value.status_code == 410 assert jobs.public_job(row)['result_available'] is False assert repo.get.await_args.kwargs['include_result'] is True attach.assert_not_awaited() def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch): from unittest.mock import AsyncMock from app.mcp import calculation_jobs as jobs, query_service from app.mcp.catalog import TOOLS original = binding() owner = uuid4() row = {'binding': durable_binding(original), 'tool': 'run_simulation', 'owner_user_id': owner, 'parameters': {'timeout_seconds': 3600, 'start_time': '2026-10-29T00:00:00Z', 'output_node_ids': ['1']}} authorize = AsyncMock() monkeypatch.setattr(query_service, 'authorize_query', authorize) scope = SimpleNamespace(project=SimpleNamespace(project_id=original.project_id, user_id=owner)) _, tool, args = asyncio.run(jobs.authorize_original(scope, row)) assert tool is TOOLS['run_simulation'] assert args.timeout_seconds <= 240 assert row['parameters']['timeout_seconds'] == 3600 authorize.assert_awaited_once()