Files
ss-tools/backend/tests/test_mcp_automation_parity.py

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