"""WP-230A regression tests: real per-endpoint management RBAC. Exercises the actual FastAPI routes over real HTTP (via TestClient), through a real PKCE/BFF login against a fake-but-real OIDC provider (services/control_api/auth/fake_provider.py), against a real SQLite-backed app built with services/runtime/control_api.py's build_app() -- not a hand-rolled stand-in for the routing. This is deliberately heavier than a pure services/control_api/authz.py unit test (see test_wp230_authz.py for that): it is the only way to prove the *actual wiring* -- that each route really does call the permission matrix with the action its own design intends -- rather than just proving the matrix itself is correct in isolation. """ from __future__ import annotations import os from collections.abc import Iterator from dataclasses import dataclass from http.cookies import SimpleCookie from pathlib import Path from urllib.parse import parse_qs, urlparse import pytest from fastapi.testclient import TestClient from services.control_api.auth.config import OIDCSettings from services.control_api.auth.fake_provider import FakeOIDCProvider from services.control_api.auth.models import ManagementRole from services.control_api.contextforge.fake import FakeContextForge from services.control_api.contextforge.models import ContextForgeSettings from services.control_api.persistence import Base, create_engine_from_url, session_factory from services.runtime.control_api import build_app from services.runtime.state_sync import sync_runtime_state ROOT = Path(__file__).resolve().parents[1] ISSUER = "https://fake.oidc.test" ROLE_GROUPS: dict[ManagementRole, str] = { ManagementRole.VIEWER: "hub-viewer", ManagementRole.AUDITOR: "hub-auditor", ManagementRole.OPERATOR: "hub-operator", ManagementRole.ADMIN: "hub-admin", } ALL_ROLES = tuple(ROLE_GROUPS) @dataclass class RoleSession: role: ManagementRole client: TestClient csrf: str def get(self, path: str, **kwargs: object) -> object: return self.client.get(path, **kwargs) # type: ignore[arg-type] def post(self, path: str, **kwargs: object) -> object: headers = dict(kwargs.pop("headers", {}) or {}) # type: ignore[arg-type] headers["X-CSRF-Token"] = self.csrf return self.client.post(path, headers=headers, **kwargs) # type: ignore[arg-type] def _login(client: TestClient, provider: FakeOIDCProvider) -> str: login_response = client.get("/auth/login", follow_redirects=False) assert login_response.status_code == 307 location = login_response.headers["location"] query = parse_qs(urlparse(location).query) code = provider.authorize(location) callback_response = client.get( "/auth/callback", params={"code": code, "state": query["state"][0]}, follow_redirects=False, ) assert callback_response.status_code == 303 # The session cookie is Secure (required by OIDCSettings), and # TestClient's default base_url is plain http:// -- its cookie jar # correctly refuses to store/replay a Secure cookie over that scheme, so # the cookie is set directly rather than relying on the jar (same # pattern tests/test_auth.py already uses for the same reason). cookie = SimpleCookie() cookie.load(callback_response.headers["set-cookie"]) cookie_name = next(iter(cookie)) client.cookies.set(cookie_name, cookie[cookie_name].value) session_response = client.get("/auth/session") assert session_response.status_code == 200, session_response.text return str(session_response.json()["csrf_token"]) def _build_role_app(db_path: Path, role: ManagementRole) -> tuple[object, FakeOIDCProvider]: # These module-scoped sessions deliberately cover the complete endpoint # matrix. Give this fixture the production session ceiling so a slow CI # runner cannot turn suite duration into unrelated RBAC failures. provider = FakeOIDCProvider( issuer=ISSUER, user_groups=(ROLE_GROUPS[role],), token_ttl_seconds=28_800, ) oidc = OIDCSettings( issuer=ISSUER, audience=provider.audience, client_id="hub-bff", redirect_uri="https://hub.test/auth/callback", secure_cookie=True, role_group_map={group: r for r, group in ROLE_GROUPS.items()}, ) contextforge_fake = FakeContextForge() os.environ["WP230_FAKE_CONTEXTFORGE_TOKEN"] = "fake-token" # noqa: S105 engine = create_engine_from_url(f"sqlite:///{db_path}") app = build_app( oidc=oidc, provider_transport=provider.transport(), contextforge_settings=ContextForgeSettings( base_url="https://contextforge.test", auth_token_env="WP230_FAKE_CONTEXTFORGE_TOKEN", # noqa: S106 -- env var name, not a secret ), contextforge_transport=contextforge_fake.transport(), engine=engine, catalog_root=ROOT / "catalog", ) return app, provider @pytest.fixture(scope="module", autouse=True) def _client_bindings_env() -> Iterator[None]: # sync_runtime_state/reconcile_runtime_state both read this at call time # (not just once at seeding) -- catalog.apply re-invokes it per request, # so it must stay set for the whole module, not just around seeding. previous = os.environ.get("MCP_CLIENT_BINDINGS_JSON") os.environ["MCP_CLIENT_BINDINGS_JSON"] = "[]" yield if previous is None: os.environ.pop("MCP_CLIENT_BINDINGS_JSON", None) else: os.environ["MCP_CLIENT_BINDINGS_JSON"] = previous @pytest.fixture(scope="module") def db_path(tmp_path_factory: pytest.TempPathFactory) -> Path: path = tmp_path_factory.mktemp("wp230-rbac") / "rbac.db" engine = create_engine_from_url(f"sqlite:///{path}") Base.metadata.create_all(engine) sessions = session_factory(engine) with sessions() as session, session.begin(): sync_runtime_state(session, ROOT / "catalog", ISSUER) engine.dispose() return path @pytest.fixture(scope="module") def role_sessions(db_path: Path) -> Iterator[dict[ManagementRole, RoleSession]]: sessions: dict[ManagementRole, RoleSession] = {} clients: list[TestClient] = [] for role in ALL_ROLES: app, provider = _build_role_app(db_path, role) client = TestClient(app) clients.append(client) csrf = _login(client, provider) sessions[role] = RoleSession(role=role, client=client, csrf=csrf) yield sessions for client in clients: client.close() # --- Reads: available to every role ----------------------------------- @pytest.mark.parametrize("role", ALL_ROLES) def test_dashboard_read_available_to_every_role( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].get("/api/dashboard") assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("kind", ["projects", "servers", "tools", "connectors", "policies"]) @pytest.mark.parametrize("role", ALL_ROLES) def test_resource_read_available_to_every_role( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, kind: str ) -> None: response = role_sessions[role].get(f"/api/{kind}") assert response.status_code == 200, (role, kind, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("role", ALL_ROLES) def test_client_list_available_to_every_role( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].get("/api/client-management") assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("role", ALL_ROLES) def test_policy_simulate_available_to_every_role( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post( "/api/policies/simulate", json={"project": "nonexistent", "environment": "prod", "tool": "nonexistent"}, ) assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("role", ALL_ROLES) def test_catalog_preview_available_to_every_role( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post("/api/catalog/preview", json={"project": "ludarium"}) assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] preview = response.json() # type: ignore[union-attr] assert preview["valid"] is True assert preview["changes"] == ["reconcile all registered project routes, including ludarium"] # --- Tool readiness: bounded live probe, operator+ only ------------------ @pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR]) def test_tool_readiness_denied_below_operator( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post( "/api/tools/test", json={ "project": "mobilityops", "environment": "prod", "tool": "mobilityops.operations.summary", "arguments": {}, }, ) assert response.status_code == 403, (role, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("role", [ManagementRole.OPERATOR, ManagementRole.ADMIN]) def test_tool_readiness_is_wired_for_operator_and_admin( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post( "/api/tools/test", json={ "project": "mobilityops", "environment": "prod", "tool": "mobilityops.operations.summary", "arguments": {}, }, ) assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] body = response.json() # type: ignore[union-attr] assert body["note"] == "Readiness only; no MCP tool invocation was performed." assert body["checks"]["input"]["valid"] is True def test_tool_readiness_proves_full_registered_path_after_reconciliation( role_sessions: dict[ManagementRole, RoleSession], ) -> None: admin = role_sessions[ManagementRole.ADMIN] applied = admin.post("/api/catalog/apply", json={"project": "mobilityops"}) assert applied.status_code == 200, applied.text # type: ignore[union-attr] response = admin.post( "/api/tools/test", json={ "project": "mobilityops", "environment": "prod", "tool": "mobilityops.operations.summary", "arguments": {}, }, ) assert response.status_code == 200, response.text # type: ignore[union-attr] body = response.json() # type: ignore[union-attr] assert body["ready"] is True assert body["checks"]["binding"]["valid"] is True assert body["checks"]["policy"]["allowed"] is True assert body["checks"]["connector"]["reachable"] is True drift = admin.get("/api/dashboard").json()["drift"] # type: ignore[union-attr] assert drift == { "count": 0, "projects": [], "known": True, "detail": "No connector gateway drift measured.", } def test_tool_readiness_rejects_an_unverified_catalog_binding( role_sessions: dict[ManagementRole, RoleSession], ) -> None: response = role_sessions[ManagementRole.OPERATOR].post( "/api/tools/test", json={ "project": "devrunbook", "environment": "prod", "tool": "knowledge.index.status", "arguments": {}, }, ) assert response.status_code == 200, response.text # type: ignore[union-attr] body = response.json() # type: ignore[union-attr] assert body["ready"] is False assert body["checks"]["binding"]["valid"] is False def test_tool_readiness_validates_input_without_echoing_values( role_sessions: dict[ManagementRole, RoleSession], ) -> None: response = role_sessions[ManagementRole.OPERATOR].post( "/api/tools/test", json={ "project": "mobilityops", "environment": "prod", "tool": "mobilityops.vehicle.get", "arguments": {"unexpected": "must-not-be-echoed"}, }, ) assert response.status_code == 200, response.text # type: ignore[union-attr] serialized = response.text # type: ignore[union-attr] body = response.json() # type: ignore[union-attr] assert body["ready"] is False assert body["checks"]["input"]["valid"] is False assert "must-not-be-echoed" not in serialized assert len(body["checks"]["input"]["errors"]) == 2 def test_tool_readiness_reports_cataloged_but_undeployed_connector_type( role_sessions: dict[ManagementRole, RoleSession], ) -> None: response = role_sessions[ManagementRole.OPERATOR].post( "/api/tools/test", json={ "project": "ludarium", "environment": "prod", "tool": "deployment.project.status", "arguments": {}, }, ) assert response.status_code == 200, response.text # type: ignore[union-attr] body = response.json() # type: ignore[union-attr] assert body["ready"] is False assert body["checks"]["connector"] == { "reachable": False, "status": "not-deployed", "detail": "No production connector gateway is registered for this connector type.", } def test_tool_readiness_enforces_argument_byte_limit( role_sessions: dict[ManagementRole, RoleSession], ) -> None: response = role_sessions[ManagementRole.OPERATOR].post( "/api/tools/test", json={ "project": "mobilityops", "environment": "prod", "tool": "mobilityops.knowledge.search", "arguments": {"query": "🚗" * 9000}, }, ) assert response.status_code == 422, response.text # type: ignore[union-attr] def test_tool_readiness_request_contract_fails_closed_on_extra_fields( role_sessions: dict[ManagementRole, RoleSession], ) -> None: response = role_sessions[ManagementRole.ADMIN].post( "/api/tools/test", json={ "project": "mobilityops", "environment": "prod", "tool": "mobilityops.operations.summary", "arguments": {}, "invoke": True, }, ) assert response.status_code == 422, response.text # type: ignore[union-attr] @pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR]) def test_tool_invocation_denied_below_operator_before_secret_or_network_access( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post( "/api/tools/invoke", json={ "project": "mobilityops", "environment": "prod", "tool": "mobilityops.operations.summary", "arguments": {}, }, ) assert response.status_code == 403, (role, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR]) def test_live_operations_check_denied_below_operator( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post("/api/operations/check") assert response.status_code == 403, (role, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("role", [ManagementRole.OPERATOR, ManagementRole.ADMIN]) def test_live_operations_check_available_to_operator_and_admin( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post("/api/operations/check") assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] assert response.json()["connectors"] # type: ignore[union-attr] assert {"id": "knowledge-default", "name": "Knowledge Default"} in response.json()[ "connectors" ]["itworx-knowledge"]["connectorRefs"] # type: ignore[union-attr] # --- Audit read: auditor+ only, viewer denied --------------------------- def test_audit_read_denied_for_viewer(role_sessions: dict[ManagementRole, RoleSession]) -> None: response = role_sessions[ManagementRole.VIEWER].get("/api/audit") assert response.status_code == 403, response.text # type: ignore[union-attr] @pytest.mark.parametrize( "role", [ManagementRole.AUDITOR, ManagementRole.OPERATOR, ManagementRole.ADMIN] ) def test_audit_read_allowed_for_auditor_and_above( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].get("/api/audit") assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] # --- Client lifecycle mutations: exact role boundaries ------------------ @pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR]) def test_client_create_denied_below_admin( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post( "/api/client-management", json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"}, ) assert response.status_code == 403, (role, response.text) # type: ignore[union-attr] def test_client_create_denied_for_operator( role_sessions: dict[ManagementRole, RoleSession], ) -> None: response = role_sessions[ManagementRole.OPERATOR].post( "/api/client-management", json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"}, ) assert response.status_code == 403, response.text # type: ignore[union-attr] def test_client_create_allowed_for_admin(role_sessions: dict[ManagementRole, RoleSession]) -> None: response = role_sessions[ManagementRole.ADMIN].post( "/api/client-management", json={ "name": "wp230-admin-created", "project": "ludarium", "environment": "prod", "kind": "native-2026", }, ) assert response.status_code == 201, response.text # type: ignore[union-attr] @pytest.mark.parametrize("action", ["bootstrap", "verify", "disable", "revoke"]) @pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR]) def test_client_lifecycle_actions_denied_below_operator( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, action: str ) -> None: # A nonexistent client ID is fine here -- the RBAC check happens before # the client lookup, so a 403 (not a 404) proves the gate fired first. response = role_sessions[role].post( f"/api/client-management/00000000-0000-0000-0000-000000000000/{action}" ) assert response.status_code == 403, (role, action, response.text) # type: ignore[union-attr] @pytest.mark.parametrize("action", ["bootstrap", "verify", "disable"]) def test_client_lifecycle_actions_allowed_for_operator( role_sessions: dict[ManagementRole, RoleSession], action: str ) -> None: # Create the client via the admin session (create is admin-only), then # prove operator can perform the bounded lifecycle action on it. created = role_sessions[ManagementRole.ADMIN].post( "/api/client-management", json={ "name": f"wp230-operator-{action}", "project": "ludarium", "environment": "prod", "kind": "native-2026", }, ) assert created.status_code == 201, created.text # type: ignore[union-attr] client_id = created.json()["client"]["id"] # type: ignore[union-attr] response = role_sessions[ManagementRole.OPERATOR].post( f"/api/client-management/{client_id}/{action}" ) assert response.status_code == 200, (action, response.text) # type: ignore[union-attr] # --- Catalog apply: admin-only ------------------------------------------- @pytest.mark.parametrize( "role", [ManagementRole.VIEWER, ManagementRole.AUDITOR, ManagementRole.OPERATOR] ) def test_catalog_apply_denied_below_admin( role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole ) -> None: response = role_sessions[role].post("/api/catalog/apply", json={"project": "ludarium"}) assert response.status_code == 403, (role, response.text) # type: ignore[union-attr] def test_catalog_apply_not_blocked_by_rbac_for_admin( role_sessions: dict[ManagementRole, RoleSession], ) -> None: # Whether reconciliation itself succeeds depends on the fake # ContextForge/catalog fixture state, which is out of scope for an RBAC # test -- what matters here is that admin is never turned away at 403. response = role_sessions[ManagementRole.ADMIN].post( "/api/catalog/apply", json={"project": "ludarium"} ) assert response.status_code != 403, response.text # type: ignore[union-attr] # --- Unauthenticated access ------------------------------------------------ def test_unauthenticated_request_is_401_not_403(db_path: Path) -> None: app, _ = _build_role_app(db_path, ManagementRole.VIEWER) anonymous = TestClient(app) response = anonymous.get("/api/dashboard") assert response.status_code == 401, response.text anonymous.close()