336 lines
15 KiB
Python
336 lines
15 KiB
Python
# #region Test.McpAutomationParity [C:4] [TYPE Module] [SEMANTICS test,mcp,automation,rbac,acl,parity]
|
|
# @BRIEF 046 T019 / DG-2: REST<->MCP admission parity for the four automation reads plus the
|
|
# disabled==enabled HumanStep rejection parity (DEF-02) on the MCP surface.
|
|
# @RELATION VERIFIES -> [McpServer.ToolsAutomation.Register]
|
|
# @RELATION VERIFIES -> [McpServer.RbacServer]
|
|
# @RELATION VERIFIES -> [ScenarioAutomation.Eligibility.Assert]
|
|
# @TEST_INVARIANT Reads admit any authenticated principal type with scenario:automation READ
|
|
# (human) or the mcp:read scope (service); anonymous -> unauthenticated (401),
|
|
# lacking grant -> permission_denied (403), foreign scenario -> not_found (404).
|
|
# @TEST_INVARIANT A disabled schedule bound to a human-step revision is rejected with
|
|
# AUTOMATION_INELIGIBLE_HUMAN_STEP and zero side-effect rows (DEF-02, MCP side).
|
|
from __future__ import annotations
|
|
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from mcp.server.fastmcp import FastMCP
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
import src.mcp_server.rbac_server as rbac_module
|
|
import src.mcp_server.tools_automation as automation_module
|
|
from src.mcp_server import server as mcp_server
|
|
from src.mcp_server.auth import _access_token_context
|
|
from src.mcp_server.tools_automation import register_automation_tools
|
|
from src.models.auth import Permission, Role, User
|
|
from src.models.mapping import Base
|
|
from src.models.scenario_automation import AutomationPolicy, ScenarioSchedule
|
|
from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision
|
|
from src.services.dashboard_testing.scenario.templates import (
|
|
ACTION_REGISTRY_VERSION,
|
|
action_registry_fingerprint,
|
|
resolve_action_descriptor,
|
|
)
|
|
|
|
|
|
# #region Test.McpAutomationParity.Fixtures [C:3] [TYPE Block] [SEMANTICS test,mcp,sqlite,fixture]
|
|
# @ingroup Test.McpAutomationParity
|
|
# @BRIEF Isolated shared SQLite wired into the automation/rbac SessionLocal seams; hardcoded
|
|
# owner/foreign fixture rows for ACL assertions.
|
|
@pytest.fixture
|
|
def isolated_db(tmp_path, monkeypatch):
|
|
engine = create_engine(f"sqlite:///{tmp_path / 'mcp_automation.db'}", connect_args={"check_same_thread": False})
|
|
event.listen(engine, "connect", lambda connection, _: connection.execute("PRAGMA foreign_keys=ON"))
|
|
Base.metadata.create_all(engine)
|
|
factory = sessionmaker(bind=engine)
|
|
monkeypatch.setattr(automation_module, "SessionLocal", factory)
|
|
monkeypatch.setattr(rbac_module, "SessionLocal", factory)
|
|
try:
|
|
yield factory
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def _seed_user(factory, username: str, permissions: list[tuple[str, str]], *, admin: bool = False) -> None:
|
|
role = Role(name=f"role-{username}", is_admin=admin)
|
|
role.permissions = [Permission(resource=resource, action=action) for resource, action in permissions]
|
|
with factory() as db:
|
|
db.add(User(username=username, password_hash="x", is_active=True, roles=[role]))
|
|
db.commit()
|
|
|
|
|
|
def _seed_acl_rows(factory) -> dict[str, str]:
|
|
"""Hardcoded fixture: scenario owned by 'acl.reader' plus one foreign scenario, one
|
|
schedule and one referenced policy each."""
|
|
with factory() as db:
|
|
own_policy = AutomationPolicy(name="mcp-acl-policy-own")
|
|
foreign_policy = AutomationPolicy(name="mcp-acl-policy-foreign")
|
|
db.add_all([own_policy, foreign_policy])
|
|
db.flush()
|
|
own_scenario = str(uuid4())
|
|
foreign_scenario = str(uuid4())
|
|
db.add(ScenarioRegistryEntry(
|
|
scenario_id=own_scenario, scenario_key=f"own-{uuid4().hex[:8]}", name="own",
|
|
dashboard_id=81, environment_ids=["env-dev"],
|
|
owner_id="acl.reader", owner_username="acl.reader",
|
|
lifecycle_status="READY", validation_status="valid",
|
|
))
|
|
db.add(ScenarioRegistryEntry(
|
|
scenario_id=foreign_scenario, scenario_key=f"foreign-{uuid4().hex[:8]}", name="foreign",
|
|
dashboard_id=82, environment_ids=["env-dev"],
|
|
owner_id="foreign.owner", owner_username="foreign.owner",
|
|
lifecycle_status="READY", validation_status="valid",
|
|
))
|
|
own_schedule = ScenarioSchedule(
|
|
scenario_id=own_scenario, environment_id="env-dev", cron_expr="0 7 * * *",
|
|
policy_id=own_policy.id,
|
|
)
|
|
foreign_schedule = ScenarioSchedule(
|
|
scenario_id=foreign_scenario, environment_id="env-dev", cron_expr="0 8 * * *",
|
|
policy_id=foreign_policy.id,
|
|
)
|
|
db.add_all([own_schedule, foreign_schedule])
|
|
db.commit()
|
|
return {
|
|
"own_scenario": own_scenario, "foreign_scenario": foreign_scenario,
|
|
"own_schedule": own_schedule.id, "foreign_schedule": foreign_schedule.id,
|
|
"own_policy": own_policy.id, "foreign_policy": foreign_policy.id,
|
|
}
|
|
|
|
|
|
def _seed_human_scenario(factory, owner: str) -> tuple[str, str]:
|
|
"""Human-step current revision fixture for the DEF-02 disabled==enabled rejection."""
|
|
scenario_id = str(uuid4())
|
|
revision_id = str(uuid4())
|
|
graph = {
|
|
"action_registry_version": ACTION_REGISTRY_VERSION,
|
|
"action_registry_hash": action_registry_fingerprint(),
|
|
"steps": [{
|
|
"logical_step_id": "human",
|
|
"tool": "human",
|
|
"action": "human_checkpoint",
|
|
"action_descriptor": resolve_action_descriptor(
|
|
tool="human", action="human_checkpoint",
|
|
registry_version=ACTION_REGISTRY_VERSION,
|
|
registry_hash=action_registry_fingerprint(),
|
|
).snapshot(),
|
|
}],
|
|
"dependencies": [],
|
|
"environment_ids": ["env-dev"],
|
|
}
|
|
with factory() as db:
|
|
db.add(ScenarioRegistryEntry(
|
|
scenario_id=scenario_id, scenario_key=f"human-{uuid4().hex[:8]}", name="human fixture",
|
|
dashboard_id=83, environment_ids=["env-dev"], owner_id=owner, owner_username=owner,
|
|
lifecycle_status="READY", validation_status="valid", current_revision_id=revision_id,
|
|
))
|
|
db.add(ScenarioRevision(
|
|
revision_id=revision_id, scenario_id=scenario_id, content_hash="h" * 64,
|
|
graph_snapshot=graph, execution_template_hash="", template_version="v1",
|
|
schema_version=1, compatibility_family="default", change_summary={},
|
|
created_by=owner, activation_status="current",
|
|
))
|
|
db.commit()
|
|
return scenario_id, revision_id
|
|
|
|
|
|
def _token(subject: str, *, scopes: list[str], principal_type: str = "user"):
|
|
return _access_token_context.set(mcp_server.AccessToken(
|
|
token=f"token-{uuid4().hex[:8]}", client_id=subject, scopes=scopes,
|
|
subject=subject, claims={"principal_type": principal_type},
|
|
))
|
|
|
|
|
|
def _unwrap(value):
|
|
if isinstance(value, tuple):
|
|
return _unwrap(value[1])
|
|
if isinstance(value, dict) and set(value) == {"result"}:
|
|
return _unwrap(value["result"])
|
|
return value
|
|
|
|
|
|
def _plain_server():
|
|
"""FastMCP without the RbacFastMCP catalog layer — proves tool-body semantic codes."""
|
|
server = FastMCP("automation-body-probe")
|
|
register_automation_tools(server)
|
|
return server
|
|
|
|
|
|
def _rbac_server():
|
|
server = mcp_server._build_probe_server()
|
|
server._record = lambda **_: None
|
|
return server
|
|
# #endregion Test.McpAutomationParity.Fixtures
|
|
|
|
|
|
# #region Test.McpAutomationParity.BodyCodes [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,semantic-codes]
|
|
# @ingroup Test.McpAutomationParity
|
|
# @BRIEF Tool-body admission codes match the REST semantics: 401 unauthenticated,
|
|
# 403 permission_denied, 404 not_found (foreign == missing).
|
|
@pytest.mark.asyncio
|
|
async def test_read_body_semantic_codes(isolated_db) -> None:
|
|
ids = _seed_acl_rows(isolated_db)
|
|
_seed_user(isolated_db, "acl.reader", [("scenario:automation", "READ")])
|
|
server = _plain_server()
|
|
|
|
# Anonymous (401 semantic): no token context at all.
|
|
with pytest.raises(Exception, match="unauthenticated"):
|
|
await server.call_tool("list_scenario_schedules", {})
|
|
|
|
# Service principal without the mcp:read scope (403 semantic).
|
|
token = _token("svc-noscope", scopes=["mcp"], principal_type="service")
|
|
try:
|
|
with pytest.raises(Exception, match="permission_denied"):
|
|
await server.call_tool("list_scenario_schedules", {})
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
|
|
# Foreign scenario addressed by a READ holder resolves exactly like a missing one (404).
|
|
token = _token("acl.reader", scopes=["mcp"])
|
|
try:
|
|
foreign_policy = _unwrap(await server.call_tool(
|
|
"get_scenario_automation_policy", {"scenario_id": ids["foreign_scenario"]}
|
|
))
|
|
missing_policy = _unwrap(await server.call_tool(
|
|
"get_scenario_automation_policy", {"scenario_id": f"missing-{uuid4()}"}
|
|
))
|
|
assert foreign_policy == {"status": "not_found", "scenario_id": ids["foreign_scenario"]}
|
|
# Foreign and missing projections are indistinguishable apart from the echoed id.
|
|
assert {**foreign_policy, "scenario_id": None} == {**missing_policy, "scenario_id": None}
|
|
foreign_metrics = _unwrap(await server.call_tool(
|
|
"get_scenario_automation_metrics", {"scenario_id": ids["foreign_scenario"]}
|
|
))
|
|
assert foreign_metrics == {"status": "not_found", "scenario_id": ids["foreign_scenario"]}
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
# #endregion Test.McpAutomationParity.BodyCodes
|
|
|
|
|
|
# #region Test.McpAutomationParity.Acl [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,acl]
|
|
# @ingroup Test.McpAutomationParity
|
|
# @BRIEF Per-object ACL on reads: owners see own rows only; service principals with scope are
|
|
# admitted and see only rows they own; admins see all rows.
|
|
@pytest.mark.asyncio
|
|
async def test_read_acl_filters_foreign_rows(isolated_db) -> None:
|
|
ids = _seed_acl_rows(isolated_db)
|
|
_seed_user(isolated_db, "acl.reader", [("scenario:automation", "READ")])
|
|
_seed_user(isolated_db, "acl.admin", [], admin=True)
|
|
server = _plain_server()
|
|
|
|
token = _token("acl.reader", scopes=["mcp"])
|
|
try:
|
|
listed = _unwrap(await server.call_tool("list_scenario_schedules", {}))
|
|
schedule_ids = {item["id"] for item in listed}
|
|
assert ids["own_schedule"] in schedule_ids
|
|
assert ids["foreign_schedule"] not in schedule_ids
|
|
# Addressing the foreign scenario directly yields an empty projection (no leak).
|
|
foreign_only = _unwrap(await server.call_tool(
|
|
"list_scenario_schedules", {"scenario_id": ids["foreign_scenario"]}
|
|
))
|
|
assert foreign_only == []
|
|
own_metrics = _unwrap(await server.call_tool(
|
|
"get_scenario_automation_metrics", {"scenario_id": ids["own_scenario"]}
|
|
))
|
|
assert own_metrics["schedules_total"] == 1
|
|
own_policy = _unwrap(await server.call_tool(
|
|
"get_scenario_automation_policy", {"scenario_id": ids["own_scenario"]}
|
|
))
|
|
assert own_policy["id"] == ids["own_policy"]
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
|
|
token = _token("svc-automation", scopes=["mcp", "mcp:read"], principal_type="service")
|
|
try:
|
|
listed = _unwrap(await server.call_tool("list_scenario_schedules", {}))
|
|
assert listed == []
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
|
|
token = _token("acl.admin", scopes=["mcp"])
|
|
try:
|
|
listed = _unwrap(await server.call_tool("list_scenario_schedules", {}))
|
|
schedule_ids = {item["id"] for item in listed}
|
|
assert ids["own_schedule"] in schedule_ids
|
|
assert ids["foreign_schedule"] in schedule_ids
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
# #endregion Test.McpAutomationParity.Acl
|
|
|
|
|
|
# #region Test.McpAutomationParity.RbacCatalog [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,catalog,rbac]
|
|
# @ingroup Test.McpAutomationParity
|
|
# @BRIEF RbacFastMCP catalog parity: scoped service principals pass reads and are denied
|
|
# human-only mutations; humans without READ are denied reads.
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_admission_parity(isolated_db) -> None:
|
|
_seed_user(isolated_db, "acl.reader", [("scenario:automation", "READ")])
|
|
_seed_user(isolated_db, "acl.manager", [("scenario:automation", "MANAGE")])
|
|
server = _rbac_server()
|
|
|
|
token = _token("svc-automation", scopes=["mcp", "mcp:read"], principal_type="service")
|
|
try:
|
|
listed = _unwrap(await server.call_tool("list_scenario_schedules", {}))
|
|
assert listed == []
|
|
with pytest.raises(Exception, match="permission_denied"):
|
|
await server.call_tool("upsert_scenario_schedule", {"request": {
|
|
"scenario_id": "sc", "idempotency_key": f"k-{uuid4()}", "environment_id": "env-dev",
|
|
"revision_id": str(uuid4()), "cron_expr": "0 7 * * *",
|
|
}})
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
|
|
token = _token("acl.manager", scopes=["mcp"])
|
|
try:
|
|
with pytest.raises(Exception, match="permission_denied"):
|
|
await server.call_tool("list_scenario_schedules", {})
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
|
|
# Human-only mutation surface unchanged: a service principal is refused by owner() even
|
|
# when invoked below the catalog layer.
|
|
plain = _plain_server()
|
|
token = _token("svc-automation", scopes=["mcp", "mcp:read"], principal_type="service")
|
|
try:
|
|
with pytest.raises(Exception, match="human_principal_required"):
|
|
await plain.call_tool("upsert_scenario_schedule", {"request": {
|
|
"scenario_id": "sc", "idempotency_key": f"k-{uuid4()}", "environment_id": "env-dev",
|
|
"revision_id": str(uuid4()), "cron_expr": "0 7 * * *",
|
|
}})
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
# #endregion Test.McpAutomationParity.RbacCatalog
|
|
|
|
|
|
# #region Test.McpAutomationParity.DisabledReject [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,eligibility,def02]
|
|
# @ingroup Test.McpAutomationParity
|
|
# @BRIEF DEF-02 MCP side: a disabled schedule bound to a human-step revision is rejected with
|
|
# the identical code as enabled and leaves zero side-effect rows.
|
|
# @TEST_INVARIANT ScenarioAutomation.Eligibility.Assert: enabled=False does not bypass the
|
|
# revision-bound guard -> VERIFIED_BY: test_disabled_human_schedule_rejected
|
|
@pytest.mark.asyncio
|
|
async def test_disabled_human_schedule_rejected(isolated_db) -> None:
|
|
_seed_user(isolated_db, "acl.manager", [
|
|
("scenario:automation", "READ"), ("scenario:automation", "MANAGE"),
|
|
])
|
|
scenario_id, revision_id = _seed_human_scenario(isolated_db, owner="acl.manager")
|
|
server = _rbac_server()
|
|
token = _token("acl.manager", scopes=["mcp"])
|
|
try:
|
|
with isolated_db() as db:
|
|
before = db.query(ScenarioSchedule).count()
|
|
for enabled in (True, False):
|
|
with pytest.raises(Exception, match="AUTOMATION_INELIGIBLE_HUMAN_STEP"):
|
|
await server.call_tool("upsert_scenario_schedule", {"request": {
|
|
"scenario_id": scenario_id, "idempotency_key": f"disabled-{enabled}-{uuid4()}",
|
|
"environment_id": "env-dev", "revision_id": revision_id,
|
|
"revision_policy": "pinned", "cron_expr": "0 7 * * *", "enabled": enabled,
|
|
}})
|
|
with isolated_db() as db:
|
|
assert db.query(ScenarioSchedule).count() == before
|
|
finally:
|
|
_access_token_context.reset(token)
|
|
# #endregion Test.McpAutomationParity.DisabledReject
|
|
|
|
# #endregion Test.McpAutomationParity
|