litellm/tests/e2e/management/test_config_misc_endpoints_e2e.py
mubashir1osmani 64fc19d61a
fix(e2e): stop tests from breaking the shared proxy for every suite after them (#34664)
* fix(e2e): stop the cache-settings test from persisting a degraded Redis config

TestCacheSettings.test_update_persists_cache_backend_to_get read the live cache
settings and wrote them back, intending a no-op. Its capture modelled only
type/host/port, so on a TLS cluster the write-back silently dropped `ssl` and
`redis_startup_nodes`.

That is not recoverable on its own. `/cache/settings` persists what it receives
into LiteLLM_CacheConfig, that row outranks the YAML `cache_params`, and
init_cache_settings_in_db re-applies it on a timer, so a restart does not clear
it. The proxy ends up driving a TLS-only cluster endpoint as a plaintext
standalone node and every Redis call blocks to socket timeout.

On the affected deployment that took out rate limiting entirely (the v3 limiter
is a Lua script on Redis with no DB fallback), Redis-only budget levels (tag,
per-model, team-member, per-window), spend tracking, `ResetBudgetJob` (which
self-starved at 54 skipped runs per 15 min), and `ProxyConfig.add_deployment`,
whose last statement syncs guardrails and never ran. 60 of 72 failures in one
run traced back here.

The settings blob is now round-tripped verbatim via a RootModel over an
exhaustive value union, so a subset cannot be written. Two guards make a
regression fail loudly at this test instead of silently downstream:

- refuse to write when GET reports redis_type=cluster but omits
  redis_startup_nodes, which is the exact precondition for persisting a
  downgrade. GET resolves the stored row overlaid with REDIS_* env and never
  reads YAML, so a cluster configured only in YAML cannot round-trip here
- compare /cache/ping before and after, so a write that breaks connectivity
  fails this test rather than every suite that follows

The underlying product defect is filed as LIT-4816: GET cannot express the
effective config, and a partial POST is allowed to downgrade transport. This
change only stops the suite from triggering it; the Admin UI can still do so.

basedpyright clean (0 errors) under the e2e gate.

* fix(e2e): scope the bedrock guardrail per request and send OpenAI's current token param

Two failures that had nothing to do with the guardrail or route under test.

create_bedrock_guardrail registered with default_on=True, which applies the
guardrail to every request the proxy serves. The upstream ApplyGuardrail call was
answering 403, and that came back to unrelated traffic as
`403 Bedrock guardrail request failed`, failing three a2a tests and a passthrough
headers test alongside the bedrock one. The harness already supports the
per-request `guardrails` selector, so the guardrail is now registered opted out of
default_on and selected by the test that wants it. A broken upstream guardrail
fails its own test instead of whatever else is running.

Note this only contains the blast radius; the 403 itself still needs the
bedrock:ApplyGuardrail permission (or a valid guardrail identifier) on the
deployment, so test_bedrock_pre_call_blocks_harmful_prompt can still fail on its
own until that is sorted.

The OpenAI passthrough body sent `max_tokens`, which newer models reject with
"Unsupported parameter: 'max_tokens' is not supported with this model. Use
'max_completion_tokens' instead." Passthrough forwards the body untranslated, so
drop_params does not apply and the body has to satisfy OpenAI's contract
directly. vllm_chat keeps max_tokens, which vLLM accepts.

basedpyright clean (0 errors) under the e2e gate.

* fix(e2e): drop the pinned a2a api_key that broke every message/send

#34512 pinned `api_key="os.environ/ANTHROPIC_API_KEY"` on the a2a bridge agent.
The a2a bridge forwards the agent's litellm_params straight into
litellm.acompletion() without expanding "os.environ/" indirection, so that literal
string was sent upstream as x-api-key and every message/send failed with
`AnthropicException - {"type":"authentication_error","message":"invalid x-api-key"}`.

Omitting api_key restores the normal provider resolution: litellm reads
ANTHROPIC_API_KEY from the proxy's own environment for this provider, which is what
the agent-owner flow depends on and what the suite did before #34512.

Verified against a live proxy, same agent shape each time:

  api_key omitted                        -> message/send 200
  api_key "os.environ/ANTHROPIC_API_KEY" -> message/send 500 invalid x-api-key
  api_key <literal key>                  -> message/send 200

and the key itself is valid (direct call to api.anthropic.com returns 200), so this
was indirection that never got expanded rather than a bad credential.

This accounts for four failures (test_semver_protocol_version_registers_and_serves,
test_message_send_runs_completion_bridge, test_pinned_v0_3_serves_flat_message_shape,
test_pinned_v1_0_serves_nested_message_shape). They were previously reported as
`403 Bedrock guardrail request failed`, because a default_on Bedrock guardrail
short-circuited the request before it ever reached the bridge and hid this.

The bridge silently ignoring "os.environ/" in agent params is a product defect in
its own right, filed separately; anyone configuring an agent credential that way
through the UI hits the same wall.

basedpyright clean (0 errors) under the e2e gate.

* test(e2e): make the load suite less aggressive against a shared proxy

750 users at spawn rate 50 saturated the request path hard enough to distort the
latency-sensitive suites sharing the same proxy, and it spends real provider money
at that rate. Drop to 200 users at spawn rate 20.

The RPS floor moves with the user count rather than staying put, so the assertion
keeps its meaning instead of becoming a formality: 355 RPS over 750 users is
~0.47 RPS/user, and 90 over 200 holds that same per-user expectation with a
similar pass margin. A request-path regression still trips it.

All four knobs stay env-overridable (E2E_LOAD_USERS, E2E_LOAD_SPAWN_RATE,
E2E_LOAD_DURATION_SECONDS, E2E_LOAD_MIN_RPS) for a deliberate load run.

Note the recorded failure for this test was "no requests completed in 60s", which
was the gateway wedged on unreachable Redis rather than a throughput regression;
this change is about not perturbing its neighbours, not about that failure.

* fix(e2e): make the reasoning-tokens assertion exercise a request that reasons

test_openai_chat_reasoning_reports_reasoning_tokens asked "A train travels 60 miles
in 1.5 hours. What is its average speed in mph?" at reasoning_effort="low", then
asserted reasoning_tokens > 0. The model answers that directly without reasoning, so
0 is correct behavior and the assertion was testing the model's discretion rather
than litellm's reporting.

Verified against a live proxy on a dedicated openai/gpt-5.6 deployment, matching how
the test provisions its model:

  reasoning_effort=low,  one-step arithmetic   -> reasoning_tokens=0
  reasoning_effort=high, the prompt used here  -> reasoning_tokens=114

Raised to high effort with a prompt that requires a proof plus a search, so the
field under test is actually populated and the assertion fails only if litellm stops
surfacing it.

While confirming this I also checked prompt caching, which needed no change:
cached_tokens comes back 3615 of 3618 prompt tokens on a repeated large prefix
against a dedicated deployment. An earlier reading of 0 was an artifact of probing a
fan-out alias whose requests land on different deployments, not a caching defect.

* test(e2e): skip the files-list test while LIT-4820 is open

GET /v1/files does not include a just-uploaded file. The upload returns 200 and
GET /v1/files/{id} resolves it, but the listing never contains it: the returned set
stays fixed at 27 entries whose newest created_at is roughly ten hours older than
the upload, on both the managed (/v1/files?model=) and provider-scoped
(/openai/v1/files) routes. Polled for 40s, so not an eventual-consistency window.

Filed as LIT-4820. Skipping keeps a known, ticketed product bug from holding the
suite red and masking a new regression somewhere else in the same test.

The assertion is left exactly as it was on purpose. It encodes the contract we
actually want, that a file retrievable by id is also enumerable, and anything that
lists files (a UI picker, cleanup tooling that lists then deletes and would
therefore leak provider-side files) depends on it. Relaxing it to get green would
delete the signal. The skip reason says so and links the ticket, and the ticket
records that removing this marker is part of its definition of done.

Matches the existing pattern in this file, where test_unified_file_and_batch_create
skips with a reason citing LIT-3266.

While skipped, the registry cell llm.files.openai.list.nonstream.works has no
passing covering test, so files-list coverage reports as uncovered rather than
passing, which is the honest state.

* fix(e2e): parse Sentinel node lists in the cache-settings model

The value union covered scalar lists and lists of mappings, but not lists of
lists. `redis_startup_nodes` holds host/port mappings while `sentinel_nodes` holds
positional pairs (CACHE_SETTINGS_FIELDS documents `[['localhost', 26379]]`), so on
a Sentinel deployment pydantic rejected the response:

  sentinel_nodes.list[dict[str,...]].1
  Input should be a valid dictionary [input_value=['localhost', 26380]]

The round-trip test reads GET /cache/settings before it writes anything, so that
rejection failed the test at the read, before any assertion ran. A Sentinel
deployment would have looked like a broken cache-settings route rather than a
model too narrow to parse a documented shape.

A list element may now be a scalar, a list or a mapping, which covers both node
shapes without special-casing either and tolerates a heterogeneous list instead of
rejecting the whole response.

Adds TestCacheSettingsModel, harness-level with no `e2e` marker so it runs without
a proxy, covering all four backend shapes (cluster mappings, sentinel pairs, plain
node, url mode with a null discrete field) plus transport() key selection.
Confirmed it fails on the previous union and passes on this one:

  old union -> 1 failed, 4 passed (the sentinel case)
  new union -> 5 passed

* test(e2e): remove the cache-settings round-trip test

The test could not fail for the thing it claimed to test, and could break the
deployment it ran against. Both halves of that are worth stating.

It read the live settings, wrote back identical values, and asserted the read-back
matched. If POST /cache/settings were a complete no-op that returned 200 and touched
nothing, GET would still return the values read a moment earlier and the test would
pass. It verified that GET is stable, not that the route persists anything.

Against that, /cache/settings persists what it receives into LiteLLM_CacheConfig,
that row outranks YAML cache_params, and init_cache_settings_in_db re-applies it on a
timer. A write that omits ssl or redis_startup_nodes converts a TLS cluster into a
plaintext standalone client and every later Redis call blocks to socket timeout. On
2026-07-25 that failed 60 of 72 tests in one run: rate limiting stopped enforcing,
Redis-only budgets admitted billable over-budget spend, ResetBudgetJob self-starved,
and guardrail sync never ran.

Guarding the previous shape was not sufficient. Writing the blob verbatim plus a
cluster precondition and a /cache/ping check narrowed the hazard but did not remove
it, because GET cannot express the effective config: it resolves the stored row
overlaid with REDIS_* env and never reads YAML. On a fresh deploy it cannot see
YAML's ssl to echo back, so a TLS non-cluster deployment could still have a row
written that drops it. No round-trip through this route is safe on a shared proxy.

Removed with the models and helpers it owned, and TestCacheSettingsModel with them
since it existed only to protect that parsing.

The registry row mgmt.cache_settings.update.happy_path stays, now carrying the
rationale for why it is deliberately uncovered and what a safe test would require
(an isolated proxy, or LIT-4816 fixed so a partial write cannot downgrade
transport). Coverage therefore reports this cell as a gap, which is the honest
state. Collector passes --strict; the module still collects 11 tests.
2026-07-25 23:12:55 +00:00

610 lines
22 KiB
Python

"""Live e2e: the config and miscellaneous Management/UI routes.
One method per registry cell, each asserting the real contract against a live
proxy: read-only inventory routes return their documented shape, stateless
validators compute their verdict from the request, and the write routes persist
so a read-back reflects the change. Router settings, which mutate global proxy
state, are exercised with a benign, self-restoring change so a shared proxy is left
as it was found.
Cache settings are deliberately not covered here; see the rationale on
mgmt.cache_settings.update.happy_path in coverage_registry/mgmt.yaml before adding
a test for that route.
"""
from __future__ import annotations
import math
import time
from collections.abc import Callable
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import NoBody, Success, unwrap, unwrap_status
from lifecycle import ResourceManager
from management_client import ManagementClient
from models import KeyGenerateBody, LiteLLMParamsBody, TeamNewBody
pytestmark = pytest.mark.e2e
def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T:
deadline = time.monotonic() + client.proxy.poll_timeout
while time.monotonic() < deadline:
found = attempt()
if found is not None:
return found
time.sleep(client.proxy.poll_interval)
pytest.fail(failure)
# ---- callbacks -------------------------------------------------------------
class CallbacksListResponse(BaseModel):
success: list[str]
failure: list[str]
success_and_failure: list[str]
# ---- cost estimate ---------------------------------------------------------
class CostEstimateBody(BaseModel):
model: str
input_tokens: int
output_tokens: int
num_requests_per_day: int | None = None
class CostEstimateResponse(BaseModel):
model: str
input_tokens: int
output_tokens: int
cost_per_request: float
input_cost_per_request: float
output_cost_per_request: float
margin_cost_per_request: float
daily_cost: float | None = None
provider: str | None = None
# ---- credential migration check --------------------------------------------
class MigrationReport(BaseModel):
residual_legacy: int
total_undecryptable: int
class MigrationCheckResponse(BaseModel):
status: str
report: MigrationReport
# ---- tool + workflow inventories -------------------------------------------
class ToolListEntry(BaseModel):
name: str | None = None
class ToolListResponse(BaseModel):
tools: list[ToolListEntry]
total: int
class WorkflowRunEntry(BaseModel):
workflow_id: str | None = None
class WorkflowRunsResponse(BaseModel):
runs: list[WorkflowRunEntry]
count: int
# ---- compliance ------------------------------------------------------------
class ComplianceGdprBody(BaseModel):
request_id: str
user_id: str
model: str
timestamp: str
class ComplianceCheck(BaseModel):
check_name: str
article: str
passed: bool
detail: str
class ComplianceResponse(BaseModel):
compliant: bool
regulation: str
checks: list[ComplianceCheck]
# ---- fallback management ---------------------------------------------------
class FallbackShape(BaseModel):
model: str
fallback_models: list[str]
fallback_type: str
class FallbackCreateBody(FallbackShape):
pass
class FallbackResponse(FallbackShape):
message: str
class FallbackGetParams(BaseModel):
fallback_type: str
class FallbackGetResponse(FallbackShape):
pass
# ---- jwt key mapping -------------------------------------------------------
class JwtKeyMappingNewBody(BaseModel):
jwt_claim_name: str
jwt_claim_value: str
key: str
description: str
class JwtInfoParams(BaseModel):
id: str
class JwtDeleteBody(BaseModel):
id: str
class JwtKeyMappingResponse(BaseModel):
id: str
jwt_claim_name: str
jwt_claim_value: str
is_active: bool
description: str | None = None
# ---- router settings via /config/update ------------------------------------
class RouterSettingsPatch(BaseModel):
num_retries: int
class ConfigUpdateBody(BaseModel):
router_settings: RouterSettingsPatch
class ConfigUpdateResponse(BaseModel):
message: str
class RouterCurrentValues(BaseModel):
num_retries: int | None = None
class RouterSettingsResponse(BaseModel):
current_values: RouterCurrentValues
# ---- mcp server submission -------------------------------------------------
class McpRegisterBody(BaseModel):
server_name: str
url: str
transport: str
description: str
class McpServerResponse(BaseModel):
server_id: str
server_name: str | None = None
approval_status: str
transport: str
url: str | None = None
class TestInventoryRoutes:
@pytest.mark.covers("mgmt.callback.list.happy_path")
def test_callbacks_list_reports_active_logging_callbacks(self, client: ManagementClient) -> None:
listing = unwrap(
client.proxy.transport.get(
"/callbacks/list",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=CallbacksListResponse,
)
)
every = [*listing.success, *listing.failure, *listing.success_and_failure]
assert every, "/callbacks/list reported no active logging callbacks; the proxy always runs the db logger"
assert "_ProxyDBLogger" in every, (
f"/callbacks/list omitted the always-on _ProxyDBLogger spend logger; got {every}"
)
@pytest.mark.covers("mgmt.tool_management.list.happy_path")
def test_tool_list_returns_catalog_with_consistent_total(self, client: ManagementClient) -> None:
listing = unwrap(
client.proxy.transport.get(
"/v1/tool/list",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=ToolListResponse,
)
)
assert listing.total == len(listing.tools), (
f"/v1/tool/list total {listing.total} disagrees with the {len(listing.tools)} tools returned"
)
@pytest.mark.covers("mgmt.workflow.list.happy_path")
def test_workflow_runs_list_returns_consistent_count(self, client: ManagementClient) -> None:
listing = unwrap(
client.proxy.transport.get(
"/v1/workflows/runs",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=WorkflowRunsResponse,
)
)
assert listing.count == len(listing.runs), (
f"/v1/workflows/runs count {listing.count} disagrees with the {len(listing.runs)} runs returned"
)
@pytest.mark.covers("mgmt.credential_migration.check.happy_path")
def test_credential_migration_check_reports_residual_scan(self, client: ManagementClient) -> None:
report = unwrap(
client.proxy.transport.get(
"/credentials/migrate-encryption/check",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=MigrationCheckResponse,
)
)
assert report.status == "success", f"migrate-encryption/check status {report.status!r}, expected 'success'"
assert report.report.residual_legacy >= 0, (
f"residual_legacy count is negative ({report.report.residual_legacy}); the scan is broken"
)
assert report.report.total_undecryptable >= 0, (
f"total_undecryptable count is negative ({report.report.total_undecryptable}); the scan is broken"
)
class TestCostEstimate:
@pytest.mark.covers("mgmt.cost_tracking.estimate.happy_path")
def test_estimate_computes_cost_from_token_counts(self, client: ManagementClient) -> None:
estimate = unwrap(
client.proxy.transport.post(
"/cost/estimate",
headers=client.proxy.transport.master,
json=CostEstimateBody(
model="gpt-4o-mini", input_tokens=1000, output_tokens=500, num_requests_per_day=100
),
response_type=CostEstimateResponse,
)
)
assert estimate.input_cost_per_request > 0, (
f"input cost per request is {estimate.input_cost_per_request}; a priced model must cost more than zero"
)
assert estimate.output_cost_per_request > 0, (
f"output cost per request is {estimate.output_cost_per_request}; a priced model must cost more than zero"
)
expected_per_request = (
estimate.input_cost_per_request + estimate.output_cost_per_request + estimate.margin_cost_per_request
)
assert math.isclose(estimate.cost_per_request, expected_per_request, rel_tol=1e-9), (
f"cost_per_request {estimate.cost_per_request} != input+output+margin {expected_per_request}"
)
assert estimate.daily_cost is not None and math.isclose(
estimate.daily_cost, estimate.cost_per_request * 100, rel_tol=1e-9
), f"daily_cost {estimate.daily_cost} != cost_per_request * 100 requests {estimate.cost_per_request * 100}"
class TestComplianceRoutes:
@pytest.mark.covers("mgmt.compliance.gdpr.happy_path")
def test_gdpr_check_derives_verdict_from_the_request(self, client: ManagementClient) -> None:
result = unwrap(
client.proxy.transport.post(
"/compliance/gdpr",
headers=client.proxy.transport.master,
json=ComplianceGdprBody(
request_id=f"e2e-gdpr-{unique_marker()}",
user_id=f"e2e-user-{unique_marker()}",
model="gpt-4o-mini",
timestamp="2026-07-21T00:00:00Z",
),
response_type=ComplianceResponse,
)
)
assert result.regulation == "GDPR", (
f"/compliance/gdpr reported regulation {result.regulation!r}, expected 'GDPR'"
)
articles = {check.article for check in result.checks}
assert articles == {"Art. 32", "Art. 5(1)(c)", "Art. 30"}, (
f"/compliance/gdpr returned articles {articles}, expected the three GDPR articles"
)
assert result.compliant == all(check.passed for check in result.checks), (
"the overall compliant verdict must be the conjunction of the individual checks"
)
assert all(check.check_name and check.detail for check in result.checks), (
"every compliance check must carry a name and a human-readable detail"
)
class TestFallbackManagement:
@pytest.mark.covers("mgmt.fallback_management.update.happy_path")
def test_create_persists_and_is_read_back(self, client: ManagementClient, resources: ResourceManager) -> None:
primary = f"e2e-fallback-primary-{unique_marker()}"
secondary = f"e2e-fallback-secondary-{unique_marker()}"
params = LiteLLMParamsBody(model="openai/gpt-5.5", api_key="e2e-dummy-key")
primary_id = client.proxy.create_model(primary, params)
resources.defer(lambda: client.proxy.delete_model(primary_id))
secondary_id = client.proxy.create_model(secondary, params)
resources.defer(lambda: client.proxy.delete_model(secondary_id))
resources.defer(lambda: self._delete_fallback(client, primary))
created = unwrap(
client.proxy.transport.post(
"/fallback",
headers=client.proxy.transport.master,
json=FallbackCreateBody(model=primary, fallback_models=[secondary], fallback_type="general"),
response_type=FallbackResponse,
)
)
assert created.model == primary and created.fallback_models == [secondary], (
f"/fallback echoed model={created.model!r} fallbacks={created.fallback_models}, "
f"configured {primary!r} -> [{secondary!r}]"
)
def read_back() -> FallbackGetResponse | None:
result = client.proxy.transport.get(
f"/fallback/{primary}",
headers=client.proxy.transport.master,
params=FallbackGetParams(fallback_type="general"),
response_type=FallbackGetResponse,
)
match result:
case Success(data=data) if secondary in data.fallback_models:
return data
case _:
return None
got = _poll(client, read_back, f"GET /fallback/{primary} never reported {secondary} after /fallback")
assert got.fallback_models == [secondary], (
f"GET /fallback/{primary} reports fallbacks {got.fallback_models}, configured [{secondary!r}]"
)
@staticmethod
def _delete_fallback(client: ManagementClient, model: str) -> None:
_ = client.proxy.transport.delete(
f"/fallback/{model}",
headers=client.proxy.transport.master,
json=NoBody(),
params=FallbackGetParams(fallback_type="general"),
response_type=NoBody,
)
class TestJwtKeyMapping:
@pytest.mark.covers("mgmt.jwt_key_mapping.new.happy_path")
def test_new_persists_mapping_and_is_read_back(
self, client: ManagementClient, resources: ResourceManager
) -> None:
key = client.proxy.generate_key(KeyGenerateBody())
resources.defer(lambda: client.proxy.delete_key(key))
claim_value = f"e2e_jwt_{unique_marker()}"
created = unwrap(
client.proxy.transport.post(
"/jwt/key/mapping/new",
headers=client.proxy.transport.master,
json=JwtKeyMappingNewBody(
jwt_claim_name="team_id",
jwt_claim_value=claim_value,
key=key,
description="e2e coverage mapping",
),
response_type=JwtKeyMappingResponse,
)
)
resources.defer(lambda: self._delete_mapping(client, created.id))
assert created.jwt_claim_value == claim_value and created.is_active, (
f"/jwt/key/mapping/new returned claim_value={created.jwt_claim_value!r} active={created.is_active}, "
f"configured {claim_value!r} active=True"
)
info = unwrap(
client.proxy.transport.get(
"/jwt/key/mapping/info",
headers=client.proxy.transport.master,
params=JwtInfoParams(id=created.id),
response_type=JwtKeyMappingResponse,
)
)
assert info.id == created.id and info.jwt_claim_name == "team_id" and info.jwt_claim_value == claim_value, (
f"/jwt/key/mapping/info reports {info.jwt_claim_name!r}={info.jwt_claim_value!r} for id {info.id}, "
f"created team_id={claim_value!r}"
)
@staticmethod
def _delete_mapping(client: ManagementClient, mapping_id: str) -> None:
_ = client.proxy.transport.post(
"/jwt/key/mapping/delete",
headers=client.proxy.transport.master,
json=JwtDeleteBody(id=mapping_id),
response_type=NoBody,
)
class TestRouterSettings:
@pytest.mark.covers("mgmt.router_settings.update.happy_path")
def test_config_update_persists_router_setting_to_get(
self, client: ManagementClient, resources: ResourceManager
) -> None:
"""/config/update is the only write path for router_settings (there is no
dedicated router-settings write route). The change is restored on teardown so
the shared proxy keeps its original retry policy."""
original = self._read_num_retries(client)
assert original is not None, "GET /router/settings did not report num_retries; cannot prove a change"
resources.defer(lambda: self._write_num_retries(client, original))
target = original + 5
response = unwrap(
client.proxy.transport.post(
"/config/update",
headers=client.proxy.transport.master,
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=target)),
response_type=ConfigUpdateResponse,
)
)
assert "success" in response.message.lower(), (
f"/config/update reported {response.message!r}, expected a success message"
)
_ = _poll(
client,
lambda: True if self._read_num_retries(client) == target else None,
f"GET /router/settings never reported num_retries {target} after /config/update",
)
self._write_num_retries(client, original)
restored = _poll(
client,
lambda: original if self._read_num_retries(client) == original else None,
f"GET /router/settings never returned to the original num_retries {original} after the restore",
)
assert restored == original, f"router num_retries left at {restored}, expected the original {original}"
@staticmethod
def _read_num_retries(client: ManagementClient) -> int | None:
return unwrap(
client.proxy.transport.get(
"/router/settings",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=RouterSettingsResponse,
)
).current_values.num_retries
@staticmethod
def _write_num_retries(client: ManagementClient, value: int) -> None:
_ = unwrap(
client.proxy.transport.post(
"/config/update",
headers=client.proxy.transport.master,
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=value)),
response_type=ConfigUpdateResponse,
)
)
class TestMcpServerSubmission:
@pytest.mark.covers("mgmt.mcp_server.register.happy_path")
def test_register_submits_pending_server(self, client: ManagementClient, resources: ResourceManager) -> None:
"""A non-admin, team-scoped key submits an MCP server for review; the proxy
stores it as pending_review without loading it into the runtime registry."""
team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}"))
resources.defer(lambda: client.delete_team(team_id))
team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id))
resources.defer(lambda: client.proxy.delete_key(team_key))
server_name = f"e2e_mcp_{unique_marker()}"
submitted = unwrap_status(
client.proxy.transport.post(
"/v1/mcp/server/register",
headers=client.proxy.transport.bearer(team_key),
json=McpRegisterBody(
server_name=server_name,
url="https://example.com/mcp",
transport="sse",
description="e2e coverage submission",
),
response_type=McpServerResponse,
),
201,
)
resources.defer(lambda: self._delete_server(client, submitted.server_id))
assert submitted.approval_status == "pending_review", (
f"a user submission must be pending_review, got {submitted.approval_status!r}"
)
assert submitted.server_name == server_name and submitted.transport == "sse", (
f"/v1/mcp/server/register echoed name={submitted.server_name!r} transport={submitted.transport!r}, "
f"configured {server_name!r}/sse"
)
@pytest.mark.covers("mgmt.mcp_server.approve.persists")
def test_approve_activates_submission_and_persists(
self, client: ManagementClient, resources: ResourceManager
) -> None:
"""An admin approving a pending submission flips it to active, and the change
persists to a fresh read of the server."""
team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}"))
resources.defer(lambda: client.delete_team(team_id))
team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id))
resources.defer(lambda: client.proxy.delete_key(team_key))
submitted = unwrap(
client.proxy.transport.post(
"/v1/mcp/server/register",
headers=client.proxy.transport.bearer(team_key),
json=McpRegisterBody(
server_name=f"e2e_mcp_{unique_marker()}",
url="https://example.com/mcp",
transport="sse",
description="e2e coverage submission",
),
response_type=McpServerResponse,
)
)
resources.defer(lambda: self._delete_server(client, submitted.server_id))
assert submitted.approval_status == "pending_review", (
f"a fresh submission must be pending_review before approval, got {submitted.approval_status!r}"
)
approved = unwrap(
client.proxy.transport.put(
f"/v1/mcp/server/{submitted.server_id}/approve",
headers=client.proxy.transport.master,
json=NoBody(),
response_type=McpServerResponse,
)
)
assert approved.approval_status == "active", (
f"approve must flip the submission to active, got {approved.approval_status!r}"
)
fetched = unwrap(
client.proxy.transport.get(
f"/v1/mcp/server/{submitted.server_id}",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=McpServerResponse,
)
)
assert fetched.server_id == submitted.server_id and fetched.approval_status == "active", (
f"GET /v1/mcp/server/{submitted.server_id} reports approval_status {fetched.approval_status!r} "
"after approve, expected 'active'"
)
@staticmethod
def _delete_server(client: ManagementClient, server_id: str) -> None:
_ = client.proxy.transport.delete(
f"/v1/mcp/server/{server_id}",
headers=client.proxy.transport.master,
json=NoBody(),
response_type=NoBody,
)