mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
M0 e M1 revisados e fixes implementados
This commit is contained in:
@@ -37,8 +37,28 @@ ISSUER = "https://keycloak.example/realms/emcp"
|
||||
AUDIENCE = "emcp-gateway"
|
||||
KID = "test-key-1"
|
||||
|
||||
GATEWAY_PORT = 18000
|
||||
SALES_PORT = 18100
|
||||
_current_gateway_port: int | None = None
|
||||
"""Set by `governed_stack` (or a test managing its own stack, via
|
||||
`set_current_gateway_port`) for the duration of one test. A *fresh* port
|
||||
per test -- rather than a fixed constant -- avoids TIME_WAIT/bind
|
||||
contention between one test's `RunningServer.stop()` and the next test's
|
||||
bind on the same port, which showed up as intermittent "SSE stream ended
|
||||
without a response" failures once enough e2e tests accumulated to make
|
||||
that race likely within a single pytest run."""
|
||||
|
||||
|
||||
def set_current_gateway_port(port: int | None) -> None:
|
||||
global _current_gateway_port
|
||||
_current_gateway_port = port
|
||||
|
||||
|
||||
def _gateway_url() -> str:
|
||||
if _current_gateway_port is None:
|
||||
raise RuntimeError(
|
||||
"No gateway is currently running for this test (see governed_stack fixture "
|
||||
"or set_current_gateway_port)"
|
||||
)
|
||||
return f"http://127.0.0.1:{_current_gateway_port}/mcp"
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
@@ -180,19 +200,34 @@ async def governed_stack(
|
||||
) -> AsyncIterator[None]:
|
||||
"""Real OPA + real JWKS-backed OIDC auth, wired into fresh Gateway/Sales
|
||||
instances for one test (EP-01/EP-03/EP-04/EP-05 all real, only Keycloak
|
||||
itself is substituted by the JWKS server -- see RI/README.md)."""
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
|
||||
itself is substituted by the JWKS server -- see RI/README.md).
|
||||
|
||||
Deliberately Sales-only, not Sales+Finance: most tests using this fixture
|
||||
never reach a live backend call for a Finance tool (denied earlier, at
|
||||
entitlement or REQUIRE_APPROVAL), and this file's own flakiness note
|
||||
documents that more in-process ASGI server pairs per test measurably
|
||||
increases the known e2e flakiness rate. `governed_stack_with_finance`
|
||||
below is the (currently two) tests that actually need a live Finance
|
||||
backend -- keeping the blast radius of that cost limited to just those.
|
||||
"""
|
||||
gateway_port = free_port()
|
||||
sales_port = free_port()
|
||||
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||
monkeypatch.setenv("OPA_URL", opa_url)
|
||||
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
||||
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
||||
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
||||
# EP-07: the Gateway's outbound credential to the sales-domain backend --
|
||||
# deliberately a different value/shape than any inbound client token.
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||
|
||||
import example_mcp_servers.sales_domain.server as sales_module
|
||||
|
||||
sales_config = uvicorn.Config(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=SALES_PORT,
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
sales_server = RunningServer(sales_config)
|
||||
@@ -202,23 +237,91 @@ async def governed_stack(
|
||||
|
||||
auth_settings, token_verifier = gateway_module.build_auth()
|
||||
gateway_config = uvicorn.Config(
|
||||
gateway_module.server.streamable_http_app(
|
||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
||||
),
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
port=gateway_port,
|
||||
log_level="warning",
|
||||
)
|
||||
gateway_server = RunningServer(gateway_config)
|
||||
await gateway_server.start()
|
||||
set_current_gateway_port(gateway_port)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
set_current_gateway_port(None)
|
||||
await gateway_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def governed_stack_with_finance(
|
||||
monkeypatch: pytest.MonkeyPatch, opa_url: str, jwks_url: str
|
||||
) -> AsyncIterator[None]:
|
||||
"""Same as `governed_stack`, plus a real Finance domain server (EP-06-T03) --
|
||||
for the handful of tests that need a live Finance backend to actually
|
||||
reach (e.g. proving the Fabric router dispatches Finance capabilities
|
||||
there, or a full approval-then-execute cycle against it)."""
|
||||
gateway_port = free_port()
|
||||
sales_port = free_port()
|
||||
finance_port = free_port()
|
||||
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||
monkeypatch.setenv("FINANCE_DOMAIN_URL", f"http://127.0.0.1:{finance_port}/mcp")
|
||||
monkeypatch.setenv("OPA_URL", opa_url)
|
||||
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
||||
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
||||
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||
monkeypatch.setenv("FINANCE_DOMAIN_SERVICE_TOKEN", "test-finance-domain-backend-credential")
|
||||
|
||||
import example_mcp_servers.sales_domain.server as sales_module
|
||||
|
||||
sales_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await sales_server.start()
|
||||
|
||||
import example_mcp_servers.finance_domain.server as finance_module
|
||||
|
||||
finance_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
finance_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=finance_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await finance_server.start()
|
||||
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
|
||||
auth_settings, token_verifier = gateway_module.build_auth()
|
||||
gateway_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=gateway_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await gateway_server.start()
|
||||
set_current_gateway_port(gateway_port)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
set_current_gateway_port(None)
|
||||
await gateway_server.stop()
|
||||
await finance_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
async def run_against_gateway[ResultT](
|
||||
operation: Callable[[ClientSession], Awaitable[ResultT]],
|
||||
*,
|
||||
@@ -232,7 +335,7 @@ async def run_against_gateway[ResultT](
|
||||
Gateway defect: production deployments run one process per service.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
url = f"http://127.0.0.1:{GATEWAY_PORT}/mcp"
|
||||
url = _gateway_url()
|
||||
last_error: MCPError | None = None
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
|
||||
@@ -136,13 +136,13 @@ async def test_transaction_policy_denies_over_limit_even_though_entitled(
|
||||
assert "TRANSACTION_POLICY_DENIED" in _text(result)
|
||||
|
||||
|
||||
async def test_require_approval_is_denied_pending_ep14(
|
||||
async def test_require_approval_is_denied_without_a_grant(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §26: Finance.Payments allows payment.execute below the approval
|
||||
"""README.md §26: Finance.Payments allows payment.execute above the approval
|
||||
threshold (README.md §7.2/§17 profile) but must never silently upgrade a
|
||||
REQUIRE_APPROVAL to ALLOW -- and EP-14 (approval workflow) does not exist
|
||||
yet in this milestone, so the only safe outcome is DENY."""
|
||||
REQUIRE_APPROVAL to ALLOW -- the first attempt of any such call is always
|
||||
denied, since no approval has been granted for it yet (EP-14-T02)."""
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(
|
||||
@@ -154,6 +154,152 @@ async def test_require_approval_is_denied_pending_ep14(
|
||||
assert "REQUIRE_APPROVAL" in _text(result)
|
||||
|
||||
|
||||
async def test_full_approval_cycle_grant_then_execute_then_replay_denied(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-14 end to end: deny (no grant) -> out-of-band grant -> the *same*
|
||||
call now reaches the real backend -> a second attempt of the identical
|
||||
call is denied again (README.md §46, single-use "approval replay").
|
||||
|
||||
Uses Sales.Write + sales.quote.create (approval_threshold=100000,
|
||||
config/clients/profiles/sales-write.yaml); see
|
||||
`test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain`
|
||||
below for the same cycle proven against the Finance domain (EP-06-T03).
|
||||
"""
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
|
||||
token = sign_token(SALES_WRITE_CLIENT_ID)
|
||||
arguments = {"customer_id": "cust-001", "amount": 150_000, "region": "BR-SP"}
|
||||
|
||||
first_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||
)
|
||||
assert first_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(first_attempt)
|
||||
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
|
||||
|
||||
# Out-of-band: an approver grants the specific request the Gateway itself
|
||||
# created (see `on_call_tool`'s REQUIRE_APPROVAL branch) -- not a new one
|
||||
# constructed by the test, proving the Gateway and the approver are
|
||||
# looking at the same `ApprovalService` instance (`get_current_state()`).
|
||||
gateway_module.get_current_state().approval_service.grant(approval_id)
|
||||
|
||||
granted_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||
)
|
||||
assert granted_attempt.is_error is not True
|
||||
assert granted_attempt.structured_content is not None
|
||||
assert granted_attempt.structured_content["status"] == "draft"
|
||||
|
||||
replay_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||
)
|
||||
assert replay_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
|
||||
|
||||
|
||||
async def test_sales_client_tools_list_never_includes_finance_tools(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-06-T01: the Fabric router queries only the backends a client's
|
||||
entitlement actually touches -- a Sales.Read client's tools/list never
|
||||
even connects to the Finance backend."""
|
||||
token = sign_token(SALES_READ_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
tool_names = {tool.name for tool in result.tools}
|
||||
assert tool_names == {
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
}
|
||||
|
||||
|
||||
async def test_finance_client_tools_list_never_includes_sales_tools(
|
||||
governed_stack_with_finance: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-06-T01, the mirror image: a Finance.Payments client's tools/list
|
||||
routes to the Finance backend only, never Sales."""
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
tool_names = {tool.name for tool in result.tools}
|
||||
assert tool_names == {
|
||||
"finance.invoice.get",
|
||||
"finance.payment.create",
|
||||
"finance.payment.execute",
|
||||
}
|
||||
|
||||
|
||||
# EP-05-T03's SEP-2549 `ttl_ms`/`cache_scope` fields are per-version wire
|
||||
# vocabulary (`mcp_types._v2026_07_28`) that the SDK's own serialization
|
||||
# sieve strips for every earlier protocol revision (verified: neither field
|
||||
# exists in any pre-2026-07-28 per-version result model at all). This RI's
|
||||
# entire transport uses the classic session `initialize()` handshake, which
|
||||
# never negotiates 2026-07-28 (that revision is reached only via the
|
||||
# stateless per-request `server/discover` mode this RI does not use) -- so
|
||||
# there is no e2e test here asserting on `result.cache_scope`/`result.ttl_ms`:
|
||||
# whatever the Gateway sets internally, a client on this transport only ever
|
||||
# sees the field's own class default, making such an assertion pass
|
||||
# regardless of what the Gateway actually computed. `tools_list_cache_hint()`
|
||||
# itself (always `scope="private"`, configurable `ttl_ms`) is the real,
|
||||
# meaningful test -- see tests/unit/test_gateway_cache.py -- and
|
||||
# `emcp_bus.gateway.cache`'s module docstring has the full, verified
|
||||
# explanation.
|
||||
|
||||
|
||||
async def test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain(
|
||||
governed_stack_with_finance: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""EP-06-T01/T03 + EP-14 end to end against the *Finance* domain server:
|
||||
the Fabric router resolves finance.payment.create/execute to the Finance
|
||||
backend, and the same deny -> grant -> execute -> replay-denied cycle as
|
||||
`test_full_approval_cycle_grant_then_execute_then_replay_denied` (Sales)
|
||||
holds identically here."""
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
create_result = await run_against_gateway(
|
||||
lambda session: session.call_tool(
|
||||
"finance.payment.create", {"invoice_id": "inv-002", "amount": 5_000}
|
||||
),
|
||||
token=token,
|
||||
)
|
||||
assert create_result.is_error is not True
|
||||
assert create_result.structured_content is not None
|
||||
payment_id = create_result.structured_content["payment_id"]
|
||||
|
||||
execute_arguments = {"payment_id": payment_id, "amount": 50_000}
|
||||
first_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||
token=token,
|
||||
)
|
||||
assert first_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(first_attempt)
|
||||
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
|
||||
|
||||
gateway_module.get_current_state().approval_service.grant(approval_id)
|
||||
|
||||
granted_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||
token=token,
|
||||
)
|
||||
assert granted_attempt.is_error is not True
|
||||
assert granted_attempt.structured_content is not None
|
||||
assert granted_attempt.structured_content["status"] == "executed"
|
||||
|
||||
replay_attempt = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||
token=token,
|
||||
)
|
||||
assert replay_attempt.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
|
||||
|
||||
|
||||
async def test_gateway_lifespan_fails_closed_when_pdp_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -188,10 +334,19 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from tests.e2e.conftest import GATEWAY_PORT, SALES_PORT, RunningServer, run_against_gateway
|
||||
from tests.e2e.conftest import (
|
||||
RunningServer,
|
||||
free_port,
|
||||
run_against_gateway,
|
||||
set_current_gateway_port,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
|
||||
sales_port = free_port()
|
||||
gateway_port = free_port()
|
||||
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||
monkeypatch.setenv("OPA_URL", opa_url)
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||
monkeypatch.delenv("OIDC_JWKS_URL", raising=False)
|
||||
monkeypatch.delenv("OIDC_ISSUER", raising=False)
|
||||
|
||||
@@ -201,7 +356,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
uvicorn.Config(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=SALES_PORT,
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
@@ -214,15 +369,14 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
|
||||
gateway_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
gateway_module.server.streamable_http_app(
|
||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
||||
),
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
port=gateway_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await gateway_server.start()
|
||||
set_current_gateway_port(gateway_port)
|
||||
try:
|
||||
list_result = await run_against_gateway(lambda session: session.list_tools())
|
||||
assert list_result.tools == []
|
||||
@@ -233,6 +387,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
assert call_result.is_error is True
|
||||
assert "UNAUTHENTICATED" in _text(call_result)
|
||||
finally:
|
||||
set_current_gateway_port(None)
|
||||
await gateway_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
249
RI/tests/e2e/test_real_keycloak.py
Normal file
249
RI/tests/e2e/test_real_keycloak.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""EP-01-T01: the *actual* Keycloak, not the JWKS-server stand-in the rest of
|
||||
the e2e suite uses (see tests/e2e/conftest.py's module docstring). Real
|
||||
Keycloak 26.7.3 container (Docker required -- skips otherwise), our real
|
||||
`deploy/keycloak/realm-export.json`, real `client_credentials` grants, real
|
||||
tokens validated by the real Gateway/PDP/Entitlement chain.
|
||||
|
||||
Session-scoped: Keycloak's cold start (schema init + realm import) takes
|
||||
~15-20s, too slow to pay per-test. Gated on `docker` being on PATH, exactly
|
||||
like `opa_url`/`OPA_BINARY` in conftest.py -- this whole file is skipped, not
|
||||
failed, in an environment without Docker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from emcp_bus.identity.authn import AuthenticationError, TokenValidator
|
||||
from tests.e2e.conftest import RunningServer, free_port, set_current_gateway_port
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
REALM_EXPORT = REPO_ROOT / "deploy" / "keycloak" / "realm-export.json"
|
||||
|
||||
REALM_NAME = "emcp"
|
||||
CLIENT_SECRETS = {
|
||||
"sales-read-agent": "dev-sales-read-agent-secret",
|
||||
"sales-write-agent": "dev-sales-write-agent-secret",
|
||||
"finance-payments-agent": "dev-finance-payments-agent-secret",
|
||||
}
|
||||
|
||||
|
||||
def _docker_binary() -> str | None:
|
||||
return shutil.which("docker")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def keycloak_url() -> Iterator[str]:
|
||||
docker_binary = _docker_binary()
|
||||
if docker_binary is None:
|
||||
pytest.skip("docker not found on PATH -- required for the real-Keycloak suite")
|
||||
|
||||
port = free_port()
|
||||
container_name = f"emcp-ri-test-keycloak-{port}"
|
||||
# `KC_HOSTNAME` fixed to `127.0.0.1:<port>` so every issued token's `iss`
|
||||
# matches what *this test* (running on the host) uses to reach it --
|
||||
# unlike docker-compose.yml's `keycloak` service, which fixes it to the
|
||||
# internal `keycloak:8080` DNS name instead, since there the Gateway
|
||||
# reaches it over the compose network, not localhost. Same underlying
|
||||
# gotcha either way: Keycloak derives `iss` from the request's Host
|
||||
# header unless pinned, so a token fetched via one path and validated
|
||||
# via another fails issuer validation for a boring reason.
|
||||
subprocess.run(
|
||||
[
|
||||
docker_binary,
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
container_name,
|
||||
"-p",
|
||||
f"{port}:8080",
|
||||
"-e",
|
||||
"KC_BOOTSTRAP_ADMIN_USERNAME=admin",
|
||||
"-e",
|
||||
"KC_BOOTSTRAP_ADMIN_PASSWORD=admin",
|
||||
"-e",
|
||||
# Full URL form, not a bare "host:port" -- Keycloak 26's
|
||||
# `KC_HOSTNAME` only accepts a plain hostname or a complete URL
|
||||
# with scheme ("Provided hostname is neither a plain hostname
|
||||
# nor a valid URL" otherwise); a plain hostname alone can't carry
|
||||
# this test's dynamically allocated port.
|
||||
f"KC_HOSTNAME=http://127.0.0.1:{port}",
|
||||
"-e",
|
||||
"KC_HOSTNAME_STRICT=false",
|
||||
"-v",
|
||||
f"{REALM_EXPORT}:/opt/keycloak/data/import/realm-export.json:ro",
|
||||
"quay.io/keycloak/keycloak:26.7.3",
|
||||
"start-dev",
|
||||
"--import-realm",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
for _ in range(120):
|
||||
try:
|
||||
response = httpx.get(f"{base_url}/realms/{REALM_NAME}", timeout=1)
|
||||
if response.status_code == 200:
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise RuntimeError("Keycloak did not become ready in time")
|
||||
yield base_url
|
||||
finally:
|
||||
subprocess.run([docker_binary, "rm", "-f", container_name], capture_output=True)
|
||||
|
||||
|
||||
def _fetch_token(keycloak_url: str, client_id: str) -> str:
|
||||
response = httpx.post(
|
||||
f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": CLIENT_SECRETS[client_id],
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token: str = response.json()["access_token"]
|
||||
return token
|
||||
|
||||
|
||||
# --- TokenValidator against real Keycloak (no Gateway involved) -----------
|
||||
|
||||
|
||||
def test_real_keycloak_token_validates_with_the_real_azp_claim(keycloak_url: str) -> None:
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
validator = TokenValidator(
|
||||
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
|
||||
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
|
||||
audience="emcp-gateway",
|
||||
)
|
||||
|
||||
authenticated = validator.validate(token)
|
||||
|
||||
assert authenticated.client_id == "sales-read-agent"
|
||||
|
||||
|
||||
def test_real_keycloak_token_rejected_for_wrong_audience(keycloak_url: str) -> None:
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
validator = TokenValidator(
|
||||
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
|
||||
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
|
||||
audience="some-other-resource-server",
|
||||
)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
validator.validate(token)
|
||||
|
||||
|
||||
# --- Full Gateway chain against real Keycloak ------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def governed_stack_with_real_keycloak(
|
||||
monkeypatch: pytest.MonkeyPatch, opa_url: str, keycloak_url: str
|
||||
) -> AsyncIterator[None]:
|
||||
"""Same shape as conftest.py's `governed_stack`, except OIDC points at
|
||||
the real, dockerized Keycloak from `keycloak_url` instead of the JWKS
|
||||
stand-in the rest of the suite uses."""
|
||||
import uvicorn
|
||||
|
||||
gateway_port = free_port()
|
||||
sales_port = free_port()
|
||||
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||
monkeypatch.setenv("OPA_URL", opa_url)
|
||||
monkeypatch.setenv(
|
||||
"OIDC_JWKS_URL", f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs"
|
||||
)
|
||||
monkeypatch.setenv("OIDC_ISSUER", f"{keycloak_url}/realms/{REALM_NAME}")
|
||||
monkeypatch.setenv("OIDC_AUDIENCE", "emcp-gateway")
|
||||
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||
|
||||
import example_mcp_servers.sales_domain.server as sales_module
|
||||
|
||||
sales_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await sales_server.start()
|
||||
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
|
||||
auth_settings, token_verifier = gateway_module.build_auth()
|
||||
gateway_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=gateway_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await gateway_server.start()
|
||||
set_current_gateway_port(gateway_port)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
set_current_gateway_port(None)
|
||||
await gateway_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
async def test_full_chain_with_real_keycloak_token(
|
||||
governed_stack_with_real_keycloak: None, keycloak_url: str
|
||||
) -> None:
|
||||
"""README.md §45 Teste 1, against a real IdP end to end: the Gateway
|
||||
validates a token *actually issued by Keycloak*, resolves the real
|
||||
Sales.Read entitlement, and reaches the real downstream Sales server."""
|
||||
from tests.e2e.conftest import run_against_gateway
|
||||
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
|
||||
tools = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
assert {t.name for t in tools.tools} == {
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
}
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"}),
|
||||
token=token,
|
||||
)
|
||||
assert result.is_error is not True
|
||||
assert result.structured_content == {
|
||||
"customer_id": "cust-001",
|
||||
"name": "Aurora Ltda",
|
||||
"region": "BR-SP",
|
||||
}
|
||||
|
||||
|
||||
async def test_cross_domain_denial_with_real_keycloak_token(
|
||||
governed_stack_with_real_keycloak: None, keycloak_url: str
|
||||
) -> None:
|
||||
"""A real Sales.Read token from real Keycloak still cannot reach Finance."""
|
||||
from tests.e2e.conftest import run_against_gateway
|
||||
|
||||
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", {"amount": 999}),
|
||||
token=token,
|
||||
)
|
||||
assert result.is_error is True
|
||||
Reference in New Issue
Block a user