"""Regression: procurement MCP tools must enforce the SAME RBAC as their HTTP twins. Background — the bug this guards against (2026-05-30, Round-56 review): the eight procurement MCP tools (``list_procurement_*`` / ``get_procurement_*`` / ``create_procurement_*``) were registered with ``tool_runtime(...)`` only, which enforces *active-employee* status but NO role. Their HTTP twins are gated ``require_admin_read`` (reads) / ``require_admin`` (writes). Since any employee can self-mint a Personal Access Token, a plain employee could call these tools over MCP and read every supplier's tax_id / banking / contacts and all PO amounts, and create suppliers + draft POs — data the HTTP layer returns 403 for. See ``feedback_mcp_sync_rule``. Invariant under test: a plain ``employee`` is refused (ForbiddenError) on every procurement tool; a ``global_admin`` is admitted on reads; writes are admitted for ``global_admin`` only. """ from __future__ import annotations import pytest from app.core.exceptions import AppException, ForbiddenError from app.mcp import context as _mcp_ctx from app.mcp import tools as _tools pytestmark = pytest.mark.asyncio # (tool coroutine, kwargs) for every procurement READ tool. _READ_CALLS = [ (_tools.list_procurement_categories, {}), (_tools.list_procurement_suppliers, {}), (_tools.list_procurement_contracts, {}), (_tools.list_procurement_orders, {}), ( _tools.get_procurement_contract, {"contract_id": "00000000-0000-0000-0000-000000000000"}, ), ( _tools.get_procurement_order, {"order_id": "00000000-0000-0000-0000-000000000000"}, ), (_tools.list_procurement_budgets, {}), ( _tools.get_procurement_budget, {"budget_id": "00000000-0000-0000-0000-000000000000"}, ), ] # Write tools — global_admin only. _WRITE_CALLS = [ (_tools.create_procurement_supplier, {"name": "ACME Test Co"}), ( _tools.create_procurement_order, {"order_type": "purchase", "items": [{"description": "x", "unit_price": "1"}]}, ), ] @pytest.fixture() def _patch_factory(db_engine, monkeypatch): """Point the module-level session factory (used by ``tool_runtime``) at the test engine so committed fixture rows are visible.""" from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core import database as _db monkeypatch.setattr( _db, "async_session_factory", async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False), ) def _as_user(user_id): """Set the MCP user contextvar, returning a reset token.""" return _mcp_ctx.mcp_user_id.set(user_id) async def test_employee_is_refused_on_every_procurement_read( employee_user, _patch_factory, ): user, _ = employee_user token = _as_user(user.id) try: for fn, kwargs in _READ_CALLS: with pytest.raises(ForbiddenError): await fn(**kwargs) finally: _mcp_ctx.mcp_user_id.reset(token) async def test_employee_is_refused_on_every_procurement_write( employee_user, _patch_factory, ): user, _ = employee_user token = _as_user(user.id) try: for fn, kwargs in _WRITE_CALLS: with pytest.raises(ForbiddenError): await fn(**kwargs) finally: _mcp_ctx.mcp_user_id.reset(token) async def test_admin_admitted_on_procurement_reads(admin_user, _patch_factory): user, _ = admin_user token = _as_user(user.id) try: # Listing tools must succeed (empty catalog → empty data, no raise). res = await _tools.list_procurement_suppliers() assert "data" in res res = await _tools.list_procurement_categories() assert "data" in res finally: _mcp_ctx.mcp_user_id.reset(token) async def test_admin_can_create_supplier(admin_user, _patch_factory): user, _ = admin_user token = _as_user(user.id) try: res = await _tools.create_procurement_supplier(name="ACME Round56 Test") assert res["data"]["name"] == "ACME Round56 Test" assert res["data"]["id"] finally: _mcp_ctx.mcp_user_id.reset(token) async def test_leaver_admin_is_refused_on_every_procurement_write( admin_user, db_session, _patch_factory, ): user, _ = admin_user user.employment_status = "leaver_pending" await db_session.commit() token = _as_user(user.id) try: for fn, kwargs in _WRITE_CALLS: with pytest.raises(AppException) as error: await fn(**kwargs) assert error.value.code == "LEAVER_RUNTIME_LOCKED" finally: _mcp_ctx.mcp_user_id.reset(token) async def test_mcp_order_create_reuses_http_idempotency_contract( admin_user, _patch_factory, db_session, ): """MCP callers get the same durable key/fingerprint semantics as HTTP.""" from sqlalchemy import func, select from app.domains.procurement.models import ProcurementOrder user, _ = admin_user token = _as_user(user.id) key = f"mcp-po-{user.id}" kwargs = { "order_type": "purchase", "items": [{"description": "MCP idempotent PO", "unit_price": "10"}], "total_amount": "10", "idempotency_key": key, } try: first = await _tools.create_procurement_order(**kwargs) replay = await _tools.create_procurement_order(**kwargs) assert replay["data"]["id"] == first["data"]["id"] assert await db_session.scalar( select(func.count()).select_from(ProcurementOrder).where( ProcurementOrder.id == first["data"]["id"], ) ) == 1 finally: _mcp_ctx.mcp_user_id.reset(token) async def test_asset_admin_refused_on_budget_list(asset_admin_user, _patch_factory): """MCP↔HTTP parity: the budget LIST is finance-governance data. The region-scoped asset_admin passes the coarse software-read gate the other procurement read tools use, but must NOT enumerate every department's budget — same finance-tier gate the HTTP twin GET /procurement/budgets enforces. (Other procurement reads stay open to asset_admin.)""" user, _ = asset_admin_user token = _as_user(user.id) try: with pytest.raises(ForbiddenError): await _tools.list_procurement_budgets() # Sanity: a non-budget procurement read still admits asset_admin. res = await _tools.list_procurement_suppliers() assert "data" in res finally: _mcp_ctx.mcp_user_id.reset(token)