mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
Two tests in the config/misc management suite were failing every run against the Buildkite e2e stack, and one of them took the rest of the build with it. test_add_allowed_ip_does_not_store_unrelated_config_value posted 127.0.0.1 to /add/allowed_ip. That route sets the live general_settings["allowed_ips"] that auth_utils._check_valid_ip reads before it persists anything, and the check is exact string membership with no CIDR support, so from the moment the POST returns only 127.0.0.1 can reach the proxy. The runner 403s on its very next call, and the deferred /delete/allowed_ip sits behind the same auth dependency, so the cleanup is locked out too and every later test in the build 403s. Build 254's first attempt lost 459 of its 465 failures to that one cascade. There is no safe way to exercise the route against a shared proxy: nothing reports the caller's address as the proxy sees it, so a test cannot allowlist itself first. Move the claim to the route's own TestClient suite, where the auth dependency is overridden and general_settings is per-test, and record the route in the module docstring beside /cache/settings and the Vault override so it is not re-added. save_config's end of the contract was already covered by test_ProxyConfig_save_config_merges_changed_keys_without_copying_file_settings; the new test covers the route's end, that what it hands save_config differs from the loaded config in allowed_ips and nothing else. The unrelated-key probe also only ever worked on one lane: max_parallel_requests was added to tests/e2e/gateway/stage_mirror_ci_config.yml and never to the Buildkite stack's config, where resolve() reports it as "unset" rather than "config". That key is now unused, so drop it again. test_config_update_persists_router_setting_to_get wrote router_settings. num_retries, which both lanes declare in their config file, so the config- ownership work correctly refuses it with a 400. Switch to retry_after, which is declared by neither lane, is accepted by /config/update, and is reported back by GET /router/settings. Verified against a live proxy: max_fallbacks also takes the write but never reads back, so the read-back poll is what picks the key.
627 lines
23 KiB
Python
627 lines
23 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, the Vault config override and the allowed-IP routes are deliberately
|
|
not covered here. All three reconfigure the whole proxy: /cache/settings persists what
|
|
it receives into a row that outranks the YAML cache_params and is re-applied on a timer,
|
|
/config_overrides/hashicorp_vault swaps the process-wide secret manager, and
|
|
/add/allowed_ip mutates the live general_settings["allowed_ips"] that
|
|
auth_utils._check_valid_ip reads, so the first call locks every other client out of the
|
|
shared proxy. The allowlist is an exact string match with no CIDR support, and no route
|
|
reports the caller's address as the proxy sees it, so a test cannot allowlist itself
|
|
first; /delete/allowed_ip sits behind the same auth dependency, so the cleanup is locked
|
|
out too and the proxy stays poisoned for the rest of the build. None of the three can be
|
|
exercised safely against the shared proxy the suites run on, so they need an isolated
|
|
proxy before a test lands. Do not add a read-then-write-back test for any of them.
|
|
"""
|
|
|
|
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):
|
|
retry_after: int
|
|
|
|
|
|
class ConfigUpdateBody(BaseModel):
|
|
router_settings: RouterSettingsPatch
|
|
|
|
|
|
class ConfigUpdateResponse(BaseModel):
|
|
message: str
|
|
|
|
|
|
class RouterCurrentValues(BaseModel):
|
|
retry_after: 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.
|
|
|
|
retry_after is the subject because it satisfies all three constraints at once:
|
|
no lane's config file declares it, so the database owns it and the write is not
|
|
refused as config-owned; it is in RUNTIME_UPDATABLE_ROUTER_SETTINGS, so
|
|
/config/update accepts it; and it is in ROUTER_SETTINGS_FIELDS backed by an
|
|
always-set Router attribute, so GET /router/settings reports it for the
|
|
read-back. Bumping it by one second is the smallest change that proves the
|
|
round-trip without slowing a concurrent test that hits a retry."""
|
|
original = self._read_retry_after(client)
|
|
assert original is not None, "GET /router/settings did not report retry_after; cannot prove a change"
|
|
resources.defer(lambda: self._write_retry_after(client, original))
|
|
|
|
target = original + 1
|
|
response = unwrap(
|
|
client.proxy.transport.post(
|
|
"/config/update",
|
|
headers=client.proxy.transport.master,
|
|
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(retry_after=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_retry_after(client) == target else None,
|
|
f"GET /router/settings never reported retry_after {target} after /config/update",
|
|
)
|
|
|
|
self._write_retry_after(client, original)
|
|
restored = _poll(
|
|
client,
|
|
lambda: original if self._read_retry_after(client) == original else None,
|
|
f"GET /router/settings never returned to the original retry_after {original} after the restore",
|
|
)
|
|
assert restored == original, f"router retry_after left at {restored}, expected the original {original}"
|
|
|
|
@staticmethod
|
|
def _read_retry_after(client: ManagementClient) -> int | None:
|
|
return unwrap(
|
|
client.proxy.transport.get(
|
|
"/router/settings",
|
|
headers=client.proxy.transport.master,
|
|
params=NoBody(),
|
|
response_type=RouterSettingsResponse,
|
|
)
|
|
).current_values.retry_after
|
|
|
|
@staticmethod
|
|
def _write_retry_after(client: ManagementClient, value: int) -> None:
|
|
_ = unwrap(
|
|
client.proxy.transport.post(
|
|
"/config/update",
|
|
headers=client.proxy.transport.master,
|
|
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(retry_after=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,
|
|
)
|