"""Regression: the 3 reconciliation-fix MCP tools (PR4) must enforce the SAME RBAC as their HTTP twins — a plain employee with a self-minted PAT must be refused. * transfer_apikey_assignment_owner / re_enable_apikey_cost_rules → ``require_asset_admin`` (can_manage_assets). * refresh_ai_spending_rollup → ``can_manage_ai_spending`` (IT admin). """ from __future__ import annotations import pytest from app.core.exceptions import ForbiddenError from app.mcp import context as _mcp_ctx from app.mcp import tools as _tools pytestmark = pytest.mark.asyncio _ZERO = "00000000-0000-0000-0000-000000000000" _CALLS = [ (_tools.transfer_apikey_assignment_owner, {"assignment_id": _ZERO, "assigned_to": _ZERO}), (_tools.re_enable_apikey_cost_rules, {"assignment_id": _ZERO}), (_tools.refresh_ai_spending_rollup, {"period": None}), ] @pytest.fixture() def _patch_factory(db_engine, monkeypatch): 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): return _mcp_ctx.mcp_user_id.set(user_id) async def test_employee_refused_on_every_recon_fix_tool( employee_user, _patch_factory, ): user, _ = employee_user token = _as_user(user.id) try: for fn, kwargs in _CALLS: with pytest.raises(ForbiddenError): await fn(**kwargs) finally: _mcp_ctx.mcp_user_id.reset(token) async def test_admin_passes_rbac_gate(admin_user, _patch_factory): """Admin clears the RBAC gate — refresh_ai_spending_rollup with a closed month exercises the full path (compute on an empty test DB just writes zero rows). The other two would 404 on the zero UUID AFTER the gate, which is the gate-passed signal we want.""" from datetime import UTC, datetime from unittest.mock import AsyncMock, patch from app.core.exceptions import NotFoundError from app.domains.ai_spending.service import AiSpendingService user, _ = admin_user token = _as_user(user.id) try: # rollup refresh: gate passes (compute stubbed — the migration-149 # whitelist setting isn't in the create_all test schema, and we're # testing the RBAC gate not the rollup mechanics). with patch.object( AiSpendingService, "refresh_period_rollup", AsyncMock(return_value=0), ): res = await _tools.refresh_ai_spending_rollup( period=f"{datetime.now(UTC).year - 1:04d}-01", ) # MCP tools return the serialized payload directly (not enveloped). assert "periods" in res # apikey tool: gate passes, then NotFound on the zero UUID # (NOT ForbiddenError — that would mean the gate blocked admin). with pytest.raises(Exception) as exc: await _tools.re_enable_apikey_cost_rules(assignment_id=_ZERO) assert not isinstance(exc.value, ForbiddenError) assert isinstance(exc.value, NotFoundError) finally: _mcp_ctx.mcp_user_id.reset(token)