"""Tests for MCP config secret masking and preservation. Verifies that GET /api/mcp/config masks sensitive fields (env values, header values, OAuth secrets) and that PUT /api/mcp/config correctly preserves existing secrets when the frontend round-trips masked values. """ from __future__ import annotations import pytest from app.gateway.routers.mcp import ( McpOAuthConfigResponse, McpServerConfigResponse, _mask_server_config, _merge_preserving_secrets, ) # --------------------------------------------------------------------------- # _mask_server_config # --------------------------------------------------------------------------- def test_mask_replaces_env_values_with_asterisks(): """Env dict values should be replaced with '***'.""" server = McpServerConfigResponse( env={"GITHUB_TOKEN": "ghp_real_secret_123", "API_KEY": "sk-abc"}, ) masked = _mask_server_config(server) assert masked.env == {"GITHUB_TOKEN": "***", "API_KEY": "***"} def test_mask_replaces_header_values_with_asterisks(): """Header dict values should be replaced with '***'.""" server = McpServerConfigResponse( headers={"Authorization": "Bearer tok_123", "X-API-Key": "key_456"}, ) masked = _mask_server_config(server) assert masked.headers == {"Authorization": "***", "X-API-Key": "***"} def test_mask_removes_oauth_secrets(): """OAuth client_secret and refresh_token should be set to None.""" server = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_id="my-client", client_secret="super-secret", refresh_token="refresh-token-abc", token_url="https://auth.example.com/token", ), ) masked = _mask_server_config(server) assert masked.oauth is not None assert masked.oauth.client_secret is None assert masked.oauth.refresh_token is None # Non-secret fields preserved assert masked.oauth.client_id == "my-client" assert masked.oauth.token_url == "https://auth.example.com/token" def test_mask_preserves_non_secret_fields(): """Non-sensitive fields should pass through unchanged.""" server = McpServerConfigResponse( enabled=True, type="stdio", command="npx", args=["-y", "@modelcontextprotocol/server-github"], env={"KEY": "val"}, description="GitHub MCP server", ) masked = _mask_server_config(server) assert masked.enabled is True assert masked.type == "stdio" assert masked.command == "npx" assert masked.args == ["-y", "@modelcontextprotocol/server-github"] assert masked.description == "GitHub MCP server" def test_mask_handles_empty_env_and_headers(): """Empty env/headers dicts should remain empty.""" server = McpServerConfigResponse() masked = _mask_server_config(server) assert masked.env == {} assert masked.headers == {} def test_mask_handles_no_oauth(): """Server without OAuth should remain None.""" server = McpServerConfigResponse(oauth=None) masked = _mask_server_config(server) assert masked.oauth is None def test_mask_does_not_mutate_original(): """Masking should return a new object, not modify the original.""" server = McpServerConfigResponse(env={"KEY": "secret"}) masked = _mask_server_config(server) assert server.env["KEY"] == "secret" assert masked.env["KEY"] == "***" # --------------------------------------------------------------------------- # _merge_preserving_secrets # --------------------------------------------------------------------------- def test_merge_preserves_masked_env_values(): """Incoming '***' env values should be replaced with existing secrets.""" incoming = McpServerConfigResponse(env={"KEY": "***"}) existing = McpServerConfigResponse(env={"KEY": "real_secret"}) merged = _merge_preserving_secrets(incoming, existing) assert merged.env["KEY"] == "real_secret" def test_merge_preserves_masked_header_values(): """Incoming '***' header values should be replaced with existing secrets.""" incoming = McpServerConfigResponse(headers={"Authorization": "***"}) existing = McpServerConfigResponse(headers={"Authorization": "Bearer real"}) merged = _merge_preserving_secrets(incoming, existing) assert merged.headers["Authorization"] == "Bearer real" def test_merge_preserves_oauth_secrets_when_none(): """Incoming None oauth secrets should preserve existing values.""" incoming = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_secret=None, refresh_token=None, token_url="https://auth.example.com/token", ), ) existing = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_secret="existing-secret", refresh_token="existing-refresh", token_url="https://auth.example.com/token", ), ) merged = _merge_preserving_secrets(incoming, existing) assert merged.oauth is not None assert merged.oauth.client_secret == "existing-secret" assert merged.oauth.refresh_token == "existing-refresh" def test_merge_accepts_new_secret_values(): """Incoming real secret values should replace existing ones.""" incoming = McpServerConfigResponse( env={"KEY": "new_secret"}, oauth=McpOAuthConfigResponse( client_secret="new-client-secret", refresh_token="new-refresh-token", token_url="https://auth.example.com/token", ), ) existing = McpServerConfigResponse( env={"KEY": "old_secret"}, oauth=McpOAuthConfigResponse( client_secret="old-secret", refresh_token="old-refresh", token_url="https://auth.example.com/token", ), ) merged = _merge_preserving_secrets(incoming, existing) assert merged.env["KEY"] == "new_secret" assert merged.oauth.client_secret == "new-client-secret" assert merged.oauth.refresh_token == "new-refresh-token" def test_merge_handles_no_existing_oauth(): """When existing has no oauth but incoming does, keep incoming.""" incoming = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_secret="new-secret", token_url="https://auth.example.com/token", ), ) existing = McpServerConfigResponse(oauth=None) merged = _merge_preserving_secrets(incoming, existing) assert merged.oauth is not None assert merged.oauth.client_secret == "new-secret" def test_merge_does_not_mutate_original(): """Merge should return a new object, not modify the original.""" incoming = McpServerConfigResponse(env={"KEY": "***"}) existing = McpServerConfigResponse(env={"KEY": "secret"}) merged = _merge_preserving_secrets(incoming, existing) assert incoming.env["KEY"] == "***" assert existing.env["KEY"] == "secret" assert merged.env["KEY"] == "secret" # --------------------------------------------------------------------------- # Comment 2 fix: masked value for new key is rejected # --------------------------------------------------------------------------- def test_merge_rejects_masked_value_for_new_env_key(): """Sending '***' for a key that doesn't exist in existing should raise 400.""" from fastapi import HTTPException incoming = McpServerConfigResponse(env={"NEW_KEY": "***"}) existing = McpServerConfigResponse(env={}) with pytest.raises(HTTPException) as exc_info: _merge_preserving_secrets(incoming, existing) assert exc_info.value.status_code == 400 assert "NEW_KEY" in exc_info.value.detail def test_merge_rejects_masked_value_for_new_header_key(): """Sending '***' for a header key that doesn't exist should raise 400.""" from fastapi import HTTPException incoming = McpServerConfigResponse(headers={"X-New-Auth": "***"}) existing = McpServerConfigResponse(headers={}) with pytest.raises(HTTPException) as exc_info: _merge_preserving_secrets(incoming, existing) assert exc_info.value.status_code == 400 assert "X-New-Auth" in exc_info.value.detail # --------------------------------------------------------------------------- # Comment 4 fix: empty string clears OAuth secrets # --------------------------------------------------------------------------- def test_merge_empty_string_clears_oauth_client_secret(): """Sending '' for client_secret should clear the stored value.""" incoming = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_secret="", refresh_token=None, token_url="https://auth.example.com/token", ), ) existing = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_secret="existing-secret", refresh_token="existing-refresh", token_url="https://auth.example.com/token", ), ) merged = _merge_preserving_secrets(incoming, existing) assert merged.oauth.client_secret is None assert merged.oauth.refresh_token == "existing-refresh" def test_merge_empty_string_clears_oauth_refresh_token(): """Sending '' for refresh_token should clear the stored value.""" incoming = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_secret=None, refresh_token="", token_url="https://auth.example.com/token", ), ) existing = McpServerConfigResponse( oauth=McpOAuthConfigResponse( client_secret="existing-secret", refresh_token="existing-refresh", token_url="https://auth.example.com/token", ), ) merged = _merge_preserving_secrets(incoming, existing) assert merged.oauth.client_secret == "existing-secret" assert merged.oauth.refresh_token is None # --------------------------------------------------------------------------- # Round-trip integration: mask → merge should preserve original secrets # --------------------------------------------------------------------------- def test_roundtrip_mask_then_merge_preserves_original_secrets(): """Simulates the full frontend round-trip: GET (masked) → toggle → PUT.""" original = McpServerConfigResponse( enabled=True, env={"GITHUB_TOKEN": "ghp_real_secret"}, headers={"Authorization": "Bearer real_token"}, oauth=McpOAuthConfigResponse( client_id="client-123", client_secret="oauth-secret", refresh_token="refresh-abc", token_url="https://auth.example.com/token", ), description="GitHub MCP server", ) # Step 1: Server returns masked config (simulates GET response) masked = _mask_server_config(original) assert masked.env["GITHUB_TOKEN"] == "***" assert masked.oauth.client_secret is None # Step 2: Frontend toggles enabled and sends back (simulates PUT request) from_frontend = masked.model_copy(update={"enabled": False}) # Step 3: Server merges with existing secrets (simulates PUT handler) restored = _merge_preserving_secrets(from_frontend, original) assert restored.enabled is False assert restored.env["GITHUB_TOKEN"] == "ghp_real_secret" assert restored.headers["Authorization"] == "Bearer real_token" assert restored.oauth.client_secret == "oauth-secret" assert restored.oauth.refresh_token == "refresh-abc" # Non-secret fields from the update are preserved assert restored.description == "GitHub MCP server"