diff --git a/application/single_app/config.py b/application/single_app/config.py index 7adc43bf2..70f8cf21c 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.216" +VERSION = "0.250.217" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_action_connection_tests.py b/application/single_app/functions_action_connection_tests.py new file mode 100644 index 000000000..dce1dc27d --- /dev/null +++ b/application/single_app/functions_action_connection_tests.py @@ -0,0 +1,682 @@ +# functions_action_connection_tests.py +"""Connection tests for configurable SimpleChat action (plugin) types. + +Each public tester takes an already hydrated action manifest, authenticates with the +configured credentials, and performs one lightweight read against the configured +resource. Testers never raise; they return a normalized result dictionary so the +calling route can jsonify it directly. + +Secrets are never echoed back to the browser. Every failure message is passed through +``sanitize_connection_error`` before it leaves this module. +""" + +import asyncio +import base64 +import logging +import re +from datetime import timedelta +from typing import Any, Dict, List, Optional + +import requests + +from functions_appinsights import log_event +from functions_azure_maps import ( + AZURE_MAPS_DEFAULT_ENDPOINT, + AZURE_MAPS_DEFAULT_LANGUAGE, + AZURE_MAPS_DEFAULT_TILESET_ID, + AZURE_MAPS_DEFAULT_VIEW, + AZURE_MAPS_TILE_API_VERSION, +) +from functions_mcp_operations import MCP_CUSTOM_HEADERS_FIELD + +ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS = 20 +ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS = 1 +ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS = 15 + +REDACTED_PLACEHOLDER = "[redacted]" + +_SENSITIVE_ASSIGNMENT_PATTERN = re.compile( + r"(?i)\b(password|pwd|passphrase|private[_\s-]*key|secret|token|api[_\s-]*key|accountkey|sharedaccesssignature|sig)\b\s*[=:]\s*[^\s,;&'\"]+" +) +_AUTHORIZATION_HEADER_PATTERN = re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}") + +_MISSING_PACKAGE_MESSAGES = { + "snowflake": "The Snowflake Connector for Python is not installed in this container image. Rebuild the image with snowflake-connector-python to test Snowflake actions.", + "tableau": "The tableauserverclient package is not installed in this container image. Rebuild the image with tableauserverclient to test Tableau actions.", + "log_analytics": "The azure-monitor-query package is not installed in this container image. Rebuild the image with azure-monitor-query to test Log Analytics actions.", +} + + +def resolve_test_timeout(raw_timeout: Any, default_timeout: int = ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS) -> int: + """Clamp a caller-supplied timeout into the allowed action connection test range.""" + try: + timeout = int(raw_timeout) + except (TypeError, ValueError): + timeout = default_timeout + if timeout < ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS: + timeout = ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS + return min(timeout, ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS) + + +def collect_manifest_secret_values(manifest: Optional[Dict[str, Any]]) -> List[str]: + """Collect every literal secret value stored in a manifest so it can be redacted.""" + if not isinstance(manifest, dict): + return [] + + secret_values: List[str] = [] + + auth = manifest.get("auth") if isinstance(manifest.get("auth"), dict) else {} + for auth_field in ("key", "identity", "tenantId"): + auth_value = auth.get(auth_field) + if isinstance(auth_value, str) and len(auth_value.strip()) >= 4: + secret_values.append(auth_value.strip()) + + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + passphrase = additional_fields.get("private_key_passphrase") + if isinstance(passphrase, str) and len(passphrase.strip()) >= 4: + secret_values.append(passphrase.strip()) + + custom_headers = additional_fields.get(MCP_CUSTOM_HEADERS_FIELD) + if isinstance(custom_headers, dict): + for header_value in custom_headers.values(): + if isinstance(header_value, str) and len(header_value.strip()) >= 4: + secret_values.append(header_value.strip()) + + # Redact the longest values first so overlapping secrets do not leave fragments behind. + return sorted(set(secret_values), key=len, reverse=True) + + +def sanitize_connection_error(error: Any, manifest: Optional[Dict[str, Any]] = None, max_length: int = 400) -> str: + """Return an error message safe to show in the browser, with credentials removed.""" + message = str(error or "").strip() + if not message: + return "The connection test failed for an unknown reason." + + for secret_value in collect_manifest_secret_values(manifest): + message = message.replace(secret_value, REDACTED_PLACEHOLDER) + encoded_secret = base64.b64encode(secret_value.encode("utf-8", errors="ignore")).decode("ascii") + if len(encoded_secret) >= 8: + message = message.replace(encoded_secret, REDACTED_PLACEHOLDER) + + message = _SENSITIVE_ASSIGNMENT_PATTERN.sub(lambda match: f"{match.group(1)}={REDACTED_PLACEHOLDER}", message) + message = _AUTHORIZATION_HEADER_PATTERN.sub(lambda match: f"{match.group(1)} {REDACTED_PLACEHOLDER}", message) + message = re.sub(r"\s+", " ", message).strip() + + if len(message) > max_length: + message = f"{message[:max_length].rstrip()}..." + return message + + +def build_success_result(message: str, **details: Any) -> Dict[str, Any]: + """Build a normalized successful action connection test result.""" + return { + "success": True, + "message": message, + "status": 200, + "details": {key: value for key, value in details.items() if value not in (None, "")}, + } + + +def build_failure_result(error: str, status: int = 400, **details: Any) -> Dict[str, Any]: + """Build a normalized failed action connection test result.""" + return { + "success": False, + "error": error, + "status": status, + "details": {key: value for key, value in details.items() if value not in (None, "")}, + } + + +def _missing_package_result(package_key: str) -> Dict[str, Any]: + """Build a friendly failure result for an uninstalled optional dependency.""" + return build_failure_result(_MISSING_PACKAGE_MESSAGES[package_key], status=501) + + +def _log_connection_test(plugin_type: str, result: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> None: + """Emit low-sensitivity telemetry for an action connection test outcome.""" + context = {"plugin_type": plugin_type, "succeeded": bool(result.get("success"))} + context.update(extra or {}) + if result.get("success"): + log_event(f"[ACTION_TEST] {plugin_type} connection test succeeded", extra=context, level=logging.INFO) + return + context["status"] = result.get("status") + log_event(f"[ACTION_TEST] {plugin_type} connection test failed", extra=context, level=logging.WARNING) + + +def _http_status_failure(status_code: int, auth_hint: str, not_found_hint: str, fallback_hint: str) -> Dict[str, Any]: + """Map an upstream HTTP status onto an actionable failure result.""" + if status_code in (401, 403): + return build_failure_result(auth_hint, status=403, http_status=status_code) + if status_code == 404: + return build_failure_result(not_found_hint, status=404, http_status=status_code) + return build_failure_result(f"{fallback_hint} (HTTP {status_code}).", status=400, http_status=status_code) + + +def _build_openapi_probe_request(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Build headers, query params, and basic auth for the OpenAPI base URL probe.""" + from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory + + normalized_auth = OpenApiPluginFactory._extract_auth_config(manifest) or {} + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + auth_type = str(normalized_auth.get("type") or "none").strip().lower() + + headers: Dict[str, str] = {"Accept": "*/*"} + params: Dict[str, str] = {} + basic_auth = None + + if auth_type == "bearer": + token = str(normalized_auth.get("token") or "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + elif auth_type == "basic": + username = str(normalized_auth.get("username") or "") + password = str(normalized_auth.get("password") or "") + if username or password: + basic_auth = (username, password) + elif auth_type in ("key", "api_key"): + key_value = str(normalized_auth.get("key") or normalized_auth.get("value") or "").strip() + key_name = str( + additional_fields.get("api_key_name") + or normalized_auth.get("name") + or "X-API-Key" + ).strip() or "X-API-Key" + key_location = str( + additional_fields.get("api_key_location") + or normalized_auth.get("location") + or "header" + ).strip().lower() + if key_value: + if key_location == "query": + params[key_name] = key_value + else: + headers[key_name] = key_value + + return {"headers": headers, "params": params, "basic_auth": basic_auth, "auth_type": auth_type} + + +def test_openapi_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate an OpenAPI action by parsing its spec and probing the authenticated base URL.""" + from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory + + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + base_url = str(manifest.get("endpoint") or additional_fields.get("base_url") or "").strip().rstrip("/") + timeout = resolve_test_timeout(additional_fields.get("timeout")) + + if not base_url: + return build_failure_result("A base URL is required before testing an OpenAPI action.") + + try: + plugin = OpenApiPluginFactory.create_from_config( + { + "base_url": base_url, + "endpoint": base_url, + "auth": manifest.get("auth") or {}, + "additionalFields": additional_fields, + "openapi_spec_content": additional_fields.get("openapi_spec_content"), + "openapi_source_type": additional_fields.get("openapi_source_type"), + "openapi_file_id": additional_fields.get("openapi_file_id"), + "openapi_spec_path": additional_fields.get("openapi_spec_path"), + } + ) + operations = plugin.get_available_operations() or [] + except Exception as exc: + result = build_failure_result( + f"The OpenAPI specification could not be loaded: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("openapi", result, {"stage": "spec_parse"}) + return result + + probe = _build_openapi_probe_request(manifest) + try: + response = requests.get( + base_url, + headers=probe["headers"], + params=probe["params"] or None, + auth=probe["basic_auth"], + timeout=timeout, + allow_redirects=True, + ) + except requests.RequestException as exc: + result = build_failure_result( + f"The API base URL could not be reached: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("openapi", result, {"stage": "base_url_probe"}) + return result + + operation_count = len(operations) + if response.status_code in (401, 403): + result = build_failure_result( + f"The API base URL responded with HTTP {response.status_code}. The specification parsed correctly, " + "so verify the configured authentication credentials.", + status=403, + http_status=response.status_code, + operation_count=operation_count, + ) + _log_connection_test("openapi", result, {"stage": "base_url_probe", "http_status": response.status_code}) + return result + + result = build_success_result( + f"Specification parsed with {operation_count} operation{'' if operation_count == 1 else 's'}. " + f"The base URL responded with HTTP {response.status_code}.", + operation_count=operation_count, + http_status=response.status_code, + auth_type=probe["auth_type"], + ) + _log_connection_test("openapi", result, {"operation_count": operation_count, "http_status": response.status_code}) + return result + + +def test_azure_maps_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate an Azure Maps action by fetching a single base road tile.""" + auth = manifest.get("auth") if isinstance(manifest.get("auth"), dict) else {} + subscription_key = str(auth.get("key") or "").strip() + endpoint = str(manifest.get("endpoint") or AZURE_MAPS_DEFAULT_ENDPOINT).strip().rstrip("/") + + if not subscription_key: + return build_failure_result("An Azure Maps subscription key is required before testing this action.") + + try: + response = requests.get( + f"{endpoint}/map/tile", + params={ + "api-version": AZURE_MAPS_TILE_API_VERSION, + "tilesetId": AZURE_MAPS_DEFAULT_TILESET_ID, + "zoom": 0, + "x": 0, + "y": 0, + "tileSize": "256", + "language": AZURE_MAPS_DEFAULT_LANGUAGE, + "view": AZURE_MAPS_DEFAULT_VIEW, + "subscription-key": subscription_key, + }, + timeout=ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + result = build_failure_result( + f"Azure Maps could not be reached: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("azure_maps_openlayers", result) + return result + + if response.status_code == 200: + content_type = str(response.headers.get("Content-Type") or "").lower() + if not content_type.startswith("image/"): + result = build_failure_result( + "Azure Maps returned a non-image response for the test tile. Confirm the endpoint is an Azure Maps account URL." + ) + _log_connection_test("azure_maps_openlayers", result) + return result + + result = build_success_result( + f"Successfully retrieved an Azure Maps {AZURE_MAPS_DEFAULT_TILESET_ID} tile with the configured subscription key.", + tileset_id=AZURE_MAPS_DEFAULT_TILESET_ID, + http_status=response.status_code, + ) + _log_connection_test("azure_maps_openlayers", result) + return result + + result = _http_status_failure( + response.status_code, + "Azure Maps rejected the subscription key. Verify the key and confirm it belongs to the target Azure Maps account.", + "Azure Maps could not find the tile endpoint. Verify the configured Azure Maps endpoint URL.", + "The Azure Maps tile request failed", + ) + _log_connection_test("azure_maps_openlayers", result, {"http_status": response.status_code}) + return result + + +def test_blob_storage_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Blob Storage action by reading container properties and listing one blob.""" + from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError + from semantic_kernel_plugins.blob_storage_plugin import BlobStoragePlugin + + try: + plugin = BlobStoragePlugin(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("blob_storage", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Blob Storage client could not be created: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("blob_storage", result, {"stage": "client"}) + return result + + try: + container_properties = plugin.container_client.get_container_properties() + except ResourceNotFoundError: + result = build_failure_result( + f"Container '{plugin.container_name}' was not found in the configured storage account.", + status=404, + ) + _log_connection_test("blob_storage", result, {"stage": "container_read"}) + return result + except ClientAuthenticationError as exc: + result = build_failure_result( + f"Storage authentication failed: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("blob_storage", result, {"stage": "container_read"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Blob Storage container could not be read: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("blob_storage", result, {"stage": "container_read"}) + return result + + try: + blob_pages = plugin.container_client.list_blobs( + name_starts_with=plugin.blob_prefix or None, + results_per_page=1, + ).by_page() + listed_blobs = list(next(blob_pages, [])) + except Exception as exc: + result = build_failure_result( + f"The Blob Storage container could not be listed: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("blob_storage", result, {"stage": "container_list"}) + return result + + prefix_label = f" under prefix '{plugin.blob_prefix}'" if plugin.blob_prefix else "" + blob_label = "at least one blob" if listed_blobs else "no blobs yet" + result = build_success_result( + f"Connected to container '{plugin.container_name}' and found {blob_label}{prefix_label}.", + container_name=plugin.container_name, + blob_prefix=plugin.blob_prefix, + last_modified=str(getattr(container_properties, "last_modified", "") or ""), + ) + _log_connection_test("blob_storage", result) + return result + + +def test_databricks_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Databricks action by reading the configured SQL warehouse.""" + from semantic_kernel_plugins.databricks_plugin_factory import DatabricksPluginFactory + + try: + plugin = DatabricksPluginFactory.create_from_config(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("databricks", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Databricks action configuration is invalid: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("databricks", result, {"stage": "configuration"}) + return result + + timeout = resolve_test_timeout(plugin.timeout) + try: + headers = plugin._headers() + except Exception as exc: + result = build_failure_result( + f"A Databricks access token could not be acquired: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("databricks", result, {"stage": "token"}) + return result + + try: + response = requests.get( + f"{plugin.endpoint}/api/2.0/sql/warehouses/{plugin.warehouse_id}", + headers=headers, + timeout=timeout, + ) + except requests.RequestException as exc: + result = build_failure_result( + f"The Databricks workspace could not be reached: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("databricks", result, {"stage": "warehouse_read"}) + return result + + if response.status_code != 200: + result = _http_status_failure( + response.status_code, + "Databricks rejected the configured credentials. Verify the token, service principal, or managed identity access to this workspace.", + f"SQL warehouse '{plugin.warehouse_id}' was not found in this Databricks workspace.", + "The Databricks warehouse lookup failed", + ) + _log_connection_test("databricks", result, {"stage": "warehouse_read", "http_status": response.status_code}) + return result + + try: + warehouse = response.json() or {} + except ValueError: + warehouse = {} + + warehouse_name = str(warehouse.get("name") or plugin.warehouse_id) + warehouse_state = str(warehouse.get("state") or "UNKNOWN") + result = build_success_result( + f"Connected to Databricks SQL warehouse '{warehouse_name}' (state: {warehouse_state}).", + warehouse_id=plugin.warehouse_id, + warehouse_name=warehouse_name, + warehouse_state=warehouse_state, + ) + _log_connection_test("databricks", result, {"warehouse_state": warehouse_state}) + return result + + +def test_log_analytics_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Log Analytics action by running a trivial KQL query against the workspace.""" + try: + from azure.monitor.query import LogsQueryStatus + except ImportError: + return _missing_package_result("log_analytics") + + from semantic_kernel_plugins.log_analytics_plugin import LogAnalyticsPlugin + + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + workspace_id = str(additional_fields.get("workspaceId") or "").strip() + if not workspace_id: + return build_failure_result("A Log Analytics workspace ID is required before testing this action.") + + try: + plugin = LogAnalyticsPlugin(manifest) + except Exception as exc: + result = build_failure_result( + f"The Log Analytics client could not be created: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("log_analytics", result, {"stage": "client"}) + return result + + if not plugin._client: + result = build_failure_result( + "A Log Analytics query client could not be created. Verify the workspace ID, cloud, and authentication settings." + ) + _log_connection_test("log_analytics", result, {"stage": "client"}) + return result + + try: + response = plugin._client.query_workspace( + workspace_id=workspace_id, + query="print TestConnection = 1", + timespan=timedelta(minutes=5), + ) + except Exception as exc: + result = build_failure_result( + f"The Log Analytics workspace query failed: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("log_analytics", result, {"stage": "query"}) + return result + + query_status = getattr(response, "status", None) + if query_status == LogsQueryStatus.FAILURE: + partial_error = getattr(response, "partial_error", None) + result = build_failure_result( + f"The Log Analytics workspace rejected the test query: {sanitize_connection_error(partial_error or 'Unknown query failure.', manifest)}" + ) + _log_connection_test("log_analytics", result, {"stage": "query"}) + return result + + cloud = str(additional_fields.get("cloud") or "public") + result = build_success_result( + f"Successfully queried Log Analytics workspace {workspace_id} in the {cloud} cloud.", + workspace_id=workspace_id, + cloud=cloud, + query_status=str(query_status or "SUCCESS"), + ) + _log_connection_test("log_analytics", result, {"cloud": cloud}) + return result + + +def test_mcp_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate an MCP action by initializing a session and listing the server's tools.""" + from semantic_kernel_plugins.mcp_plugin_factory import McpPluginFactory + + try: + probe_result = asyncio.run(McpPluginFactory.probe_server_from_config(manifest)) + except Exception as exc: + result = build_failure_result( + f"The MCP server connection failed: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("mcp", result) + return result + + if not isinstance(probe_result, dict): + result = build_failure_result("The MCP server returned an unexpected probe response.") + _log_connection_test("mcp", result) + return result + + tools = probe_result.get("tools") or [] + transport = str(probe_result.get("transport") or "") + auth_method = str(probe_result.get("auth_method") or "") + warnings = [str(warning) for warning in (probe_result.get("warnings") or []) if str(warning).strip()] + + message = ( + f"Connected over {transport or 'the configured transport'} and listed " + f"{len(tools)} tool{'' if len(tools) == 1 else 's'}." + ) + if warnings: + message = f"{message} {' '.join(warnings)}" + + result = build_success_result( + message, + transport=transport, + auth_method=auth_method, + tool_count=len(tools), + ) + result["warnings"] = warnings + _log_connection_test("mcp", result, {"transport": transport, "tool_count": len(tools)}) + return result + + +def test_snowflake_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Snowflake action by opening a session and reading the account version.""" + from semantic_kernel_plugins.snowflake_plugin_factory import SnowflakePluginFactory + + try: + plugin = SnowflakePluginFactory.create_from_config(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("snowflake", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Snowflake action configuration is invalid: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("snowflake", result, {"stage": "configuration"}) + return result + + connection = None + cursor = None + try: + connection = plugin._connect() + cursor = connection.cursor() + cursor.execute("SELECT CURRENT_VERSION()") + row = cursor.fetchone() + snowflake_version = str(row[0]) if row else "unknown" + except ImportError: + result = _missing_package_result("snowflake") + _log_connection_test("snowflake", result, {"stage": "driver"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Snowflake connection failed: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("snowflake", result, {"stage": "connect"}) + return result + finally: + if cursor is not None: + try: + cursor.close() + except Exception: + log_event( + "[ACTION_TEST] Snowflake test cursor could not be closed cleanly.", + level=logging.DEBUG, + debug_only=True, + ) + if connection is not None: + try: + connection.close() + except Exception: + log_event( + "[ACTION_TEST] Snowflake test connection could not be closed cleanly.", + level=logging.DEBUG, + debug_only=True, + ) + + result = build_success_result( + f"Connected to Snowflake account '{plugin.account}' using warehouse '{plugin.warehouse}' (version {snowflake_version}).", + account=plugin.account, + warehouse=plugin.warehouse, + snowflake_version=snowflake_version, + ) + _log_connection_test("snowflake", result) + return result + + +def test_tableau_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Tableau action by signing in to the configured server and site.""" + from semantic_kernel_plugins.tableau_plugin_factory import TableauPluginFactory + + try: + plugin = TableauPluginFactory.create_from_config(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("tableau", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Tableau action configuration is invalid: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("tableau", result, {"stage": "configuration"}) + return result + + try: + plugin._require_tsc() + except RuntimeError: + result = _missing_package_result("tableau") + _log_connection_test("tableau", result, {"stage": "driver"}) + return result + + try: + server = plugin._create_server() + tableau_auth = plugin._get_tableau_auth() + with server.auth.sign_in(tableau_auth): + server_version = str(getattr(server, "version", "") or "unknown") + site_id = str(getattr(server, "site_id", "") or "") + except Exception as exc: + result = build_failure_result( + f"The Tableau sign-in failed: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("tableau", result, {"stage": "sign_in"}) + return result + + site_label = plugin.site_content_url or "the default site" + result = build_success_result( + f"Signed in to Tableau at {plugin.endpoint} on {site_label} (API version {server_version}).", + server_version=server_version, + site_content_url=plugin.site_content_url, + site_id_present=bool(site_id), + ) + _log_connection_test("tableau", result) + return result diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 234dd62fa..309a80753 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -52,7 +52,6 @@ resolve_secret_reference_for_context, SecretReturnType, redact_plugin_secret_values, - retrieve_secret_from_key_vault_by_full_name, ui_trigger_word, validate_secret_name_dynamic, ) @@ -66,6 +65,16 @@ log_action_deletion, ) from functions_azure_maps import AZURE_MAPS_DEFAULT_ENDPOINT, AZURE_MAPS_PLUGIN_TYPE +from functions_action_connection_tests import ( + test_azure_maps_connection, + test_blob_storage_connection, + test_databricks_connection, + test_log_analytics_connection, + test_mcp_connection, + test_openapi_connection, + test_snowflake_connection, + test_tableau_connection, +) from functions_blob_storage_operations import ( BLOB_STORAGE_PLUGIN_TYPE, derive_blob_endpoint_from_connection_string, @@ -774,20 +783,45 @@ def _hydrate_sql_test_identity(data, existing_plugin, user_id): ) -def _resolve_secret_value_for_plugin_test(value, field_name, plugin_label='plugin'): - """Resolve a Key Vault reference for plugin test-connection flows.""" +ACTION_CONNECTION_TEST_AUTH_SECRET_FIELDS = ('key', 'identity', 'tenantId') +ACTION_CONNECTION_TEST_ADDITIONAL_SECRET_FIELDS = ('private_key_passphrase',) +# Secret reference sources must match how keyvault_plugin_get_helper stored each field. +ACTION_AUTH_SECRET_SOURCES = {"action"} +ACTION_ADDITIONAL_SECRET_SOURCES = {"action-addset"} + + +def _resolve_secret_value_for_action_test(value, field_name, plugin_label, scope_value, scope, allowed_sources): + """Resolve a Key Vault reference for an action test, bound to the action's own scope. + + Callers must pass an explicit scope and the allowed reference sources for the field being + resolved. A caller-supplied reference name is untrusted input, so resolving it without a + scope check would let an authenticated user read another user's, group's, or global + action's secret and forward it to a caller-controlled endpoint. + + Sources mirror how the action was stored by ``keyvault_plugin_get_helper``: ``auth.*`` + fields use ``action`` while ``additionalFields.*`` values and MCP custom headers use + ``action-addset``. + """ if not isinstance(value, str) or not value: return value if not validate_secret_name_dynamic(value): return value - resolved_value = retrieve_secret_from_key_vault_by_full_name(value) - if validate_secret_name_dynamic(resolved_value): - raise ValueError(f"Unable to resolve stored Key Vault secret for {plugin_label} field '{field_name}'.") - return resolved_value + if not scope_value or not scope: + raise ValueError( + f"Unable to determine the Key Vault scope for {plugin_label} field '{field_name}'." + ) + + return resolve_secret_reference_for_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources=allowed_sources, + context_label=f"{plugin_label} field '{field_name}'", + ) -def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin): +def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin, scope_value, scope): """Resolve or preserve MCP custom header values for transient discovery calls.""" additional_fields = discovery_manifest.get('additionalFields') if isinstance(discovery_manifest.get('additionalFields'), dict) else {} custom_headers = additional_fields.get(MCP_CUSTOM_HEADERS_FIELD, {}) @@ -808,10 +842,13 @@ def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin): raise ValueError(f"Stored MCP custom header '{header_name}' could not be resolved. Re-enter the header value.") if not resolved_header_value: continue - hydrated_headers[header_name] = _resolve_secret_value_for_plugin_test( + hydrated_headers[header_name] = _resolve_secret_value_for_action_test( resolved_header_value, f"custom_headers.{header_name}", - plugin_label='MCP', + 'MCP', + scope_value, + scope, + ACTION_ADDITIONAL_SECRET_SOURCES, ) additional_fields[MCP_CUSTOM_HEADERS_FIELD] = hydrated_headers @@ -819,21 +856,19 @@ def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin): def _resolve_secret_value_for_sql_test(value, field_name, scope_value=None, scope="user"): - """Resolve a Key Vault reference for SQL test-connection flows.""" - if not isinstance(value, str) or not value: - return value - if not validate_secret_name_dynamic(value): - return value + """Resolve a Key Vault reference for SQL test-connection flows. - if scope_value is None: - return _resolve_secret_value_for_plugin_test(value, field_name, plugin_label='SQL') - - return resolve_secret_reference_for_context( + SQL secrets live in additionalFields (connection_string, password), so they carry the + action-addset source. Routing through the shared action-test resolver keeps the + fail-closed behavior when a scope cannot be determined. + """ + return _resolve_secret_value_for_action_test( value, - scope_value=scope_value, - scope=scope, - allowed_sources={"action-addset"}, - context_label=f"SQL field '{field_name}'", + field_name, + 'SQL', + scope_value, + scope, + ACTION_ADDITIONAL_SECRET_SOURCES, ) @@ -857,6 +892,10 @@ def _load_existing_plugin_for_test(plugin_context, user_id): return get_group_action(active_group, plugin_identifier, return_type=SecretReturnType.NAME) if plugin_scope == 'global': + # Global actions are admin-managed. Gate here so every test route inherits the check, + # including routes that do not otherwise resolve an action identity scope. + if "Admin" not in session.get("user", {}).get("roles", []): + raise PermissionError("Admin role required to load a global action for testing.") return get_global_action(plugin_identifier, return_type=SecretReturnType.NAME) return get_personal_action(user_id, plugin_identifier, return_type=SecretReturnType.NAME) @@ -1936,6 +1975,7 @@ def discover_mcp_tools(): try: existing_plugin = _load_existing_plugin_for_test(payload.get('plugin_context'), user_id) scope_type, scope_id = _resolve_action_identity_context(payload, existing_plugin, user_id) + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) discovery_manifest = dict(payload) discovery_manifest['mcp_operation_id'] = mcp_operation_id @@ -1988,10 +2028,17 @@ def discover_mcp_tools(): else: auth = discovery_manifest.get('auth') if isinstance(discovery_manifest.get('auth'), dict) else {} if auth.get('key'): - auth['key'] = _resolve_secret_value_for_plugin_test(auth.get('key'), 'auth.key', plugin_label='MCP') + auth['key'] = _resolve_secret_value_for_action_test( + auth.get('key'), + 'auth.key', + 'MCP', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) discovery_manifest['auth'] = auth - _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin) + _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin, plugin_scope_value, plugin_scope) is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(discovery_manifest, MCP_PLUGIN_TYPE) if not is_valid: @@ -2456,7 +2503,15 @@ def test_cosmos_connection(): return jsonify({'success': False, 'error': 'Stored Cosmos DB account key could not be resolved for testing. Re-enter the account key.'}), 400 try: - auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='Cosmos DB') + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + auth_key = _resolve_secret_value_for_action_test( + auth_key, + 'auth.key', + 'Cosmos DB', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 @@ -2611,7 +2666,15 @@ def test_yamcs_connection(): }), 400 try: - auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='Yamcs') + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + auth_key = _resolve_secret_value_for_action_test( + auth_key, + 'auth.key', + 'Yamcs', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 @@ -2779,7 +2842,15 @@ def test_rocksdb_connection(): }), 400 try: - auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='RocksDB') + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + auth_key = _resolve_secret_value_for_action_test( + auth_key, + 'auth.key', + 'RocksDB', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) except ValueError: return jsonify({ 'success': False, @@ -2868,3 +2939,269 @@ def test_rocksdb_connection(): 'success': False, 'error': 'The RocksDB service could not be reached. Verify the base URL and that the service is running.' }), 400 + + +def _rehydrate_action_test_secret(current_value, stored_value, plugin_label, field_label): + """Restore a masked action secret from the stored manifest for a transient test.""" + resolved_value = current_value + if resolved_value in ('', None, ui_trigger_word) and stored_value: + resolved_value = stored_value + if resolved_value == ui_trigger_word: + raise ValueError( + f"The stored {plugin_label} value for '{field_label}' could not be resolved for testing. Re-enter the credential." + ) + return resolved_value + + +def _prepare_action_test_manifest(data, plugin_type, plugin_label): + """Build a hydrated, validated transient manifest for an action test-connection route.""" + if not isinstance(data, dict): + raise ValueError('Invalid action connection test payload.') + + user_id = get_current_user_id() + existing_plugin = _load_existing_plugin_for_test(data.get('plugin_context'), user_id) + scope_type, scope_id = _resolve_action_identity_context(data, existing_plugin, user_id) + # Secret references are resolved against the loaded action's own Key Vault scope, never + # against a scope derived from the request body. + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + + default_name = f'{plugin_type}_connection_test' + manifest = { + 'name': str(data.get('name') or '').strip() or default_name, + 'displayName': str(data.get('displayName') or '').strip() or f'{plugin_label} connection test', + 'description': str(data.get('description') or '').strip() or f'Transient {plugin_label} connection test manifest', + 'type': plugin_type, + 'endpoint': str(data.get('endpoint') or '').strip(), + 'auth': dict(data.get('auth')) if isinstance(data.get('auth'), dict) else {}, + 'metadata': dict(data.get('metadata')) if isinstance(data.get('metadata'), dict) else {}, + 'additionalFields': dict(data.get('additionalFields')) if isinstance(data.get('additionalFields'), dict) else {}, + } + + identity_id = str(data.get('identity_id') or '').strip() + if identity_id: + manifest['identity_id'] = identity_id + + _apply_plugin_runtime_defaults(manifest) + + existing_auth = {} + existing_additional_fields = {} + if isinstance(existing_plugin, dict): + if isinstance(existing_plugin.get('auth'), dict): + existing_auth = existing_plugin['auth'] + if isinstance(existing_plugin.get('additionalFields'), dict): + existing_additional_fields = existing_plugin['additionalFields'] + + auth = manifest.get('auth') if isinstance(manifest.get('auth'), dict) else {} + for auth_field in ACTION_CONNECTION_TEST_AUTH_SECRET_FIELDS: + # Only rehydrate fields the modal actually collected, so switching an action to a + # different authentication method never resurrects a credential the user removed. + if auth_field not in auth: + continue + resolved_value = _rehydrate_action_test_secret( + auth.get(auth_field), + existing_auth.get(auth_field), + plugin_label, + f'auth.{auth_field}', + ) + if resolved_value not in ('', None): + auth[auth_field] = resolved_value + manifest['auth'] = auth + + additional_fields = manifest.get('additionalFields') if isinstance(manifest.get('additionalFields'), dict) else {} + for additional_field in ACTION_CONNECTION_TEST_ADDITIONAL_SECRET_FIELDS: + if additional_field not in additional_fields: + continue + resolved_value = _rehydrate_action_test_secret( + additional_fields.get(additional_field), + existing_additional_fields.get(additional_field), + plugin_label, + f'additionalFields.{additional_field}', + ) + if resolved_value not in ('', None): + additional_fields[additional_field] = resolved_value + + if plugin_type == 'openapi' and not additional_fields.get('openapi_spec_content'): + stored_spec_content = existing_additional_fields.get('openapi_spec_content') + if stored_spec_content: + additional_fields['openapi_spec_content'] = stored_spec_content + additional_fields.setdefault( + 'openapi_source_type', + existing_additional_fields.get('openapi_source_type') or 'content', + ) + manifest['additionalFields'] = additional_fields + + if plugin_type == MCP_PLUGIN_TYPE: + _hydrate_mcp_custom_headers_for_test(manifest, existing_plugin, plugin_scope_value, plugin_scope) + + if manifest.get('identity_id'): + manifest = hydrate_action_identity_reference( + manifest, + scope_type, + scope_id, + return_type=SecretReturnType.VALUE, + ) + else: + auth = manifest.get('auth') if isinstance(manifest.get('auth'), dict) else {} + if auth.get('key'): + auth['key'] = _resolve_secret_value_for_action_test( + auth.get('key'), + 'auth.key', + plugin_label, + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) + manifest['auth'] = auth + additional_fields = manifest.get('additionalFields') if isinstance(manifest.get('additionalFields'), dict) else {} + if additional_fields.get('private_key_passphrase'): + additional_fields['private_key_passphrase'] = _resolve_secret_value_for_action_test( + additional_fields.get('private_key_passphrase'), + 'additionalFields.private_key_passphrase', + plugin_label, + plugin_scope_value, + plugin_scope, + ACTION_ADDITIONAL_SECRET_SOURCES, + ) + manifest['additionalFields'] = additional_fields + + is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(manifest, plugin_type) + if not is_valid: + raise ValueError('; '.join(validation_errors) or f'The {plugin_label} action configuration is invalid.') + + return manifest, scope_type, scope_id + + +def _run_action_connection_test(plugin_type, plugin_label, tester, before_test=None): + """Prepare a transient manifest, run one action connection test, and jsonify the result.""" + data = request.get_json(silent=True) or {} + + try: + manifest, scope_type, scope_id = _prepare_action_test_manifest(data, plugin_type, plugin_label) + if callable(before_test): + before_test(manifest, scope_type, scope_id) + except PermissionError as exc: + return jsonify({'success': False, 'error': str(exc)}), 403 + except LookupError as exc: + return jsonify({'success': False, 'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + except McpRuntimeError as exc: + return jsonify({'success': False, 'error': str(exc)}), get_mcp_error_http_status(exc) + + try: + result = tester(manifest) + except Exception as exc: + log_event( + f'[ACTION_TEST] {plugin_label} connection test raised an unexpected error: {exc}', + extra={'plugin_type': plugin_type}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + 'success': False, + 'error': f'The {plugin_label} connection test failed unexpectedly. Check the action configuration and try again.', + }), 500 + + status_code = int(result.get('status') or (200 if result.get('success') else 400)) + response_payload = {'success': bool(result.get('success'))} + if result.get('success'): + response_payload['message'] = result.get('message') or 'Connection successful.' + else: + response_payload['error'] = result.get('error') or 'Connection failed.' + if result.get('details'): + response_payload['details'] = result['details'] + if result.get('warnings'): + response_payload['warnings'] = result['warnings'] + + return jsonify(response_payload), status_code + + +@bpap.route('/api/plugins/test-openapi-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_openapi_action_connection(): + """Test an OpenAPI action by parsing its specification and probing the authenticated base URL.""" + return _run_action_connection_test('openapi', 'OpenAPI', test_openapi_connection) + + +@bpap.route('/api/plugins/test-azure-maps-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_azure_maps_action_connection(): + """Test an Azure Maps action by retrieving a single base road tile.""" + return _run_action_connection_test(AZURE_MAPS_PLUGIN_TYPE, 'Azure Maps', test_azure_maps_connection) + + +@bpap.route('/api/plugins/test-blob-storage-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_blob_storage_action_connection(): + """Test a Blob Storage action by reading container properties and listing one blob.""" + return _run_action_connection_test(BLOB_STORAGE_PLUGIN_TYPE, 'Blob Storage', test_blob_storage_connection) + + +@bpap.route('/api/plugins/test-databricks-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_databricks_action_connection(): + """Test a Databricks action by reading the configured SQL warehouse.""" + return _run_action_connection_test(DATABRICKS_PLUGIN_TYPE, 'Databricks', test_databricks_connection) + + +@bpap.route('/api/plugins/test-log-analytics-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_log_analytics_action_connection(): + """Test a Log Analytics action by running a trivial KQL query against the workspace.""" + return _run_action_connection_test('log_analytics', 'Log Analytics', test_log_analytics_connection) + + +@bpap.route('/api/plugins/test-mcp-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_mcp_action_connection(): + """Test an MCP action by initializing a session and listing the server's tools.""" + + def enforce_mcp_test_policy(manifest, scope_type, scope_id): + if scope_type != WORKSPACE_IDENTITY_SCOPE_GLOBAL: + stdio_error = _reject_non_admin_mcp_stdio(manifest, scope_label='non-global') + if stdio_error: + raise PermissionError(stdio_error) + _enforce_mcp_destination_policy( + manifest, + scope_type, + scope_id, + operation='mcp_connection_test', + user_id=get_current_user_id(), + ) + + return _run_action_connection_test( + MCP_PLUGIN_TYPE, + 'MCP', + test_mcp_connection, + before_test=enforce_mcp_test_policy, + ) + + +@bpap.route('/api/plugins/test-snowflake-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_snowflake_action_connection(): + """Test a Snowflake action by opening a session and reading the account version.""" + return _run_action_connection_test(SNOWFLAKE_PLUGIN_TYPE, 'Snowflake', test_snowflake_connection) + + +@bpap.route('/api/plugins/test-tableau-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_tableau_action_connection(): + """Test a Tableau action by signing in to the configured server and site.""" + return _run_action_connection_test(TABLEAU_PLUGIN_TYPE, 'Tableau', test_tableau_connection) diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 6fd0a7b99..66b213ff5 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -13,6 +13,7 @@ const DATABRICKS_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'manag const SNOWFLAKE_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'username_password']; const TABLEAU_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'username_password']; const YAMCS_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'username_password']; +const LOG_ANALYTICS_ACTION_IDENTITY_AUTH_TYPES = ['client_secret', 'managed_identity']; const BLOB_STORAGE_PLUGIN_TYPE = 'blob_storage'; const DATABRICKS_PLUGIN_TYPE = 'databricks'; const DATABRICKS_DEFAULT_CLOUD = 'azure_commercial'; @@ -62,6 +63,19 @@ const ROCKSDB_DEFAULT_COLUMN_FAMILY = 'default'; const ROCKSDB_DEFAULT_MAX_RESULTS = 100; const ROCKSDB_DEFAULT_MAX_VALUE_BYTES = 32768; const ROCKSDB_DEFAULT_TIMEOUT = 30; +const LOG_ANALYTICS_PLUGIN_TYPE = 'log_analytics'; +const LOG_ANALYTICS_DEFAULT_ENDPOINT = 'https://api.loganalytics.io'; +const LOG_ANALYTICS_DEFAULT_CLOUD = 'public'; +const ACTION_CONNECTION_TEST_CONFIG = { + openapi: { idPrefix: 'openapi', url: '/api/plugins/test-openapi-connection', label: 'OpenAPI' }, + azureMaps: { idPrefix: 'azure-maps', url: '/api/plugins/test-azure-maps-connection', label: 'Azure Maps' }, + blobStorage: { idPrefix: 'blob-storage', url: '/api/plugins/test-blob-storage-connection', label: 'Blob Storage' }, + databricks: { idPrefix: 'databricks', url: '/api/plugins/test-databricks-connection', label: 'Databricks' }, + logAnalytics: { idPrefix: 'log-analytics', url: '/api/plugins/test-log-analytics-connection', label: 'Log Analytics' }, + mcp: { idPrefix: 'mcp', url: '/api/plugins/test-mcp-connection', label: 'MCP' }, + snowflake: { idPrefix: 'snowflake', url: '/api/plugins/test-snowflake-connection', label: 'Snowflake' }, + tableau: { idPrefix: 'tableau', url: '/api/plugins/test-tableau-connection', label: 'Tableau' } +}; const CHART_DEFAULT_ENDPOINT = 'chart://internal'; const INTERNAL_DOCUMENT_SEARCH_ENDPOINT = 'internal://document-search'; const MSGRAPH_DEFAULT_ENDPOINT = 'https://graph.microsoft.com'; @@ -524,6 +538,9 @@ export class PluginModalStepper { if (kind === 'yamcs') { return this.actionIdentities.filter(identity => YAMCS_ACTION_IDENTITY_AUTH_TYPES.includes(this.getIdentityAuthType(identity))); } + if (kind === 'logAnalytics') { + return this.actionIdentities.filter(identity => LOG_ANALYTICS_ACTION_IDENTITY_AUTH_TYPES.includes(this.getIdentityAuthType(identity))); + } return this.actionIdentities; } @@ -534,6 +551,7 @@ export class PluginModalStepper { this.populateActionIdentitySelector('snowflake', 'snowflake-identity-select', 'snowflake-action-identity-group', 'snowflake-identity-status'); this.populateActionIdentitySelector('tableau', 'tableau-identity-select', 'tableau-action-identity-group', 'tableau-identity-status'); this.populateActionIdentitySelector('yamcs', 'yamcs-identity-select', 'yamcs-action-identity-group', 'yamcs-identity-status'); + this.populateActionIdentitySelector('logAnalytics', 'log-analytics-identity-select', 'log-analytics-action-identity-group', 'log-analytics-identity-status'); this.populateActionIdentitySelector('generic', 'plugin-auth-identity-select-generic', 'generic-action-identity-group', 'plugin-auth-identity-status-generic'); this.populateActionIdentitySelector('sql', 'sql-identity-select', 'sql-action-identity-group', 'sql-identity-status'); } @@ -597,6 +615,7 @@ export class PluginModalStepper { snowflake: 'snowflake-identity-select', tableau: 'tableau-identity-select', yamcs: 'yamcs-identity-select', + logAnalytics: 'log-analytics-identity-select', generic: 'plugin-auth-identity-select-generic', sql: 'sql-identity-select' }; @@ -615,6 +634,7 @@ export class PluginModalStepper { snowflake: 'snowflake-identity-select', tableau: 'tableau-identity-select', yamcs: 'yamcs-identity-select', + logAnalytics: 'log-analytics-identity-select', generic: 'plugin-auth-identity-select-generic', sql: 'sql-identity-select' }; @@ -655,7 +675,8 @@ export class PluginModalStepper { databricks: 'databricks-auth-method', snowflake: 'snowflake-auth-method', tableau: 'tableau-auth-method', - yamcs: 'yamcs-auth-method' + yamcs: 'yamcs-auth-method', + logAnalytics: 'log-analytics-auth-method' }; const authSelect = document.getElementById(authSelectIds[kind] || 'plugin-auth-type-generic'); if (authSelect) { @@ -673,6 +694,8 @@ export class PluginModalStepper { this.toggleTableauAuthFields(); } else if (kind === 'yamcs') { this.toggleYamcsAuthFields(); + } else if (kind === 'logAnalytics') { + this.toggleLogAnalyticsAuthFields(); } else { this.toggleGenericAuthFields(); } @@ -705,6 +728,18 @@ export class PluginModalStepper { document.getElementById('tableau-identity-select').addEventListener('change', () => this.handleActionIdentityChange('tableau')); document.getElementById('yamcs-auth-method').addEventListener('change', () => this.toggleYamcsAuthFields()); document.getElementById('yamcs-identity-select').addEventListener('change', () => this.handleActionIdentityChange('yamcs')); + const logAnalyticsCloud = document.getElementById('log-analytics-cloud'); + if (logAnalyticsCloud) { + logAnalyticsCloud.addEventListener('change', () => this.handleLogAnalyticsCloudChange()); + } + const logAnalyticsAuthMethod = document.getElementById('log-analytics-auth-method'); + if (logAnalyticsAuthMethod) { + logAnalyticsAuthMethod.addEventListener('change', () => this.toggleLogAnalyticsAuthFields()); + } + const logAnalyticsIdentitySelect = document.getElementById('log-analytics-identity-select'); + if (logAnalyticsIdentitySelect) { + logAnalyticsIdentitySelect.addEventListener('change', () => this.handleActionIdentityChange('logAnalytics')); + } document.getElementById('plugin-auth-identity-select-generic').addEventListener('change', () => this.handleActionIdentityChange('generic')); const msGraphMailSendMode = document.getElementById('msgraph-mail-send-mode'); if (msGraphMailSendMode) { @@ -761,6 +796,14 @@ export class PluginModalStepper { testYamcsBtn.addEventListener('click', () => this.testYamcsConnection()); } + // Test connection buttons for the remaining configurable action types + Object.keys(ACTION_CONNECTION_TEST_CONFIG).forEach(testKey => { + const button = document.getElementById(`${ACTION_CONNECTION_TEST_CONFIG[testKey].idPrefix}-test-connection-btn`); + if (button) { + button.addEventListener('click', () => this.runActionConnectionTest(testKey)); + } + }); + const keyVaultReminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); if (keyVaultReminderToggle) { keyVaultReminderToggle.addEventListener('change', () => this.toggleKeyVaultReminderFields()); @@ -1331,6 +1374,10 @@ export class PluginModalStepper { return !!(type && type.toLowerCase() === AZURE_MAPS_PLUGIN_TYPE); } + isLogAnalyticsType(type = this.selectedType) { + return !!(type && type.toLowerCase() === LOG_ANALYTICS_PLUGIN_TYPE); + } + isChartType(type = this.selectedType) { return !!(type && type.toLowerCase() === 'chart'); } @@ -3437,7 +3484,7 @@ export class PluginModalStepper { } isStructuredConfigType(type = this.selectedType) { - return this.isSqlType(type) || this.isCosmosType(type) || this.isRocksDbType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isYamcsType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type); + return this.isSqlType(type) || this.isCosmosType(type) || this.isRocksDbType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isYamcsType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type) || this.isLogAnalyticsType(type); } showConfigSectionForType() { @@ -3457,6 +3504,7 @@ export class PluginModalStepper { msGraph: document.getElementById('msgraph-config-section'), mcp: document.getElementById('mcp-config-section'), azureMaps: document.getElementById('azure-maps-config-section'), + logAnalytics: document.getElementById('log-analytics-config-section'), chart: document.getElementById('chart-config-section') }; @@ -3511,6 +3559,9 @@ export class PluginModalStepper { this.updateMsGraphCalendarDelayVisibility(); } else if (this.isAzureMapsType()) { showOnly('azureMaps'); + } else if (this.isLogAnalyticsType()) { + showOnly('logAnalytics'); + this.initializeLogAnalyticsConfiguration(); } else if (this.isChartType()) { showOnly('chart'); this.renderChartConfiguration(); @@ -3579,6 +3630,8 @@ export class PluginModalStepper { titleEl.textContent = 'Microsoft Graph Configuration'; } else if (isAzureMapsType) { titleEl.textContent = 'Azure Maps Configuration'; + } else if (this.isLogAnalyticsType()) { + titleEl.textContent = 'Log Analytics Configuration'; } else if (isChartType) { titleEl.textContent = 'Chart Configuration'; } else { @@ -3768,6 +3821,7 @@ export class PluginModalStepper { const msGraphSection = document.getElementById('msgraph-config-section'); const azureMapsSection = document.getElementById('azure-maps-config-section'); const chartSection = document.getElementById('chart-config-section'); + const logAnalyticsSection = document.getElementById('log-analytics-config-section'); const isOpenApiVisible = !openApiSection.classList.contains('d-none'); const isSqlVisible = !sqlSection.classList.contains('d-none'); const isCosmosVisible = !cosmosSection.classList.contains('d-none'); @@ -3783,6 +3837,7 @@ export class PluginModalStepper { const isMsGraphVisible = !msGraphSection.classList.contains('d-none'); const isAzureMapsVisible = !azureMapsSection.classList.contains('d-none'); const isChartVisible = !chartSection.classList.contains('d-none'); + const isLogAnalyticsVisible = !!logAnalyticsSection && !logAnalyticsSection.classList.contains('d-none'); if (isOpenApiVisible) { // Validate OpenAPI fields @@ -4283,6 +4338,38 @@ export class PluginModalStepper { this.showError('Azure Maps subscription key is required.'); return false; } + } else if (isLogAnalyticsVisible) { + const workspaceId = document.getElementById('log-analytics-workspace-id').value.trim(); + if (!workspaceId) { + this.showError('Log Analytics workspace ID is required.'); + return false; + } + + const cloud = document.getElementById('log-analytics-cloud').value; + if (cloud === 'custom') { + const authorityHost = document.getElementById('log-analytics-authority-host').value.trim(); + const endpointOverride = document.getElementById('log-analytics-endpoint-override').value.trim(); + if (!authorityHost || !endpointOverride) { + this.showError('Authority host and endpoint override are required for the custom cloud.'); + return false; + } + } + + const selectedLogAnalyticsIdentity = this.getSelectedActionIdentity('logAnalytics'); + const logAnalyticsAuthMethod = document.getElementById('log-analytics-auth-method').value; + if (!selectedLogAnalyticsIdentity && logAnalyticsAuthMethod === 'servicePrincipal') { + const clientId = document.getElementById('log-analytics-auth-identity').value.trim(); + const clientSecret = document.getElementById('log-analytics-auth-key').value.trim(); + const tenantId = document.getElementById('log-analytics-auth-tenant-id').value.trim(); + if (!clientId || !clientSecret || !tenantId) { + this.showError('Client ID, client secret, and tenant ID are required for service principal authentication.'); + return false; + } + } + if (!selectedLogAnalyticsIdentity && logAnalyticsAuthMethod === 'key' && !document.getElementById('log-analytics-auth-key').value.trim()) { + this.showError('A key is required for key authentication.'); + return false; + } } else if (isChartVisible) { const capabilityValues = Object.values(this.getSelectedChartCapabilities()); if (!capabilityValues.some(Boolean)) { @@ -5095,6 +5182,506 @@ export class PluginModalStepper { } } + getOpenApiAuthConfiguration() { + const selectedIdentity = this.getSelectedActionIdentity('openapi'); + const authType = document.getElementById('plugin-auth-type')?.value || 'none'; + const auth = {}; + const additionalFields = {}; + let identityId = ''; + + if (selectedIdentity) { + identityId = selectedIdentity.id || selectedIdentity.identity_id || ''; + auth.type = 'identity'; + auth.identity = identityId; + additionalFields.identity_auth_type = this.getIdentityAuthType(selectedIdentity); + additionalFields.auth_method = 'identity'; + } else if (authType === 'api_key') { + auth.type = 'key'; + auth.key = document.getElementById('plugin-auth-api-key-value')?.value.trim() || ''; + additionalFields.api_key_location = document.getElementById('plugin-auth-api-key-location')?.value || 'header'; + additionalFields.api_key_name = document.getElementById('plugin-auth-api-key-name')?.value.trim() || ''; + } else if (authType === 'bearer') { + auth.type = 'key'; + auth.key = document.getElementById('plugin-auth-bearer-token')?.value.trim() || ''; + additionalFields.auth_method = 'bearer'; + } else if (authType === 'basic') { + auth.type = 'key'; + const username = document.getElementById('plugin-auth-basic-username')?.value.trim() || ''; + const password = document.getElementById('plugin-auth-basic-password')?.value.trim() || ''; + auth.key = `${username}:${password}`; + additionalFields.auth_method = 'basic'; + } else if (authType === 'oauth2') { + auth.type = 'key'; + auth.key = document.getElementById('plugin-auth-oauth2-token')?.value.trim() || ''; + additionalFields.auth_method = 'oauth2'; + } else if (authType === 'none') { + auth.type = 'key'; + auth.key = ''; + additionalFields.auth_method = 'none'; + } + + return { auth, additionalFields, identityId }; + } + + getAzureMapsConfiguration() { + return { + endpoint: AZURE_MAPS_DEFAULT_ENDPOINT, + auth: { + type: 'key', + key: document.getElementById('azure-maps-key')?.value.trim() || '' + }, + additionalFields: {}, + identityId: '' + }; + } + + getBlobStorageConfiguration() { + const connectionString = document.getElementById('blob-storage-connection-string')?.value.trim() || ''; + const containerName = document.getElementById('blob-storage-container-name')?.value.trim() || ''; + const blobPrefix = this.normalizeBlobStoragePrefix(document.getElementById('blob-storage-blob-prefix')?.value || ''); + + const additionalFields = { + container_name: containerName, + blob_storage_capabilities: this.getSelectedBlobStorageCapabilities(), + blob_storage_read_file_types: this.getSelectedBlobStorageReadFileTypes(), + blob_storage_upload_file_types: this.getSelectedBlobStorageUploadFileTypes() + }; + if (blobPrefix) { + additionalFields.blob_prefix = blobPrefix; + } + + return { + endpoint: this.deriveBlobStorageEndpointFromConnectionString(connectionString) || this.originalPlugin?.endpoint || '', + auth: { type: 'connection_string', key: connectionString }, + additionalFields, + identityId: '' + }; + } + + getLogAnalyticsIdentityAuthMethod(identity) { + return this.getIdentityAuthType(identity) === 'client_secret' ? 'servicePrincipal' : 'identity'; + } + + initializeLogAnalyticsConfiguration() { + const cloudField = document.getElementById('log-analytics-cloud'); + if (cloudField && !cloudField.value) { + cloudField.value = LOG_ANALYTICS_DEFAULT_CLOUD; + } + const endpointField = document.getElementById('log-analytics-endpoint'); + if (endpointField) { + endpointField.placeholder = LOG_ANALYTICS_DEFAULT_ENDPOINT; + } + + this.clearActionConnectionTestResult('logAnalytics'); + this.handleLogAnalyticsCloudChange(); + this.toggleLogAnalyticsAuthFields(); + } + + handleLogAnalyticsCloudChange() { + const cloud = document.getElementById('log-analytics-cloud')?.value || LOG_ANALYTICS_DEFAULT_CLOUD; + const customGroup = document.getElementById('log-analytics-custom-cloud-group'); + if (customGroup) { + customGroup.classList.toggle('d-none', cloud !== 'custom'); + } + } + + toggleLogAnalyticsAuthFields() { + const selectedIdentity = this.getSelectedActionIdentity('logAnalytics'); + const authMethod = selectedIdentity + ? this.getLogAnalyticsIdentityAuthMethod(selectedIdentity) + : (document.getElementById('log-analytics-auth-method')?.value || 'identity'); + + const identityGroup = document.getElementById('log-analytics-auth-identity-group'); + const keyGroup = document.getElementById('log-analytics-auth-key-group'); + const tenantGroup = document.getElementById('log-analytics-auth-tenant-id-group'); + const identityLabel = document.getElementById('log-analytics-auth-identity-label'); + const keyLabel = document.getElementById('log-analytics-auth-key-label'); + + const showIdentity = !selectedIdentity && ['identity', 'servicePrincipal'].includes(authMethod); + const showKey = !selectedIdentity && ['servicePrincipal', 'key'].includes(authMethod); + const showTenant = !selectedIdentity && authMethod === 'servicePrincipal'; + + if (identityGroup) identityGroup.classList.toggle('d-none', !showIdentity); + if (keyGroup) keyGroup.classList.toggle('d-none', !showKey); + if (tenantGroup) tenantGroup.classList.toggle('d-none', !showTenant); + + if (identityLabel) { + identityLabel.textContent = authMethod === 'servicePrincipal' ? 'Client ID' : 'Managed Identity Client ID'; + } + if (keyLabel) { + keyLabel.textContent = authMethod === 'servicePrincipal' ? 'Client Secret' : 'Key'; + } + + this.updateLogAnalyticsAuthInfo(authMethod, selectedIdentity); + } + + updateLogAnalyticsAuthInfo(authMethod, selectedIdentity) { + const infoDiv = document.getElementById('log-analytics-auth-info'); + const infoText = document.getElementById('log-analytics-auth-info-text'); + if (!infoDiv || !infoText) { + return; + } + + let message = 'Managed Identity uses the application identity. Grant it the Log Analytics Reader role on the target workspace.'; + if (selectedIdentity) { + message = 'Credentials come from the selected reusable identity. Action-specific fields are ignored.'; + } else if (authMethod === 'servicePrincipal') { + message = 'Service Principal authentication needs a client ID, client secret, and tenant ID with Log Analytics Reader access to the workspace.'; + } else if (authMethod === 'user') { + message = 'On-Behalf-Of User authentication runs queries as the signed-in user, so results respect that user\'s workspace permissions.'; + } else if (authMethod === 'key') { + message = 'Key authentication is only supported by custom Log Analytics-compatible endpoints.'; + } + + infoText.textContent = message; + infoDiv.classList.remove('d-none'); + } + + getLogAnalyticsConfiguration() { + const selectedIdentity = this.getSelectedActionIdentity('logAnalytics'); + const authMethod = selectedIdentity + ? this.getLogAnalyticsIdentityAuthMethod(selectedIdentity) + : (document.getElementById('log-analytics-auth-method')?.value || 'identity'); + const cloud = document.getElementById('log-analytics-cloud')?.value || LOG_ANALYTICS_DEFAULT_CLOUD; + + // Preserve stored fields such as query_history that Step 3 does not surface. + const storedFields = this.originalPlugin?.additionalFields; + const additionalFields = (storedFields && typeof storedFields === 'object' && !Array.isArray(storedFields)) + ? { ...storedFields } + : {}; + delete additionalFields.authorityHost; + delete additionalFields.endpointOverride; + delete additionalFields.identity_auth_type; + + additionalFields.workspaceId = document.getElementById('log-analytics-workspace-id')?.value.trim() || ''; + additionalFields.cloud = cloud; + if (!Array.isArray(additionalFields.query_history)) { + additionalFields.query_history = []; + } + if (cloud === 'custom') { + additionalFields.authorityHost = document.getElementById('log-analytics-authority-host')?.value.trim() || ''; + additionalFields.endpointOverride = document.getElementById('log-analytics-endpoint-override')?.value.trim() || ''; + } + + const auth = {}; + let identityId = ''; + + if (selectedIdentity) { + identityId = selectedIdentity.id || selectedIdentity.identity_id || ''; + auth.type = 'identity'; + auth.identity = identityId; + additionalFields.identity_auth_type = this.getIdentityAuthType(selectedIdentity); + } else if (authMethod === 'servicePrincipal') { + auth.type = 'servicePrincipal'; + auth.identity = document.getElementById('log-analytics-auth-identity')?.value.trim() || ''; + auth.key = document.getElementById('log-analytics-auth-key')?.value.trim() || ''; + auth.tenantId = document.getElementById('log-analytics-auth-tenant-id')?.value.trim() || ''; + } else if (authMethod === 'key') { + auth.type = 'key'; + auth.key = document.getElementById('log-analytics-auth-key')?.value.trim() || ''; + } else if (authMethod === 'user') { + auth.type = 'user'; + } else { + auth.type = 'identity'; + const managedIdentityClientId = document.getElementById('log-analytics-auth-identity')?.value.trim() || ''; + if (managedIdentityClientId) { + auth.identity = managedIdentityClientId; + } + } + + const endpoint = document.getElementById('log-analytics-endpoint')?.value.trim() + || (cloud === 'custom' ? additionalFields.endpointOverride : '') + || LOG_ANALYTICS_DEFAULT_ENDPOINT; + + return { endpoint, auth, additionalFields, identityId }; + } + + populateLogAnalyticsConfiguration(plugin) { + const additionalFields = (plugin?.additionalFields && typeof plugin.additionalFields === 'object') + ? plugin.additionalFields + : {}; + const auth = (plugin?.auth && typeof plugin.auth === 'object') ? plugin.auth : {}; + + const workspaceField = document.getElementById('log-analytics-workspace-id'); + if (workspaceField) { + workspaceField.value = additionalFields.workspaceId || ''; + } + const cloudField = document.getElementById('log-analytics-cloud'); + if (cloudField) { + cloudField.value = additionalFields.cloud || LOG_ANALYTICS_DEFAULT_CLOUD; + } + const endpointField = document.getElementById('log-analytics-endpoint'); + if (endpointField) { + endpointField.value = plugin?.endpoint && plugin.endpoint !== LOG_ANALYTICS_DEFAULT_ENDPOINT ? plugin.endpoint : ''; + } + const authorityHostField = document.getElementById('log-analytics-authority-host'); + if (authorityHostField) { + authorityHostField.value = additionalFields.authorityHost || ''; + } + const endpointOverrideField = document.getElementById('log-analytics-endpoint-override'); + if (endpointOverrideField) { + endpointOverrideField.value = additionalFields.endpointOverride || ''; + } + + const authType = String(auth.type || 'identity'); + const authMethodField = document.getElementById('log-analytics-auth-method'); + if (authMethodField && !plugin?.identity_id) { + authMethodField.value = ['identity', 'user', 'servicePrincipal', 'key'].includes(authType) ? authType : 'identity'; + } + + const authIdentityField = document.getElementById('log-analytics-auth-identity'); + if (authIdentityField) { + authIdentityField.value = auth.identity && auth.identity !== 'managed_identity' ? auth.identity : ''; + } + const authKeyField = document.getElementById('log-analytics-auth-key'); + if (authKeyField) { + authKeyField.value = auth.key || ''; + } + const authTenantField = document.getElementById('log-analytics-auth-tenant-id'); + if (authTenantField) { + authTenantField.value = auth.tenantId || ''; + } + + if (plugin?.identity_id) { + this.setSelectedActionIdentity('logAnalytics', plugin.identity_id); + } + + this.handleLogAnalyticsCloudChange(); + this.toggleLogAnalyticsAuthFields(); + } + + getActionConnectionTestElements(testKey) { + const config = ACTION_CONNECTION_TEST_CONFIG[testKey]; + if (!config) { + return null; + } + return { + config, + button: document.getElementById(`${config.idPrefix}-test-connection-btn`), + result: document.getElementById(`${config.idPrefix}-test-connection-result`), + alert: document.getElementById(`${config.idPrefix}-test-connection-alert`) + }; + } + + clearActionConnectionTestResult(testKey) { + const elements = this.getActionConnectionTestElements(testKey); + if (!elements) { + return; + } + if (elements.result) { + elements.result.classList.add('d-none'); + } + if (elements.alert) { + elements.alert.replaceChildren(); + } + } + + showActionConnectionTestMessage(testKey, variant, message, iconClass = '') { + const elements = this.getActionConnectionTestElements(testKey); + if (!elements || !elements.result || !elements.alert) { + return; + } + + elements.alert.className = `alert alert-${variant} mb-0 py-2 px-3 small`; + elements.alert.replaceChildren(); + if (iconClass) { + const icon = document.createElement('i'); + icon.className = `${iconClass} me-2`; + elements.alert.appendChild(icon); + } + elements.alert.appendChild(document.createTextNode(message)); + elements.result.classList.remove('d-none'); + } + + buildActionConnectionTestConfig(testKey) { + if (testKey === 'openapi') { + const endpoint = document.getElementById('plugin-endpoint')?.value.trim() || ''; + if (!endpoint) { + throw new Error('Enter the API base URL before testing the connection.'); + } + + const openApiAuthConfig = this.getOpenApiAuthConfiguration(); + const additionalFields = { ...openApiAuthConfig.additionalFields, base_url: endpoint }; + const specContent = document.getElementById('plugin-openapi-file')?.dataset.specContent || ''; + if (specContent) { + try { + additionalFields.openapi_spec_content = JSON.parse(specContent); + additionalFields.openapi_source_type = 'content'; + } catch (error) { + throw new Error('The uploaded OpenAPI specification could not be parsed.'); + } + } else if (!this.isEditMode) { + throw new Error('Upload an OpenAPI specification file before testing the connection.'); + } + + return { + endpoint, + auth: openApiAuthConfig.auth, + additionalFields, + identityId: openApiAuthConfig.identityId + }; + } + + if (testKey === 'azureMaps') { + const azureMapsConfig = this.getAzureMapsConfiguration(); + if (!azureMapsConfig.auth.key && !this.isEditMode) { + throw new Error('Enter the Azure Maps subscription key before testing the connection.'); + } + return azureMapsConfig; + } + + if (testKey === 'blobStorage') { + const blobStorageConfig = this.getBlobStorageConfiguration(); + if (!blobStorageConfig.additionalFields.container_name) { + throw new Error('Enter the container name before testing the connection.'); + } + if (!blobStorageConfig.auth.key && !this.isEditMode) { + throw new Error('Enter the storage connection string before testing the connection.'); + } + return blobStorageConfig; + } + + if (testKey === 'databricks') { + const databricksConfig = this.getDatabricksConfiguration(); + if (!databricksConfig.endpoint) { + throw new Error('Enter the Databricks workspace URL before testing the connection.'); + } + if (!databricksConfig.additionalFields.warehouse_id) { + throw new Error('Enter the SQL warehouse ID before testing the connection.'); + } + return databricksConfig; + } + + if (testKey === 'logAnalytics') { + const logAnalyticsConfig = this.getLogAnalyticsConfiguration(); + if (!logAnalyticsConfig.additionalFields.workspaceId) { + throw new Error('Enter the Log Analytics workspace ID before testing the connection.'); + } + return logAnalyticsConfig; + } + + if (testKey === 'mcp') { + const mcpConfig = this.getMcpConfiguration(); + const transport = mcpConfig.additionalFields?.transport; + if (transport !== 'stdio' && !mcpConfig.endpoint) { + throw new Error('Enter the MCP server endpoint before testing the connection.'); + } + return mcpConfig; + } + + if (testKey === 'snowflake') { + const snowflakeConfig = this.getSnowflakeConfiguration(); + if (!snowflakeConfig.additionalFields.account) { + throw new Error('Enter the Snowflake account identifier before testing the connection.'); + } + if (!snowflakeConfig.additionalFields.warehouse) { + throw new Error('Enter the Snowflake warehouse before testing the connection.'); + } + return snowflakeConfig; + } + + if (testKey === 'tableau') { + const tableauConfig = this.getTableauConfiguration(); + if (!tableauConfig.endpoint) { + throw new Error('Enter the Tableau server URL before testing the connection.'); + } + return tableauConfig; + } + + throw new Error('This action type does not support connection testing.'); + } + + buildActionConnectionTestPayload(testKey) { + const typeConfig = this.buildActionConnectionTestConfig(testKey); + const payload = { + name: document.getElementById('plugin-name')?.value.trim() || '', + displayName: document.getElementById('plugin-display-name')?.value.trim() || '', + description: document.getElementById('plugin-description')?.value.trim() || '', + type: this.selectedType || '', + endpoint: typeConfig.endpoint || '', + auth: typeConfig.auth || {}, + metadata: {}, + additionalFields: typeConfig.additionalFields || {}, + action_scope: this.actionIdentityScope?.scope || 'personal' + }; + + if (typeConfig.identityId) { + payload.identity_id = typeConfig.identityId; + } + + const pluginContext = this.getTestPluginContext(); + if (pluginContext) { + payload.plugin_context = pluginContext; + } + + return payload; + } + + async runActionConnectionTest(testKey) { + const elements = this.getActionConnectionTestElements(testKey); + if (!elements || !elements.button) { + return; + } + + let payload; + try { + payload = this.buildActionConnectionTestPayload(testKey); + } catch (error) { + this.showActionConnectionTestMessage( + testKey, + 'warning', + error.message || 'Complete the required configuration fields before testing the connection.', + 'bi bi-exclamation-triangle' + ); + return; + } + + const originalChildNodes = Array.from(elements.button.childNodes); + elements.button.disabled = true; + elements.button.replaceChildren(); + const spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm me-2'; + spinner.setAttribute('role', 'status'); + spinner.setAttribute('aria-hidden', 'true'); + elements.button.append(spinner, document.createTextNode('Testing...')); + this.clearActionConnectionTestResult(testKey); + + try { + const response = await fetch(elements.config.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => ({})); + + if (response.ok && data.success) { + this.showActionConnectionTestMessage( + testKey, + 'success', + data.message || 'Connection successful.', + 'bi bi-check-circle' + ); + } else { + this.showActionConnectionTestMessage( + testKey, + 'danger', + data.error || `The ${elements.config.label} connection test failed.`, + 'bi bi-x-circle' + ); + } + } catch (error) { + this.showActionConnectionTestMessage( + testKey, + 'danger', + `Test failed: ${error.message || 'Network error'}`, + 'bi bi-x-circle' + ); + } finally { + elements.button.replaceChildren(...originalChildNodes); + elements.button.disabled = false; + } + } + initializeSqlConfiguration() { // Set default values document.getElementById('sql-read-only').value = 'true'; @@ -5692,6 +6279,8 @@ export class PluginModalStepper { } else if (this.isAzureMapsType(plugin.type)) { const auth = plugin.auth || {}; document.getElementById('azure-maps-key').value = auth.key || ''; + } else if (this.isLogAnalyticsType(plugin.type)) { + this.populateLogAnalyticsConfiguration(plugin); } else if (this.isChartType(plugin.type)) { const additionalFields = plugin.additionalFields || plugin.additional_fields || {}; this.setChartCapabilities(additionalFields.chart_capabilities || plugin.chart_capabilities || null); @@ -5740,6 +6329,7 @@ export class PluginModalStepper { const yamcsSection = document.getElementById('yamcs-config-section'); const mcpSection = document.getElementById('mcp-config-section'); const azureMapsSection = document.getElementById('azure-maps-config-section'); + const logAnalyticsSection = document.getElementById('log-analytics-config-section'); const isOpenApiVisible = !openApiSection.classList.contains('d-none'); const isSqlVisible = !sqlSection.classList.contains('d-none'); const isCosmosVisible = !cosmosSection.classList.contains('d-none'); @@ -5752,6 +6342,7 @@ export class PluginModalStepper { const isYamcsVisible = !yamcsSection.classList.contains('d-none'); const isMcpVisible = !mcpSection.classList.contains('d-none'); const isAzureMapsVisible = !azureMapsSection.classList.contains('d-none'); + const isLogAnalyticsVisible = !!logAnalyticsSection && !logAnalyticsSection.classList.contains('d-none'); let auth = {}; let endpoint = ''; @@ -5778,45 +6369,10 @@ export class PluginModalStepper { additionalFields.openapi_source_type = 'content'; // Changed from 'file' additionalFields.base_url = endpoint; - const selectedIdentity = this.getSelectedActionIdentity('openapi'); - const authType = document.getElementById('plugin-auth-type').value; - - if (selectedIdentity) { - identityId = selectedIdentity.id || selectedIdentity.identity_id || ''; - auth.type = 'identity'; - auth.identity = identityId; - additionalFields.identity_auth_type = this.getIdentityAuthType(selectedIdentity); - additionalFields.auth_method = 'identity'; - } else if (authType === 'api_key') { - // Map api_key to 'key' type for schema compliance - auth.type = 'key'; - const apiKeyValue = document.getElementById('plugin-auth-api-key-value').value.trim(); - auth.key = apiKeyValue; - - // Store API key configuration details in additionalFields instead of auth object - const apiKeyLocation = document.getElementById('plugin-auth-api-key-location').value; - const apiKeyName = document.getElementById('plugin-auth-api-key-name').value.trim(); - additionalFields.api_key_location = apiKeyLocation; - additionalFields.api_key_name = apiKeyName; - } else if (authType === 'bearer') { - auth.type = 'key'; // Bearer tokens are also 'key' type in the schema - auth.key = document.getElementById('plugin-auth-bearer-token').value.trim(); - additionalFields.auth_method = 'bearer'; - } else if (authType === 'basic') { - auth.type = 'key'; // Basic auth is also 'key' type in the schema - const username = document.getElementById('plugin-auth-basic-username').value.trim(); - const password = document.getElementById('plugin-auth-basic-password').value.trim(); - auth.key = `${username}:${password}`; // Store as combined string - additionalFields.auth_method = 'basic'; - } else if (authType === 'oauth2') { - auth.type = 'key'; // OAuth2 is also 'key' type in the schema - auth.key = document.getElementById('plugin-auth-oauth2-token').value.trim(); - additionalFields.auth_method = 'oauth2'; - } else if (authType === 'none') { - auth.type = 'key'; - auth.key = ''; // Empty key for no auth - additionalFields.auth_method = 'none'; - } + const openApiAuthConfig = this.getOpenApiAuthConfiguration(); + auth = openApiAuthConfig.auth; + identityId = openApiAuthConfig.identityId; + Object.assign(additionalFields, openApiAuthConfig.additionalFields); } else if (isSqlVisible) { // Collect SQL plugin data const databaseType = document.querySelector('input[name="sql-database-type"]:checked')?.value; @@ -5990,20 +6546,10 @@ export class PluginModalStepper { auth.type = 'NoAuth'; additionalFields = this.getDocumentSearchAdditionalFields(); } else if (isBlobStorageVisible) { - const connectionString = document.getElementById('blob-storage-connection-string').value.trim(); - const containerName = document.getElementById('blob-storage-container-name').value.trim(); - const blobPrefix = this.normalizeBlobStoragePrefix(document.getElementById('blob-storage-blob-prefix').value); - - auth.type = 'connection_string'; - auth.key = connectionString; - endpoint = this.deriveBlobStorageEndpointFromConnectionString(connectionString) || this.originalPlugin?.endpoint || ''; - additionalFields.container_name = containerName; - if (blobPrefix) { - additionalFields.blob_prefix = blobPrefix; - } - additionalFields.blob_storage_capabilities = this.getSelectedBlobStorageCapabilities(); - additionalFields.blob_storage_read_file_types = this.getSelectedBlobStorageReadFileTypes(); - additionalFields.blob_storage_upload_file_types = this.getSelectedBlobStorageUploadFileTypes(); + const blobStorageConfig = this.getBlobStorageConfiguration(); + endpoint = blobStorageConfig.endpoint; + auth = blobStorageConfig.auth; + additionalFields = blobStorageConfig.additionalFields; } else if (isDatabricksVisible) { const databricksConfig = this.getDatabricksConfiguration(); endpoint = databricksConfig.endpoint; @@ -6045,9 +6591,15 @@ export class PluginModalStepper { Object.assign(additionalFields, this.getMsGraphMailSendConfiguration()); Object.assign(additionalFields, this.getMsGraphCalendarSendConfiguration()); } else if (isAzureMapsVisible) { - endpoint = AZURE_MAPS_DEFAULT_ENDPOINT; - auth.type = 'key'; - auth.key = document.getElementById('azure-maps-key').value.trim(); + const azureMapsConfig = this.getAzureMapsConfiguration(); + endpoint = azureMapsConfig.endpoint; + auth = azureMapsConfig.auth; + } else if (isLogAnalyticsVisible) { + const logAnalyticsConfig = this.getLogAnalyticsConfiguration(); + endpoint = logAnalyticsConfig.endpoint; + auth = logAnalyticsConfig.auth; + additionalFields = logAnalyticsConfig.additionalFields; + identityId = logAnalyticsConfig.identityId; } else if (this.isChartType()) { endpoint = CHART_DEFAULT_ENDPOINT; auth.type = 'user'; @@ -6157,6 +6709,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); const endpointRow = document.getElementById('summary-plugin-endpoint-row'); const databaseTypeRow = document.getElementById('summary-plugin-database-type-row'); @@ -6223,6 +6776,12 @@ export class PluginModalStepper { endpointRow.style.display = 'none'; document.getElementById('summary-plugin-database-type').textContent = 'Azure Maps tile proxy'; databaseTypeRow.style.display = ''; + } else if (isLogAnalyticsType) { + const endpoint = this.getEndpointValue(); + document.getElementById('summary-plugin-endpoint').textContent = endpoint || '-'; + endpointRow.style.display = ''; + document.getElementById('summary-plugin-database-type').textContent = 'Azure Log Analytics workspace'; + databaseTypeRow.style.display = ''; } else if (isChartType) { endpointRow.style.display = 'none'; document.getElementById('summary-plugin-database-type').textContent = 'Built-in chart action'; @@ -6249,10 +6808,10 @@ export class PluginModalStepper { } const databaseType = this.getSqlDatabaseType(); - if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && databaseType) { + if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && !isLogAnalyticsType && databaseType) { document.getElementById('summary-plugin-database-type').textContent = databaseType; databaseTypeRow.style.display = ''; - } else if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType) { + } else if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && !isLogAnalyticsType) { databaseTypeRow.style.display = 'none'; } @@ -6290,6 +6849,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); if (isOpenApiType) { return document.getElementById('plugin-endpoint').value.trim(); @@ -6317,6 +6877,8 @@ export class PluginModalStepper { return transport === 'stdio' ? MCP_STDIO_ENDPOINT : document.getElementById('mcp-endpoint').value.trim(); } else if (isAzureMapsType) { return AZURE_MAPS_DEFAULT_ENDPOINT; + } else if (isLogAnalyticsType) { + return this.getLogAnalyticsConfiguration().endpoint; } else if (isMsGraphType) { return MSGRAPH_DEFAULT_ENDPOINT; } else if (isChartType) { @@ -6341,6 +6903,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); if (isOpenApiType) { const authType = document.getElementById('plugin-auth-type').value; @@ -6388,6 +6951,11 @@ export class PluginModalStepper { return 'User'; } else if (isAzureMapsType) { return 'Subscription Key'; + } else if (isLogAnalyticsType) { + if (this.getSelectedActionIdentity('logAnalytics')) { + return 'Reusable Identity'; + } + return this.formatAuthType(document.getElementById('log-analytics-auth-method')?.value || 'identity'); } else if (isChartType) { return 'User'; } else { @@ -6947,6 +7515,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); if (isOpenApiType) { currentEndpoint = document.getElementById('plugin-endpoint')?.value || ''; @@ -6972,6 +7541,8 @@ export class PluginModalStepper { currentEndpoint = MSGRAPH_DEFAULT_ENDPOINT; } else if (isAzureMapsType) { currentEndpoint = AZURE_MAPS_DEFAULT_ENDPOINT; + } else if (isLogAnalyticsType) { + currentEndpoint = this.getLogAnalyticsConfiguration().endpoint; } else if (isChartType) { currentEndpoint = CHART_DEFAULT_ENDPOINT; } else { @@ -7044,6 +7615,12 @@ export class PluginModalStepper { } else if (isAzureMapsType) { currentAuthType = 'key'; currentAuthKey = document.getElementById('azure-maps-key')?.value || ''; + } else if (isLogAnalyticsType) { + const selectedIdentity = this.getSelectedActionIdentity('logAnalytics'); + currentAuthType = selectedIdentity ? 'identity' : (document.getElementById('log-analytics-auth-method')?.value || 'identity'); + if (!selectedIdentity && ['servicePrincipal', 'key'].includes(currentAuthType)) { + currentAuthKey = document.getElementById('log-analytics-auth-key')?.value || ''; + } } else if (isChartType) { currentAuthType = 'user'; } else { @@ -7086,6 +7663,8 @@ export class PluginModalStepper { }, null, 2); } else if (isAzureMapsType) { currentAdditionalFields = '{}'; + } else if (isLogAnalyticsType) { + currentAdditionalFields = JSON.stringify(this.getLogAnalyticsConfiguration().additionalFields, null, 2); } else if (isChartType) { currentAdditionalFields = JSON.stringify({ chart_capabilities: this.getSelectedChartCapabilities() @@ -7368,6 +7947,18 @@ export class PluginModalStepper { safeSetValue('plugin-auth-oauth2-token-generic'); safeSetValue('azure-maps-key'); + // Step 3 fields - Log Analytics Plugin + safeSetValue('log-analytics-workspace-id'); + safeSetValue('log-analytics-cloud', LOG_ANALYTICS_DEFAULT_CLOUD); + safeSetValue('log-analytics-endpoint'); + safeSetValue('log-analytics-authority-host'); + safeSetValue('log-analytics-endpoint-override'); + safeSetValue('log-analytics-identity-select'); + safeSetValue('log-analytics-auth-method', 'identity'); + safeSetValue('log-analytics-auth-identity'); + safeSetValue('log-analytics-auth-key'); + safeSetValue('log-analytics-auth-tenant-id'); + // Step 3 fields - MCP Plugin safeSetValue('mcp-preconfiguration'); safeSetValue('mcp-server-profile', this.mcpDefaultServerPreset || MCP_DEFAULT_SERVER_PROFILE); diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 74988d498..8cbe78851 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -166,6 +166,17 @@
API Information
+ +
+
+ +
+
+ +
+
@@ -301,6 +312,17 @@
API Information
+ +
+
+ +
+
+ +
+
@@ -397,6 +419,17 @@
API Information
+ +
+
+ +
+
+ +
+
@@ -483,6 +516,17 @@
API Information
+ +
+
+ +
+
+ +
+
@@ -824,6 +868,17 @@
API Information
+ +
+
+ +
+
+ +
+
@@ -844,6 +899,105 @@
API Information
+ +
+
+ +
+
+ +
+
+ + +
+
+ Log Analytics action: Configure one Azure Log Analytics workspace so agents can discover table schemas and run read-only KQL queries. +
+ +
+ +
+ + +
The Log Analytics workspace ID (GUID), not the workspace resource ID.
+
+
+
+ + +
+
+ + +
Optional. Leave blank to use the default endpoint for the selected cloud.
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
@@ -994,6 +1148,17 @@
API Information
+ +
+
+ +
+
+ +
+
diff --git a/docs/explanation/features/ACTION_TEST_CONNECTION.md b/docs/explanation/features/ACTION_TEST_CONNECTION.md new file mode 100644 index 000000000..864db3367 --- /dev/null +++ b/docs/explanation/features/ACTION_TEST_CONNECTION.md @@ -0,0 +1,199 @@ +# Action Test Connection + +## Overview + +Configurable SimpleChat actions can now be validated from the action modal before they are saved. A **Test Connection** button authenticates with the credentials entered in Step 3 and performs one lightweight read against the configured resource, so a wrong warehouse ID, container name, subscription key, personal access token, or MCP endpoint is caught immediately instead of surfacing as a tool failure during a chat. + +This extends the pattern already used by SQL, Cosmos DB, Yamcs, and RocksDB actions to eight more action types. + +**Implemented in version:** **0.250.217** + +**Related issue:** [microsoft/simplechat#1267](https://github.com/microsoft/simplechat/issues/1267) + +### Supported action types + +| Action type | Manifest type | Endpoint | +| --- | --- | --- | +| OpenAPI | `openapi` | `POST /api/plugins/test-openapi-connection` | +| Azure Maps (OpenLayers) | `azure_maps_openlayers` | `POST /api/plugins/test-azure-maps-connection` | +| Blob Storage | `blob_storage` | `POST /api/plugins/test-blob-storage-connection` | +| Databricks | `databricks` | `POST /api/plugins/test-databricks-connection` | +| Log Analytics | `log_analytics` | `POST /api/plugins/test-log-analytics-connection` | +| MCP | `mcp` | `POST /api/plugins/test-mcp-connection` | +| Snowflake | `snowflake` | `POST /api/plugins/test-snowflake-connection` | +| Tableau | `tableau` | `POST /api/plugins/test-tableau-connection` | + +SQL, Cosmos DB, Yamcs, and RocksDB actions keep their existing `POST /api/plugins/test-sql-connection`, `POST /api/plugins/test-cosmos-connection`, `POST /api/plugins/test-yamcs-connection`, and `POST /api/plugins/test-rocksdb-connection` routes, which are unchanged apart from the secret-scoping hardening described below. + +### Dependencies + +Connection testing reuses the packages the actions already need at runtime: + +- `requests` (OpenAPI, Azure Maps, Databricks) +- `azure-storage-blob`, `azure-identity` (Blob Storage) +- `azure-monitor-query` (Log Analytics) +- `mcp` connectors via `McpPluginFactory` (MCP) +- `snowflake-connector-python` (Snowflake) +- `tableauserverclient` (Tableau) + +If an optional package is missing from the container image, the test returns HTTP 501 with a message naming the package to install instead of a generic 500. + +## Technical specifications + +### Architecture + +``` +Action modal (Step 3) + └── plugin_modal_stepper.js + ├── ACTION_CONNECTION_TEST_CONFIG (test key -> button prefix + route) + ├── buildActionConnectionTestConfig (per-type field collection + pre-checks) + ├── buildActionConnectionTestPayload(manifest + scope + plugin context) + └── runActionConnectionTest (spinner, fetch, Bootstrap alert) + │ + ▼ +route_backend_plugins.py + ├── _prepare_action_test_manifest (load stored action, resolve scope, rehydrate + │ masked secrets, hydrate reusable identities, + │ validate the manifest) + └── _run_action_connection_test (error mapping + JSON response shaping) + │ + ▼ +functions_action_connection_tests.py + └── one tester per action type, each returning + {success, message | error, status, details} +``` + +Routes stay thin: they only prepare and validate the transient manifest, then delegate to the matching tester. All outbound work and error sanitization lives in `functions_action_connection_tests.py`. + +### What each test verifies + +| Action type | Verification performed | +| --- | --- | +| OpenAPI | Loads the stored specification through `OpenApiPluginFactory`, reports the operation count, then issues an authenticated `GET` to the base URL. HTTP 401/403 is reported as an authentication problem; any other response confirms reachability. | +| Azure Maps | Requests tile `0/0/0` of `microsoft.base.road` from `{endpoint}/map/tile` with the subscription key and confirms an image response. | +| Blob Storage | Builds the `BlobServiceClient`, reads container properties, then lists one blob under the configured prefix to prove list permission. | +| Databricks | Calls `GET {workspace_url}/api/2.0/sql/warehouses/{warehouse_id}` with the resolved token and reports the warehouse name and state. | +| Log Analytics | Runs `print TestConnection = 1` against the workspace through `LogsQueryClient.query_workspace`. | +| MCP | Runs the same `McpPluginFactory.probe_server_from_config` probe used by Discover Tools and reports transport, auth method, tool count, and any warnings. The cached tool metadata is **not** overwritten. | +| Snowflake | Opens a connection and runs `SELECT CURRENT_VERSION()`, then closes the cursor and connection. | +| Tableau | Signs in to the configured server and site and reports the negotiated API version. | + +Every outbound call is capped at `ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS` (20 seconds). + +### Request payload + +```json +{ + "name": "commercial_databricks_sql", + "displayName": "Commercial Databricks SQL", + "description": "Read-only Databricks SQL tools", + "type": "databricks", + "endpoint": "https://adb-1234567890123456.7.azuredatabricks.net", + "auth": { "type": "key", "key": "..." }, + "metadata": {}, + "additionalFields": { "warehouse_id": "warehouse-123", "cloud": "azure_commercial" }, + "action_scope": "personal", + "identity_id": "optional-reusable-identity-id", + "plugin_context": { "scope": "user", "id": "action-id", "name": "action_name" } +} +``` + +`action_scope` is one of `personal`, `group`, or `global`. `plugin_context` is only sent when editing an existing action; it lets the backend resolve masked secrets from storage. + +### Response payload + +```json +{ + "success": true, + "message": "Connected to Databricks SQL warehouse 'Analytics' (state: RUNNING).", + "details": { "warehouse_id": "warehouse-123", "warehouse_state": "RUNNING" } +} +``` + +Failures return `{"success": false, "error": "..."}` with an HTTP status that reflects the cause: `400` for configuration problems, `403` for authentication or authorization failures, `404` for a missing resource, `501` for a missing optional package, and `502` for transport failures. + +### Security + +- **No credential ever round-trips.** `sanitize_connection_error` removes every literal secret present in the manifest (`auth.key`, `auth.identity`, `auth.tenantId`, `additionalFields.private_key_passphrase`, and MCP custom header values), including base64-encoded forms, then applies generic redaction for `password`, `pwd`, `passphrase`, `private key`, `secret`, `token`, `api key`, `AccountKey=`, `SharedAccessSignature`, and `Authorization: Bearer`/`Basic` values. +- **Key Vault references resolve only within the owning action's scope.** A reference name arrives in the request body, so it is untrusted input. `_prepare_action_test_manifest` derives the expected scope from the *loaded* action with `_resolve_plugin_secret_context(existing_plugin, user_id)` and resolves through `resolve_secret_reference_for_context`, which raises on a scope mismatch. Without this, an authenticated user could submit another user's, group's, or global action's reference name and have the plaintext secret forwarded to an endpoint they control. The scoped helper `_resolve_secret_value_for_action_test` takes no defaults for `scope_value`, `scope`, or `allowed_sources`, and fails closed when the scope cannot be determined. +- **Reference sources are per field.** `auth.*` references are stored with source `action` and `additionalFields.*` plus MCP custom headers with `action-addset`, matching `keyvault_plugin_get_helper`. The route module keeps these as `ACTION_AUTH_SECRET_SOURCES` and `ACTION_ADDITIONAL_SECRET_SOURCES`; a functional test cross-checks them against `functions_keyvault.py` so a mismatch cannot silently break edit-mode testing. +- **Global actions are admin-gated at the loader.** `_load_existing_plugin_for_test` requires the Admin role before returning a global action, so every test route inherits the check — including routes that do not otherwise resolve an action identity scope. +- **Masked secrets resolve server-side.** Editing an existing action and pressing Test Connection without retyping a credential works: `_load_existing_plugin_for_test` resolves the stored Key Vault reference for the transient test only. +- **Reusable identities are honored.** When an action uses a workspace identity, `hydrate_action_identity_reference` resolves it before the test runs. +- **Scope is revalidated.** `_resolve_action_identity_context` re-checks group membership and the Admin role for group and global actions; a caller cannot test a global action without the Admin role. +- **MCP keeps discovery parity.** The MCP route enforces `_reject_non_admin_mcp_stdio` (stdio remains admin/global only) and `_enforce_mcp_destination_policy` with the `mcp_connection_test` operation label, so it is not a weaker outbound path than `/api/plugins/mcp/discover`. +- Every route carries `@swagger_route(security=get_auth_security())`, `@login_required`, and `@user_required`. + +### File structure + +| File | Change | +| --- | --- | +| `application/single_app/functions_action_connection_tests.py` | New. One tester per action type plus shared sanitization, result, and timeout helpers. | +| `application/single_app/route_backend_plugins.py` | Eight new routes plus `_prepare_action_test_manifest`, `_run_action_connection_test`, `_rehydrate_action_test_secret`, and the scope-checked `_resolve_secret_value_for_action_test`. The unscoped `_resolve_secret_value_for_plugin_test` helper was removed, and the existing MCP discovery, Cosmos, Yamcs, RocksDB, and SQL test paths now use the scoped resolver. | +| `application/single_app/templates/_plugin_modal.html` | Test Connection block in each of the eight Step 3 sections, plus the new `#log-analytics-config-section`. | +| `application/single_app/static/js/plugin_modal_stepper.js` | Shared test runner, per-type payload builders, and full Log Analytics configuration support. | + +## Usage instructions + +### Running a connection test + +1. Open **Workspace → Actions** (or **Admin → Actions** for global actions) and create or edit an action. +2. Choose one of the supported action types and continue to **Step 3: Configuration**. +3. Fill in the connection fields and credentials. +4. Press **Test Connection**. + - A green alert confirms the resource was reached and read. + - A red alert names the failure: rejected credentials, a missing warehouse or container, an unreachable host, or an uninstalled driver. + - A yellow alert means required fields are still empty; no request is sent. +5. Fix anything the test reports, then continue to the summary and save. + +The test never changes the action. It is safe to run repeatedly, and it does not overwrite MCP discovered tool metadata. + +### Log Analytics configuration section + +Log Analytics previously reused the generic endpoint and authentication form, with **Workspace ID** and **Cloud** rendered in Step 4 under *Advanced → Additional Fields*. It now has a dedicated Step 3 section: + +- **Workspace ID** (required) — the workspace GUID, not the resource ID. +- **Cloud** — Azure Commercial, Azure US Government, or Custom. +- **API Endpoint** — optional; blank uses the default endpoint for the selected cloud. +- **Authority Host** and **Endpoint Override** — shown and required only when the cloud is Custom. +- **Authentication** — a reusable workspace identity, or Managed Identity, On-Behalf-Of User, Service Principal, or Key. + +Existing `log_analytics` actions keep the same manifest shape. The section reads and writes the same `endpoint`, `auth`, and `additionalFields` keys, and `getLogAnalyticsConfiguration()` seeds `additionalFields` from the stored action so `query_history` and any other stored keys survive an edit. New actions default `query_history` to an empty list. + +Because Log Analytics is now a structured configuration type, Step 4 no longer renders duplicate Workspace ID and Cloud inputs. + +## Testing and validation + +### Functional tests + +| Test | Coverage | +| --- | --- | +| `functional_tests/test_action_test_connection_endpoints.py` | All eight routes are registered on the `admin_plugins` Blueprint, accept only `POST`, carry the required decorator stack, delegate to a dedicated tester, the MCP route retains the stdio scope restriction and destination policy, and Key Vault references are resolved only within the owning action's scope. | +| `functional_tests/test_action_connection_test_secret_redaction.py` | Manifest secrets, generic credential patterns, and base64-encoded credentials are stripped from error messages; result helpers and the timeout clamp behave correctly. | +| `functional_tests/test_action_test_connection_modal_wiring.py` | The modal renders a button, result container, and alert for all eight types; each button maps to a registered route; results render without an `innerHTML` sink; the Log Analytics section exists, is a structured config type, and preserves `query_history`. | + +Run them with: + +```bash +cd functional_tests +python test_action_test_connection_endpoints.py +python test_action_connection_test_secret_redaction.py +python test_action_test_connection_modal_wiring.py +``` + +### UI tests + +| Test | Coverage | +| --- | --- | +| `ui_tests/test_workspace_action_test_connection_controls.py` | Parametrized over all eight action types: warning state without required fields, success alert with a mocked 200, danger alert with a mocked 403, and the submitted payload shape. | +| `ui_tests/test_workspace_log_analytics_action_modal.py` | The dedicated Log Analytics section renders, custom cloud and service principal fields toggle correctly, validation blocks an empty workspace ID, and the saved manifest keeps `query_history`. | +| `ui_tests/test_workspace_azure_maps_action_modal.py`, `test_workspace_blob_storage_action_modal.py`, `test_workspace_databricks_action_modal.py`, `test_workspace_mcp_action_modal.py`, `test_tableau_action_modal_workflow.py` | Updated to assert the Test Connection control renders alongside the existing configuration flow. | + +UI tests skip unless `SIMPLECHAT_UI_BASE_URL` and `SIMPLECHAT_UI_STORAGE_STATE` are set. + +### Known limitations + +- The OpenAPI test probes the base URL rather than invoking a specific operation, so an API that returns 404 at its root is reported as reachable with that status rather than as a failure. +- Managed identity and service principal tests exercise the application identity of the running SimpleChat instance, so results reflect that identity's permissions, not the signed-in user's. +- The Log Analytics On-Behalf-Of User mode tests with the signed-in user's token, so results depend on that user's workspace permissions. +- Connection tests are capped at 20 seconds. A resource that is reachable but slower than that is reported as a transport failure. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 0f27d5040..19791110c 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,35 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.217)** + +#### New Features + +* **Test Connection for Eight More Action Types** + * Added a **Test Connection** button to the Step 3 configuration for OpenAPI, Azure Maps, Blob Storage, Databricks, Log Analytics, MCP, Snowflake, and Tableau actions. Previously only SQL, Cosmos DB, Yamcs, and RocksDB actions could be validated before saving. + * Each test authenticates with the credentials entered in the modal and performs one lightweight read against the configured resource, so a wrong warehouse ID, container name, subscription key, personal access token, or MCP endpoint is caught immediately instead of failing later during a chat. + * Failures name the cause — rejected credentials, a missing warehouse or container, an unreachable host, or a driver that is not installed — and successes report useful detail such as the Databricks warehouse state, the Snowflake version, the Tableau API version, or the MCP tool count. + * Editing an existing action works without retyping credentials: masked secrets and reusable workspace identities are resolved server-side for the test only, and no credential value is ever returned to the browser. + * The MCP test enforces the same stdio scope restriction and outbound destination policy as MCP tool discovery, and it does not overwrite discovered tool metadata. + * (Ref: #1267, `functions_action_connection_tests.py`, `route_backend_plugins.py`, `_plugin_modal.html`, `plugin_modal_stepper.js`, `ACTION_TEST_CONNECTION.md`) + +#### User Interface Enhancements + +* **Dedicated Log Analytics Configuration Section** + * Log Analytics actions now have their own Step 3 configuration section instead of reusing the generic endpoint and authentication form. + * Workspace ID, Cloud, API Endpoint, and the authentication method moved out of *Advanced → Additional Fields* and into the main configuration step, next to the new Test Connection button. Authority Host and Endpoint Override appear only when the Custom cloud is selected. + * Existing Log Analytics actions are unaffected — the section reads and writes the same manifest fields and preserves stored values such as `query_history`. + * (Ref: #1267, `_plugin_modal.html`, `plugin_modal_stepper.js`, Log Analytics action configuration) + +#### Bug Fixes + +* **Action Secret References Now Resolved Only Within Their Own Scope** + * Action connection tests resolve a stored Key Vault secret reference strictly against the scope of the action being tested, instead of resolving any reference name supplied in the request. + * The unscoped resolver has been removed, and the existing MCP tool discovery, Cosmos DB, SQL, Yamcs, and RocksDB test paths now share the same scope-checked resolution used by the new connection tests. A reference that does not match the action's scope is rejected instead of resolved. + * Loading a global action for a connection test now requires the Admin role at the shared loader, so every test route inherits the check rather than relying on each route to gate it. + * Only affects deployments with Key Vault secret storage enabled. Normal editing is unchanged — testing an existing action still works without retyping stored credentials. + * (Ref: #1267, `route_backend_plugins.py`, `functions_keyvault.py`, action secret scoping) + ### **(v0.250.216)** #### New Features diff --git a/functional_tests/test_action_connection_test_secret_redaction.py b/functional_tests/test_action_connection_test_secret_redaction.py new file mode 100644 index 000000000..b0161346c --- /dev/null +++ b/functional_tests/test_action_connection_test_secret_redaction.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +# test_action_connection_test_secret_redaction.py +""" +Functional test for action connection test error sanitization. +Version: 0.250.217 +Implemented in: 0.250.217 + +This test ensures that action Test Connection failures never echo stored +credentials back to the browser. It covers manifest-sourced secrets, generic +credential patterns in driver error text, base64-encoded secrets, and the +timeout clamp applied to every outbound connection test. + +Refs microsoft/simplechat#1267 +""" + +import base64 +import importlib.util +import os +import sys +import traceback +import types + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +TESTER_FILE = os.path.join( + REPO_ROOT, + "application", + "single_app", + "functions_action_connection_tests.py", +) + + +def _install_test_stubs(): + """Stub the Azure-backed modules the tester module imports at load time.""" + config_module = types.ModuleType("config") + config_module.SECRET_KEY = "functional-test-secret" + sys.modules["config"] = config_module + + appinsights_module = types.ModuleType("functions_appinsights") + appinsights_module.log_event = lambda *args, **kwargs: None + sys.modules["functions_appinsights"] = appinsights_module + + mcp_operations_module = types.ModuleType("functions_mcp_operations") + mcp_operations_module.MCP_CUSTOM_HEADERS_FIELD = "custom_headers" + sys.modules["functions_mcp_operations"] = mcp_operations_module + + azure_maps_module = types.ModuleType("functions_azure_maps") + azure_maps_module.AZURE_MAPS_DEFAULT_ENDPOINT = "https://atlas.microsoft.com" + azure_maps_module.AZURE_MAPS_DEFAULT_LANGUAGE = "en-US" + azure_maps_module.AZURE_MAPS_DEFAULT_TILESET_ID = "microsoft.base.road" + azure_maps_module.AZURE_MAPS_DEFAULT_VIEW = "Auto" + azure_maps_module.AZURE_MAPS_TILE_API_VERSION = "2024-04-01" + sys.modules["functions_azure_maps"] = azure_maps_module + + +def _load_tester_module(): + """Load functions_action_connection_tests.py without the full app config.""" + _install_test_stubs() + spec = importlib.util.spec_from_file_location("functions_action_connection_tests", TESTER_FILE) + if spec is None or spec.loader is None: + raise RuntimeError("Unable to load the action connection test module.") + + module = importlib.util.module_from_spec(spec) + sys.modules["functions_action_connection_tests"] = module + spec.loader.exec_module(module) + return module + + +def test_manifest_secrets_are_redacted(): + """Verify literal manifest credentials never survive into an error message.""" + print("Testing manifest secret redaction...") + + try: + assert_app_version_at_least( + "0.250.217", + reason="Action connection test sanitization was added in 0.250.217.", + ) + + module = _load_tester_module() + + account_key = "Zm9vYmFyU3VwZXJTZWNyZXRBY2NvdW50S2V5PT0" + tenant_id = "11111111-2222-3333-4444-555555555555" + passphrase = "private-key-passphrase-value" + header_secret = "custom-header-secret-value" + + manifest = { + "auth": {"type": "key", "key": account_key, "tenantId": tenant_id}, + "additionalFields": { + "private_key_passphrase": passphrase, + "custom_headers": {"X-Api-Token": header_secret}, + }, + } + + raw_error = ( + f"Connection failed for AccountKey={account_key} tenant {tenant_id} " + f"passphrase {passphrase} header {header_secret}" + ) + sanitized = module.sanitize_connection_error(raw_error, manifest) + + for secret_value in (account_key, tenant_id, passphrase, header_secret): + assert secret_value not in sanitized, ( + f"Sanitized message still contains a manifest secret: {sanitized}" + ) + assert module.REDACTED_PLACEHOLDER in sanitized, ( + f"Sanitized message should mark redactions: {sanitized}" + ) + + print("Manifest secrets were redacted.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_generic_credential_patterns_are_redacted(): + """Verify driver error text with inline credentials is scrubbed without a manifest.""" + print("Testing generic credential pattern redaction...") + + try: + module = _load_tester_module() + + cases = [ + ("Login failed: password=SuperSecret123!", "SuperSecret123!"), + ("pwd=hunter2 rejected by the server", "hunter2"), + ("DefaultEndpointsProtocol=https;AccountKey=abc123def456==;", "abc123def456=="), + ("Rejected header Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload", "eyJhbGciOiJIUzI1NiJ9.payload"), + ("client secret: s3cr3t-client-value", "s3cr3t-client-value"), + ("SharedAccessSignature=sv=2021&sig=abcdef123456", "sv=2021"), + ] + + for raw_error, secret_fragment in cases: + sanitized = module.sanitize_connection_error(raw_error, None) + assert secret_fragment not in sanitized, ( + f"Sanitized message leaked {secret_fragment!r}: {sanitized}" + ) + + print(f"Verified {len(cases)} generic credential patterns.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_base64_encoded_manifest_secrets_are_redacted(): + """Verify a base64-encoded credential echoed by a driver is still redacted.""" + print("Testing base64-encoded secret redaction...") + + try: + module = _load_tester_module() + + raw_secret = "tableau-pat-secret-value" + encoded_secret = base64.b64encode(raw_secret.encode("utf-8")).decode("ascii") + manifest = {"auth": {"type": "key", "key": raw_secret}} + + sanitized = module.sanitize_connection_error( + f"Upstream rejected credential {encoded_secret}", + manifest, + ) + + assert encoded_secret not in sanitized, ( + f"Sanitized message leaked the base64 credential: {sanitized}" + ) + + print("Base64-encoded secrets were redacted.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_result_helpers_and_timeout_clamp(): + """Verify result shapes and the outbound timeout clamp used by every tester.""" + print("Testing result helpers and timeout clamp...") + + try: + module = _load_tester_module() + + success = module.build_success_result("Connected.", warehouse_state="RUNNING", empty_value="") + assert success["success"] is True + assert success["status"] == 200 + assert success["details"] == {"warehouse_state": "RUNNING"}, ( + f"Empty detail values should be dropped: {success['details']}" + ) + + failure = module.build_failure_result("Nope.", status=403, http_status=401) + assert failure["success"] is False + assert failure["status"] == 403 + assert failure["details"] == {"http_status": 401} + + maximum = module.ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS + minimum = module.ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS + assert module.resolve_test_timeout(9999) == maximum, "Large timeouts must be clamped." + assert module.resolve_test_timeout(0) == minimum, "Zero timeouts must be raised to the minimum." + assert module.resolve_test_timeout(-5) == minimum, "Negative timeouts must be raised to the minimum." + assert module.resolve_test_timeout("not-a-number") == module.ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS + assert module.resolve_test_timeout(10) == 10, "In-range timeouts must be preserved." + + short_values = module.collect_manifest_secret_values({"auth": {"key": "ab"}}) + assert short_values == [], f"Very short values should not be treated as secrets: {short_values}" + + empty_message = module.sanitize_connection_error("", None) + assert empty_message, "An empty error must still produce a user-facing message." + + print("Result helpers and timeout clamp behaved correctly.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_all_testers_are_exported(): + """Verify every supported action type exposes a tester function.""" + print("Testing tester exports...") + + try: + module = _load_tester_module() + + expected_testers = [ + "test_openapi_connection", + "test_azure_maps_connection", + "test_blob_storage_connection", + "test_databricks_connection", + "test_log_analytics_connection", + "test_mcp_connection", + "test_snowflake_connection", + "test_tableau_connection", + ] + + for tester_name in expected_testers: + assert callable(getattr(module, tester_name, None)), ( + f"Missing callable tester: {tester_name}" + ) + + print(f"Verified {len(expected_testers)} exported testers.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +if __name__ == "__main__": + tests = [ + test_manifest_secrets_are_redacted, + test_generic_credential_patterns_are_redacted, + test_base64_encoded_manifest_secrets_are_redacted, + test_result_helpers_and_timeout_clamp, + test_all_testers_are_exported, + ] + + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + results.append(test()) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_action_test_connection_endpoints.py b/functional_tests/test_action_test_connection_endpoints.py new file mode 100644 index 000000000..55809e573 --- /dev/null +++ b/functional_tests/test_action_test_connection_endpoints.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +# test_action_test_connection_endpoints.py +""" +Functional test for the action Test Connection endpoints. +Version: 0.250.217 +Implemented in: 0.250.217 + +This test ensures the eight action connection test routes are registered on the +admin_plugins Blueprint with the required Swagger and authentication decorators, +that every route delegates to a dedicated tester in +functions_action_connection_tests, and that the MCP route keeps the same stdio +scope restriction and outbound destination policy as MCP tool discovery. + +Refs microsoft/simplechat#1267 +""" + +import ast +import os +import re +import sys +import traceback + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +ROUTE_FILE = os.path.join(REPO_ROOT, "application", "single_app", "route_backend_plugins.py") +TESTER_FILE = os.path.join(REPO_ROOT, "application", "single_app", "functions_action_connection_tests.py") +KEYVAULT_FILE = os.path.join(REPO_ROOT, "application", "single_app", "functions_keyvault.py") + +EXPECTED_ROUTES = { + "/api/plugins/test-openapi-connection": "test_openapi_connection", + "/api/plugins/test-azure-maps-connection": "test_azure_maps_connection", + "/api/plugins/test-blob-storage-connection": "test_blob_storage_connection", + "/api/plugins/test-databricks-connection": "test_databricks_connection", + "/api/plugins/test-log-analytics-connection": "test_log_analytics_connection", + "/api/plugins/test-mcp-connection": "test_mcp_connection", + "/api/plugins/test-snowflake-connection": "test_snowflake_connection", + "/api/plugins/test-tableau-connection": "test_tableau_connection", +} + +REQUIRED_DECORATORS = ("swagger_route", "login_required", "user_required") + + +def _dotted_name(node): + """Return a dotted attribute name for a decorator expression.""" + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return f"{_dotted_name(node.value)}.{node.attr}" + return "" + + +def _parse_module(file_path): + with open(file_path, "r", encoding="utf-8") as handle: + return ast.parse(handle.read(), filename=file_path) + + +def _iter_route_functions(tree): + """Yield (path, methods, decorator_names, function_node) for every decorated route.""" + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + + decorator_names = tuple( + _dotted_name(item.func if isinstance(item, ast.Call) else item) + for item in node.decorator_list + ) + + for decorator in node.decorator_list: + if not isinstance(decorator, ast.Call): + continue + if not _dotted_name(decorator.func).endswith(".route"): + continue + if not decorator.args or not isinstance(decorator.args[0], ast.Constant): + continue + + methods = [] + for keyword in decorator.keywords: + if keyword.arg == "methods" and isinstance(keyword.value, (ast.List, ast.Tuple)): + methods = [ + element.value + for element in keyword.value.elts + if isinstance(element, ast.Constant) + ] + + yield decorator.args[0].value, methods, decorator_names, node, _dotted_name(decorator.func) + + +def test_routes_are_registered_with_required_security_decorators(): + """Verify each connection test route exists on bpap with the full decorator stack.""" + print("Testing action connection test route registration...") + + try: + assert_app_version_at_least( + "0.250.217", + reason="Action Test Connection endpoints were added in 0.250.217.", + ) + + tree = _parse_module(ROUTE_FILE) + discovered = {} + for path, methods, decorator_names, node, route_target in _iter_route_functions(tree): + if path in EXPECTED_ROUTES: + discovered[path] = { + "methods": methods, + "decorators": decorator_names, + "function_name": node.name, + "route_target": route_target, + } + + missing_routes = sorted(set(EXPECTED_ROUTES) - set(discovered)) + assert not missing_routes, f"Missing action connection test routes: {missing_routes}" + + for path, details in sorted(discovered.items()): + assert details["route_target"] == "bpap.route", ( + f"{path} must be registered on the admin_plugins Blueprint, found {details['route_target']}." + ) + assert details["methods"] == ["POST"], ( + f"{path} must only accept POST, found {details['methods']}." + ) + for required_decorator in REQUIRED_DECORATORS: + assert required_decorator in details["decorators"], ( + f"{path} is missing the @{required_decorator} decorator." + ) + + print(f"Verified {len(discovered)} routes with required decorators.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_every_route_delegates_to_a_dedicated_tester(): + """Verify each route calls _run_action_connection_test with its matching tester.""" + print("Testing action connection test route delegation...") + + try: + route_source = open(ROUTE_FILE, "r", encoding="utf-8").read() + tester_tree = _parse_module(TESTER_FILE) + + exported_testers = { + node.name + for node in tester_tree.body + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_") + } + + missing_testers = sorted(set(EXPECTED_ROUTES.values()) - exported_testers) + assert not missing_testers, ( + f"functions_action_connection_tests is missing testers: {missing_testers}" + ) + + assert "_prepare_action_test_manifest" in route_source, ( + "route_backend_plugins.py must define the shared _prepare_action_test_manifest helper." + ) + assert "_run_action_connection_test" in route_source, ( + "route_backend_plugins.py must define the shared _run_action_connection_test helper." + ) + + for tester_name in sorted(EXPECTED_ROUTES.values()): + assert f"_run_action_connection_test(" in route_source, ( + "Routes must delegate through _run_action_connection_test." + ) + assert tester_name in route_source, ( + f"route_backend_plugins.py must reference the {tester_name} tester." + ) + + print(f"Verified {len(EXPECTED_ROUTES)} route-to-tester delegations.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_mcp_route_keeps_discovery_security_parity(): + """Verify the MCP test route enforces stdio scope limits and destination policy.""" + print("Testing MCP connection test security parity...") + + try: + tree = _parse_module(ROUTE_FILE) + + mcp_route_node = None + for path, _methods, _decorators, node, _route_target in _iter_route_functions(tree): + if path == "/api/plugins/test-mcp-connection": + mcp_route_node = node + break + + assert mcp_route_node is not None, "The MCP connection test route was not found." + + mcp_route_source = ast.unparse(mcp_route_node) + assert "_reject_non_admin_mcp_stdio" in mcp_route_source, ( + "The MCP connection test route must reject stdio transport outside global scope." + ) + assert "_enforce_mcp_destination_policy" in mcp_route_source, ( + "The MCP connection test route must enforce the outbound destination policy." + ) + assert "mcp_connection_test" in mcp_route_source, ( + "The MCP destination policy call must pass a distinct operation label." + ) + + print("MCP connection test route matches discovery security guarantees.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_secret_references_are_resolved_within_the_action_scope(): + """Verify caller-supplied Key Vault references cannot be resolved across scopes. + + A reference name arrives in the request body, so resolving it without a scope check + would let any authenticated user read another user's, group's, or global action's + secret and forward it to a caller-controlled endpoint. + """ + print("Testing action test secret scope enforcement...") + + try: + route_source = open(ROUTE_FILE, "r", encoding="utf-8").read() + tree = _parse_module(ROUTE_FILE) + + scoped_helper = None + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "_resolve_secret_value_for_action_test": + scoped_helper = node + break + + assert scoped_helper is not None, ( + "route_backend_plugins.py must define the scoped _resolve_secret_value_for_action_test helper." + ) + + helper_source = ast.unparse(scoped_helper) + assert "resolve_secret_reference_for_context" in helper_source, ( + "The action test secret helper must resolve through the scope-checked Key Vault resolver." + ) + + helper_argument_names = [argument.arg for argument in scoped_helper.args.args] + for required_argument in ("scope_value", "scope", "allowed_sources"): + assert required_argument in helper_argument_names, ( + f"The action test secret helper must require an explicit {required_argument} argument." + ) + assert scoped_helper.args.defaults == [], ( + "The action test secret helper must not default its scope or source arguments." + ) + assert "if not scope_value or not scope" in helper_source, ( + "The action test secret helper must fail closed when the scope cannot be determined." + ) + + # The source constants must match how keyvault_plugin_get_helper stored each field, + # otherwise editing an action and testing it without retyping the secret breaks. + keyvault_source = open(KEYVAULT_FILE, "r", encoding="utf-8").read() + auth_read_source = re.search( + r"for auth_field in \('key',.*?allowed_sources=\{(\"|')([a-z-]+)(\"|')\}", + keyvault_source, + re.DOTALL, + ) + assert auth_read_source, "Unable to locate the auth field Key Vault source in functions_keyvault.py." + expected_auth_source = auth_read_source.group(2) + + route_source_normalized = route_source.replace('"', "'") + assert f"ACTION_AUTH_SECRET_SOURCES = {{'{expected_auth_source}'}}" in route_source_normalized, ( + f"auth.* references are stored with source '{expected_auth_source}', " + "so ACTION_AUTH_SECRET_SOURCES must match or edit-mode connection tests will fail." + ) + assert "ACTION_ADDITIONAL_SECRET_SOURCES = {'action-addset'}" in route_source_normalized, ( + "additionalFields.* and MCP custom header references are stored with source 'action-addset'." + ) + assert expected_auth_source != "action-addset", ( + "auth.* and additionalFields.* must not share a source; the distinction is what this test guards." + ) + + prepare_node = None + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "_prepare_action_test_manifest": + prepare_node = node + break + + assert prepare_node is not None, "_prepare_action_test_manifest was not found." + + prepare_source = ast.unparse(prepare_node) + assert "_resolve_plugin_secret_context(existing_plugin, user_id)" in prepare_source, ( + "The action test manifest must derive the Key Vault scope from the loaded action, not the request body." + ) + assert "ACTION_AUTH_SECRET_SOURCES" in prepare_source, ( + "auth.key must be resolved with the auth secret source." + ) + assert "ACTION_ADDITIONAL_SECRET_SOURCES" in prepare_source, ( + "additionalFields secrets must be resolved with the additional-field secret source." + ) + assert "_resolve_secret_value_for_plugin_test" not in prepare_source, ( + "The action test manifest must not use the unscoped Key Vault resolver." + ) + + # No connection test route may fall back to the unscoped resolver. + unscoped_uses = re.findall(r"_resolve_secret_value_for_plugin_test\b", route_source) + assert not unscoped_uses, ( + f"The unscoped Key Vault resolver must not be used, found {len(unscoped_uses)} references." + ) + + # The scoped helper must be the single chokepoint for resolving a reference to a value, + # so every path inherits the scope, source, and fail-closed checks. + resolver_call_sites = re.findall(r"^\s+return resolve_secret_reference_for_context\(", route_source, re.MULTILINE) + assert len(resolver_call_sites) == 1, ( + "resolve_secret_reference_for_context must only be called from _resolve_secret_value_for_action_test, " + f"found {len(resolver_call_sites)} call sites." + ) + + # Global actions are admin-managed, so the shared loader must gate them for every route. + loader_node = None + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "_load_existing_plugin_for_test": + loader_node = node + break + + assert loader_node is not None, "_load_existing_plugin_for_test was not found." + loader_source = ast.unparse(loader_node) + assert "get_global_action" in loader_source, "The loader must handle the global action scope." + assert "PermissionError" in loader_source and "Admin" in loader_source, ( + "Loading a global action for testing must require the Admin role, so routes that do not " + "resolve an action identity scope still inherit the check." + ) + + print("Action test secrets resolve only within the owning action's scope and source.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +if __name__ == "__main__": + tests = [ + test_routes_are_registered_with_required_security_decorators, + test_every_route_delegates_to_a_dedicated_tester, + test_mcp_route_keeps_discovery_security_parity, + test_secret_references_are_resolved_within_the_action_scope, + ] + + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + results.append(test()) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_action_test_connection_modal_wiring.py b/functional_tests/test_action_test_connection_modal_wiring.py new file mode 100644 index 000000000..0912de166 --- /dev/null +++ b/functional_tests/test_action_test_connection_modal_wiring.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +# test_action_test_connection_modal_wiring.py +""" +Functional test for the action modal Test Connection wiring. +Version: 0.250.217 +Implemented in: 0.250.217 + +This test ensures the action modal renders a Test Connection control for all +eight newly supported action types, that every button is wired to the matching +backend route, that results are rendered without an innerHTML sink, and that the +new Log Analytics Step 3 section replaces the generic form while preserving +stored additionalFields such as query_history. + +Refs microsoft/simplechat#1267 +""" + +import os +import re +import sys +import traceback + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +APP_DIR = os.path.join(REPO_ROOT, "application", "single_app") +MODAL_TEMPLATE = os.path.join(APP_DIR, "templates", "_plugin_modal.html") +STEPPER_JS = os.path.join(APP_DIR, "static", "js", "plugin_modal_stepper.js") +ROUTE_FILE = os.path.join(APP_DIR, "route_backend_plugins.py") + +TEST_CONNECTION_PREFIXES = [ + "openapi", + "azure-maps", + "blob-storage", + "databricks", + "log-analytics", + "mcp", + "snowflake", + "tableau", +] + +LOG_ANALYTICS_FIELD_IDS = [ + "log-analytics-config-section", + "log-analytics-workspace-id", + "log-analytics-cloud", + "log-analytics-endpoint", + "log-analytics-custom-cloud-group", + "log-analytics-authority-host", + "log-analytics-endpoint-override", + "log-analytics-action-identity-group", + "log-analytics-identity-select", + "log-analytics-identity-status", + "log-analytics-auth-method", + "log-analytics-auth-identity", + "log-analytics-auth-key", + "log-analytics-auth-tenant-id", +] + + +def _read(file_path): + with open(file_path, "r", encoding="utf-8") as handle: + return handle.read() + + +def test_modal_renders_test_connection_controls(): + """Verify all eight action types render a button, result container, and alert.""" + print("Testing Test Connection markup...") + + try: + assert_app_version_at_least( + "0.250.217", + reason="Action Test Connection controls were added in 0.250.217.", + ) + + template_source = _read(MODAL_TEMPLATE) + + for prefix in TEST_CONNECTION_PREFIXES: + for suffix in ("btn", "result", "alert"): + element_id = f'id="{prefix}-test-connection-{suffix}"' + assert element_id in template_source, f"Missing modal element: {element_id}" + + assert f'id="{prefix}-test-connection-result" class="mt-2 d-none"' in template_source, ( + f"The {prefix} test connection result must start hidden with the Bootstrap d-none class." + ) + + print(f"Verified Test Connection markup for {len(TEST_CONNECTION_PREFIXES)} action types.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_buttons_are_wired_to_matching_backend_routes(): + """Verify the stepper config maps every button prefix to a registered route.""" + print("Testing Test Connection button wiring...") + + try: + stepper_source = _read(STEPPER_JS) + route_source = _read(ROUTE_FILE) + + config_match = re.search( + r"const ACTION_CONNECTION_TEST_CONFIG = \{(.*?)\n\};", + stepper_source, + re.DOTALL, + ) + assert config_match, "ACTION_CONNECTION_TEST_CONFIG was not found in plugin_modal_stepper.js." + + entries = re.findall( + r"(\w+):\s*\{\s*idPrefix:\s*'([^']+)',\s*url:\s*'([^']+)'", + config_match.group(1), + ) + assert len(entries) == len(TEST_CONNECTION_PREFIXES), ( + f"Expected {len(TEST_CONNECTION_PREFIXES)} test connection entries, found {len(entries)}." + ) + + configured_prefixes = sorted(prefix for _key, prefix, _url in entries) + assert configured_prefixes == sorted(TEST_CONNECTION_PREFIXES), ( + f"Configured prefixes do not match the modal markup: {configured_prefixes}" + ) + + for _key, prefix, url in entries: + assert f"@bpap.route('{url}', methods=['POST'])" in route_source, ( + f"The {prefix} test connection URL {url} is not registered in route_backend_plugins.py." + ) + + assert "ACTION_CONNECTION_TEST_CONFIG).forEach(testKey" in stepper_source, ( + "bindEvents must attach a click handler for every configured test connection button." + ) + assert "this.runActionConnectionTest(testKey)" in stepper_source, ( + "Test connection buttons must call runActionConnectionTest." + ) + + print(f"Verified {len(entries)} button-to-route mappings.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_results_render_without_an_inner_html_sink(): + """Verify server-provided test messages are rendered through DOM text nodes.""" + print("Testing Test Connection result rendering...") + + try: + stepper_source = _read(STEPPER_JS) + + renderer_match = re.search( + r"showActionConnectionTestMessage\(testKey, variant, message, iconClass = ''\) \{(.*?)\n \}", + stepper_source, + re.DOTALL, + ) + assert renderer_match, "showActionConnectionTestMessage was not found." + + renderer_body = renderer_match.group(1) + assert "innerHTML" not in renderer_body, ( + "Test connection results must not use innerHTML for server-provided text." + ) + assert "createTextNode(message)" in renderer_body, ( + "Test connection results must render the message as a text node." + ) + assert "replaceChildren()" in renderer_body, ( + "Test connection results must clear prior content with DOM APIs." + ) + + runner_match = re.search( + r"async runActionConnectionTest\(testKey\) \{(.*?)\n \}\n\n initializeSqlConfiguration", + stepper_source, + re.DOTALL, + ) + assert runner_match, "runActionConnectionTest was not found." + assert "innerHTML" not in runner_match.group(1), ( + "runActionConnectionTest must not use innerHTML for the button loading state." + ) + + print("Test connection results avoid innerHTML sinks.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_log_analytics_section_replaces_the_generic_form(): + """Verify the Log Analytics Step 3 section exists and is treated as structured config.""" + print("Testing Log Analytics configuration section...") + + try: + template_source = _read(MODAL_TEMPLATE) + stepper_source = _read(STEPPER_JS) + + for element_id in LOG_ANALYTICS_FIELD_IDS: + assert f'id="{element_id}"' in template_source, f"Missing Log Analytics element: {element_id}" + + assert "const LOG_ANALYTICS_PLUGIN_TYPE = 'log_analytics';" in stepper_source, ( + "The stepper must declare the log_analytics plugin type constant." + ) + assert "isLogAnalyticsType(type = this.selectedType)" in stepper_source, ( + "The stepper must expose an isLogAnalyticsType predicate." + ) + + structured_match = re.search( + r"isStructuredConfigType\(type = this\.selectedType\) \{\s*return ([^;]+);", + stepper_source, + ) + assert structured_match, "isStructuredConfigType was not found." + assert "this.isLogAnalyticsType(type)" in structured_match.group(1), ( + "log_analytics must be a structured config type so Step 4 stops rendering duplicate fields." + ) + + assert "logAnalytics: document.getElementById('log-analytics-config-section')" in stepper_source, ( + "The Log Analytics section must be registered in showConfigSectionForType." + ) + assert "this.initializeLogAnalyticsConfiguration();" in stepper_source, ( + "Selecting the Log Analytics type must initialize its configuration section." + ) + assert "logAnalytics: 'log-analytics-identity-select'" in stepper_source, ( + "Log Analytics must support reusable workspace identities." + ) + + print(f"Verified {len(LOG_ANALYTICS_FIELD_IDS)} Log Analytics elements and stepper wiring.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +def test_log_analytics_preserves_stored_additional_fields(): + """Verify getLogAnalyticsConfiguration keeps stored fields such as query_history.""" + print("Testing Log Analytics additionalFields preservation...") + + try: + stepper_source = _read(STEPPER_JS) + + config_match = re.search( + r"getLogAnalyticsConfiguration\(\) \{(.*?)\n \}", + stepper_source, + re.DOTALL, + ) + assert config_match, "getLogAnalyticsConfiguration was not found." + + config_body = config_match.group(1) + assert "this.originalPlugin?.additionalFields" in config_body, ( + "Log Analytics config must seed additionalFields from the stored action." + ) + assert "query_history" in config_body, ( + "Log Analytics config must preserve or default the required query_history field." + ) + assert "Array.isArray(additionalFields.query_history)" in config_body, ( + "query_history must default to an array when it is missing." + ) + assert "additionalFields.workspaceId" in config_body, ( + "Log Analytics config must write the workspaceId field." + ) + assert "delete additionalFields.authorityHost" in config_body, ( + "Custom-cloud fields must be cleared when the cloud is not custom." + ) + + print("Log Analytics configuration preserves stored additionalFields.") + print("Test passed!") + return True + + except Exception as e: + print(f"Test failed: {e}") + traceback.print_exc() + return False + + +if __name__ == "__main__": + tests = [ + test_modal_renders_test_connection_controls, + test_buttons_are_wired_to_matching_backend_routes, + test_results_render_without_an_inner_html_sink, + test_log_analytics_section_replaces_the_generic_form, + test_log_analytics_preserves_stored_additional_fields, + ] + + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + results.append(test()) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/ui_tests/test_tableau_action_modal_workflow.py b/ui_tests/test_tableau_action_modal_workflow.py index f6a87ec3b..58f78dc58 100644 --- a/ui_tests/test_tableau_action_modal_workflow.py +++ b/ui_tests/test_tableau_action_modal_workflow.py @@ -56,6 +56,9 @@ def test_tableau_action_modal_custom_workflow_renders(): "tableau-max-results", "tableau-timeout", "tableau-use-server-version", + "tableau-test-connection-btn", + "tableau-test-connection-result", + "tableau-test-connection-alert", "summary-tableau-section", "summary-tableau-auth-method", ] @@ -71,6 +74,8 @@ def test_tableau_action_modal_custom_workflow_renders(): "getTableauConfiguration", "populateTableauSummary", "Tableau Configuration", + "url: '/api/plugins/test-tableau-connection'", + "runActionConnectionTest", ]: assert marker in modal_js @@ -107,16 +112,18 @@ def test_tableau_action_modal_custom_workflow_renders(): page.locator("#tableau-config-section").evaluate("element => element.classList.remove('d-none')") page.locator("#summary-tableau-section").evaluate("element => element.classList.remove('d-none')") - expect(page.get_by_label("Server URL")).to_be_visible() - expect(page.get_by_label("Site Content URL")).to_be_visible() - expect(page.get_by_label("Reusable Identity")).to_be_attached() - expect(page.get_by_label("Authentication Method")).to_be_visible() - expect(page.get_by_label("PAT Name")).to_be_visible() - expect(page.get_by_label("PAT Secret")).to_be_visible() - expect(page.get_by_label("Page Size")).to_be_visible() - expect(page.get_by_label("Max Results")).to_be_visible() - expect(page.get_by_label("Timeout (seconds)")).to_be_visible() - expect(page.get_by_label("Use Tableau server version negotiation")).to_be_checked() + # Scoped by id because several action sections now share the same field labels + # (for example Server URL, Reusable Identity, and Timeout (seconds)). + expect(page.locator("#tableau-server-url")).to_be_visible() + expect(page.locator("#tableau-site-content-url")).to_be_visible() + expect(page.locator("#tableau-identity-select")).to_be_attached() + expect(page.locator("#tableau-auth-method")).to_be_visible() + expect(page.locator("#tableau-pat-name")).to_be_visible() + expect(page.locator("#tableau-pat-secret")).to_be_visible() + expect(page.locator("#tableau-page-size")).to_be_visible() + expect(page.locator("#tableau-max-results")).to_be_visible() + expect(page.locator("#tableau-timeout")).to_be_visible() + expect(page.locator("#tableau-use-server-version")).to_be_checked() expect(page.locator("#summary-tableau-section")).to_contain_text("Tableau Configuration") expect(page.locator("#summary-tableau-section")).to_contain_text("Server Version Negotiation") finally: diff --git a/ui_tests/test_workspace_action_test_connection_controls.py b/ui_tests/test_workspace_action_test_connection_controls.py new file mode 100644 index 000000000..88c37fc38 --- /dev/null +++ b/ui_tests/test_workspace_action_test_connection_controls.py @@ -0,0 +1,310 @@ +# test_workspace_action_test_connection_controls.py +""" +UI test for the workspace action modal Test Connection controls. + +Version: 0.250.217 +Implemented in: 0.250.217 + +This test ensures every action type that supports connection testing renders a +Test Connection button in Step 3, posts the collected configuration to its own +backend route, and renders success, failure, and missing-field states as visible +Bootstrap alerts without leaving the modal. + +Refs microsoft/simplechat#1267 +""" + +import json +import os +import re +from pathlib import Path + +import pytest + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} + +OPENAPI_SPEC = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": {"/status": {"get": {"operationId": "getStatus", "summary": "Status"}}}, +} + + +def _fill_openapi(page): + page.locator("#plugin-endpoint").fill("https://api.example.com") + page.evaluate( + """(spec) => { + const fileInput = document.getElementById('plugin-openapi-file'); + fileInput.dataset.fileId = 'ui-test-spec'; + fileInput.dataset.specContent = JSON.stringify(spec); + }""", + OPENAPI_SPEC, + ) + + +def _fill_azure_maps(page): + page.locator("#azure-maps-key").fill("maps-key-123") + + +def _fill_blob_storage(page): + page.locator("#blob-storage-connection-string").fill( + "DefaultEndpointsProtocol=https;AccountName=uitest;AccountKey=dGVzdC1hY2NvdW50LWtleQ==;EndpointSuffix=core.windows.net" + ) + page.locator("#blob-storage-container-name").fill("knowledge-base") + + +def _fill_databricks(page): + page.locator("#databricks-workspace-url").fill("https://adb-1234567890123456.7.azuredatabricks.net") + page.locator("#databricks-warehouse-id").fill("warehouse-123") + page.locator("#databricks-auth-method").select_option("pat") + page.locator("#databricks-token").fill("test-token") + + +def _fill_log_analytics(page): + page.locator("#log-analytics-workspace-id").fill("11111111-2222-3333-4444-555555555555") + page.locator("#log-analytics-cloud").select_option("public") + + +def _fill_mcp(page): + page.locator("#mcp-transport").select_option("streamable_http") + page.locator("#mcp-endpoint").fill("https://example.com/mcp") + + +def _fill_snowflake(page): + page.locator("#snowflake-account").fill("acme-analytics") + page.locator("#snowflake-warehouse").fill("COMPUTE_WH") + page.locator("#snowflake-auth-method").select_option("password") + page.locator("#snowflake-user").fill("analyst@example.com") + page.locator("#snowflake-password").fill("test-password") + + +def _fill_tableau(page): + page.locator("#tableau-server-url").fill("https://10ax.online.tableau.com") + page.locator("#tableau-pat-name").fill("simplechat-agent") + page.locator("#tableau-pat-secret").fill("pat-secret") + + +ACTION_TEST_CASES = [ + pytest.param( + "openapi", + "#openapi-config-section", + "openapi", + "**/api/plugins/test-openapi-connection", + "Status API", + _fill_openapi, + "#plugin-endpoint", + id="openapi", + ), + pytest.param( + "azure_maps_openlayers", + "#azure-maps-config-section", + "azure-maps", + "**/api/plugins/test-azure-maps-connection", + "Coverage Map", + _fill_azure_maps, + "#azure-maps-key", + id="azure_maps", + ), + pytest.param( + "blob_storage", + "#blob-storage-config-section", + "blob-storage", + "**/api/plugins/test-blob-storage-connection", + "Knowledge Base Blobs", + _fill_blob_storage, + "#blob-storage-container-name", + id="blob_storage", + ), + pytest.param( + "databricks", + "#databricks-config-section", + "databricks", + "**/api/plugins/test-databricks-connection", + "Commercial Databricks SQL", + _fill_databricks, + "#databricks-warehouse-id", + id="databricks", + ), + pytest.param( + "log_analytics", + "#log-analytics-config-section", + "log-analytics", + "**/api/plugins/test-log-analytics-connection", + "Platform Logs", + _fill_log_analytics, + "#log-analytics-workspace-id", + id="log_analytics", + ), + pytest.param( + "mcp", + "#mcp-config-section", + "mcp", + "**/api/plugins/test-mcp-connection", + "Docs MCP Server", + _fill_mcp, + "#mcp-endpoint", + id="mcp", + ), + pytest.param( + "snowflake", + "#snowflake-config-section", + "snowflake", + "**/api/plugins/test-snowflake-connection", + "Analytics Snowflake", + _fill_snowflake, + "#snowflake-account", + id="snowflake", + ), + pytest.param( + "tableau", + "#tableau-config-section", + "tableau", + "**/api/plugins/test-tableau-connection", + "Tableau Cloud Content", + _fill_tableau, + "#tableau-server-url", + id="tableau", + ), +] + + +def _require_ui_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +def _open_action_step_three(page, expect, pytest_module, action_type, display_name): + """Navigate the workspace action modal to Step 3 for the given action type.""" + response = page.goto(f"{BASE_URL}/workspace", wait_until="networkidle") + assert response is not None, "Expected a navigation response when loading /workspace." + + if response.status in SKIP_RESPONSE_CODES: + pytest_module.skip(f"Workspace page unavailable in this environment (HTTP {response.status}).") + + assert response.ok, f"Expected /workspace to load successfully, got HTTP {response.status}." + + plugins_tab_button = page.locator("#plugins-tab-btn") + if plugins_tab_button.count() == 0: + pytest_module.skip("Workspace actions are not enabled in this environment.") + plugins_tab_button.click() + + create_button = page.locator("#create-plugin-btn") + if create_button.count() == 0: + pytest_module.skip("Workspace action creation is not available in this environment.") + expect(create_button).to_be_visible() + create_button.click() + + modal = page.locator("#plugin-modal") + expect(modal).to_be_visible() + + action_card = page.locator(f'.action-type-card[data-type="{action_type}"]') + if action_card.count() == 0: + pytest_module.skip(f"Action type {action_type} is not available in this environment.") + action_card.click() + + modal.get_by_role("button", name="Next").click() + page.locator("#plugin-display-name").fill(display_name) + modal.get_by_role("button", name="Next").click() + return modal + + +@pytest.mark.ui +@pytest.mark.parametrize( + "action_type,section_selector,id_prefix,route_glob,display_name,fill_configuration,required_field_selector", + ACTION_TEST_CASES, +) +def test_workspace_action_test_connection_controls( + action_type, + section_selector, + id_prefix, + route_glob, + display_name, + fill_configuration, + required_field_selector, +): + """Validate the Test Connection button posts configuration and renders every result state.""" + _require_ui_env() + playwright_sync_api = pytest.importorskip("playwright.sync_api") + expect = playwright_sync_api.expect + + test_requests = [] + responder = { + "status": 200, + "body": {"success": True, "message": "Connection successful for the configured resource."}, + } + + with playwright_sync_api.sync_playwright() as playwright: + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + def handle_plugins(route): + if route.request.method == "GET": + route.fulfill(status=200, content_type="application/json", body="[]") + return + route.fulfill(status=200, content_type="application/json", body='{"success": true}') + + def handle_connection_test(route): + test_requests.append(json.loads(route.request.post_data or "{}")) + route.fulfill( + status=responder["status"], + content_type="application/json", + body=json.dumps(responder["body"]), + ) + + page.route("**/api/user/plugins", handle_plugins) + page.route(route_glob, handle_connection_test) + + try: + _open_action_step_three(page, expect, pytest, action_type, display_name) + + config_section = page.locator(section_selector) + expect(config_section).to_be_visible() + expect(page.locator("#generic-config-section")).to_be_hidden() + + test_button = page.locator(f"#{id_prefix}-test-connection-btn") + result_alert = page.locator(f"#{id_prefix}-test-connection-alert") + expect(test_button).to_be_visible() + expect(page.locator(f"#{id_prefix}-test-connection-result")).to_be_hidden() + + # Missing required configuration renders a warning without calling the backend. + page.locator(required_field_selector).fill("") + test_button.click() + expect(result_alert).to_be_visible() + expect(result_alert).to_have_class(re.compile(r"alert-warning")) + assert not test_requests, "A warning state must not send a connection test request." + + # A complete configuration posts to the type-specific route and renders success. + fill_configuration(page) + test_button.click() + expect(result_alert).to_have_class(re.compile(r"alert-success")) + expect(result_alert).to_contain_text("Connection successful") + assert len(test_requests) == 1, "Expected exactly one connection test request." + + submitted = test_requests[0] + assert submitted["type"] == action_type, ( + f"Expected the {action_type} manifest type, got {submitted.get('type')}." + ) + assert submitted["action_scope"] == "personal" + assert isinstance(submitted.get("auth"), dict) + assert isinstance(submitted.get("additionalFields"), dict) + + # A backend failure renders a danger alert with the server-provided reason. + responder["status"] = 403 + responder["body"] = {"success": False, "error": "Credentials were rejected by the resource."} + test_button.click() + expect(result_alert).to_have_class(re.compile(r"alert-danger")) + expect(result_alert).to_contain_text("Credentials were rejected") + assert len(test_requests) == 2, "Expected a second connection test request." + + expect(page.locator("#plugin-modal")).to_be_visible() + finally: + context.close() + browser.close() diff --git a/ui_tests/test_workspace_azure_maps_action_modal.py b/ui_tests/test_workspace_azure_maps_action_modal.py index 71a28b376..614bf09fb 100644 --- a/ui_tests/test_workspace_azure_maps_action_modal.py +++ b/ui_tests/test_workspace_azure_maps_action_modal.py @@ -110,6 +110,8 @@ def handle_admin_validation(route): expect(page.locator("#step-3-title")).to_have_text("Azure Maps Configuration") expect(page.locator("#azure-maps-config-section")).to_be_visible() + expect(page.locator("#azure-maps-test-connection-btn")).to_be_visible() + expect(page.locator("#azure-maps-test-connection-result")).to_be_hidden() expect(page.locator("#generic-config-section")).to_be_hidden() expect(page.locator("#sql-config-section")).to_be_hidden() expect(page.locator("#cosmos-config-section")).to_be_hidden() diff --git a/ui_tests/test_workspace_blob_storage_action_modal.py b/ui_tests/test_workspace_blob_storage_action_modal.py index fa222eedc..d9dceccdf 100644 --- a/ui_tests/test_workspace_blob_storage_action_modal.py +++ b/ui_tests/test_workspace_blob_storage_action_modal.py @@ -115,6 +115,8 @@ def handle_admin_validation(route): modal.get_by_role("button", name="Next").click() expect(page.locator("#blob-storage-config-section")).to_be_visible() + expect(page.locator("#blob-storage-test-connection-btn")).to_be_visible() + expect(page.locator("#blob-storage-test-connection-result")).to_be_hidden() expect(page.locator("#generic-config-section")).to_be_hidden() expect(page.locator("#sql-config-section")).to_be_hidden() expect(page.locator("#cosmos-config-section")).to_be_hidden() diff --git a/ui_tests/test_workspace_databricks_action_modal.py b/ui_tests/test_workspace_databricks_action_modal.py index 18581fe0f..6db6a1538 100644 --- a/ui_tests/test_workspace_databricks_action_modal.py +++ b/ui_tests/test_workspace_databricks_action_modal.py @@ -114,6 +114,8 @@ def handle_admin_validation(route): modal.get_by_role("button", name="Next").click() expect(page.locator("#databricks-config-section")).to_be_visible() + expect(page.locator("#databricks-test-connection-btn")).to_be_visible() + expect(page.locator("#databricks-test-connection-result")).to_be_hidden() expect(page.locator("#generic-config-section")).to_be_hidden() expect(page.locator("#sql-config-section")).to_be_hidden() diff --git a/ui_tests/test_workspace_log_analytics_action_modal.py b/ui_tests/test_workspace_log_analytics_action_modal.py new file mode 100644 index 000000000..88c708284 --- /dev/null +++ b/ui_tests/test_workspace_log_analytics_action_modal.py @@ -0,0 +1,162 @@ +# test_workspace_log_analytics_action_modal.py +""" +UI test for the workspace Log Analytics action modal. + +Version: 0.250.217 +Implemented in: 0.250.217 + +This test ensures the Log Analytics action type now uses a dedicated Step 3 +configuration section instead of the generic endpoint form, reveals the custom +cloud fields only when the custom cloud is selected, validates required fields, +and saves a manifest that keeps the required query_history additional field. + +Refs microsoft/simplechat#1267 +""" + +import json +import os +from pathlib import Path + +import pytest + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} +WORKSPACE_ID = "11111111-2222-3333-4444-555555555555" + + +def _require_ui_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +@pytest.mark.ui +def test_workspace_log_analytics_action_modal(): + """Validate the dedicated Log Analytics configuration section and save payload.""" + _require_ui_env() + playwright_sync_api = pytest.importorskip("playwright.sync_api") + expect = playwright_sync_api.expect + + validation_requests = [] + saved_payloads = [] + + with playwright_sync_api.sync_playwright() as playwright: + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + def handle_plugins(route): + request = route.request + if request.method == "GET": + route.fulfill(status=200, content_type="application/json", body="[]") + return + + saved_payloads.append(json.loads(request.post_data or "[]")) + route.fulfill(status=200, content_type="application/json", body='{"success": true}') + + def handle_validation(route): + validation_requests.append(json.loads(route.request.post_data or "{}")) + route.fulfill( + status=200, + content_type="application/json", + body='{"valid": true, "errors": [], "warnings": []}', + ) + + page.route("**/api/user/plugins", handle_plugins) + page.route("**/api/plugins/validate", handle_validation) + + try: + response = page.goto(f"{BASE_URL}/workspace", wait_until="networkidle") + assert response is not None, "Expected a navigation response when loading /workspace." + + if response.status in SKIP_RESPONSE_CODES: + pytest.skip(f"Workspace page unavailable in this environment (HTTP {response.status}).") + + assert response.ok, f"Expected /workspace to load successfully, got HTTP {response.status}." + + plugins_tab_button = page.locator("#plugins-tab-btn") + if plugins_tab_button.count() == 0: + pytest.skip("Workspace actions are not enabled in this environment.") + plugins_tab_button.click() + + create_button = page.locator("#create-plugin-btn") + if create_button.count() == 0: + pytest.skip("Workspace action creation is not available in this environment.") + expect(create_button).to_be_visible() + create_button.click() + + modal = page.locator("#plugin-modal") + expect(modal).to_be_visible() + + log_analytics_card = page.locator('.action-type-card[data-type="log_analytics"]') + if log_analytics_card.count() == 0: + pytest.skip("The Log Analytics action type is not available in this environment.") + log_analytics_card.click() + + modal.get_by_role("button", name="Next").click() + page.locator("#plugin-display-name").fill("Platform Logs") + modal.get_by_role("button", name="Next").click() + + expect(page.locator("#step-3-title")).to_have_text("Log Analytics Configuration") + expect(page.locator("#log-analytics-config-section")).to_be_visible() + expect(page.locator("#generic-config-section")).to_be_hidden() + expect(page.locator("#sql-config-section")).to_be_hidden() + + # Custom cloud fields stay hidden until the custom cloud is selected. + expect(page.locator("#log-analytics-custom-cloud-group")).to_be_hidden() + page.locator("#log-analytics-cloud").select_option("custom") + expect(page.locator("#log-analytics-custom-cloud-group")).to_be_visible() + page.locator("#log-analytics-cloud").select_option("public") + expect(page.locator("#log-analytics-custom-cloud-group")).to_be_hidden() + + # Service principal fields appear only for service principal authentication. + expect(page.locator("#log-analytics-auth-tenant-id-group")).to_be_hidden() + page.locator("#log-analytics-auth-method").select_option("servicePrincipal") + expect(page.locator("#log-analytics-auth-tenant-id-group")).to_be_visible() + expect(page.locator("#log-analytics-auth-key-group")).to_be_visible() + page.locator("#log-analytics-auth-method").select_option("identity") + expect(page.locator("#log-analytics-auth-tenant-id-group")).to_be_hidden() + + # A missing workspace ID blocks navigation to the next step. + modal.get_by_role("button", name="Next").click() + expect(page.locator("#log-analytics-config-section")).to_be_visible() + + page.locator("#log-analytics-workspace-id").fill(WORKSPACE_ID) + page.locator("#plugin-modal-skip").click() + + expect(page.locator("#summary-plugin-database-type")).to_have_text("Azure Log Analytics workspace") + expect(page.locator("#summary-plugin-endpoint")).to_have_text("https://api.loganalytics.io") + + modal.get_by_role("button", name="Save Action").click() + + expect(modal).to_be_hidden() + assert len(validation_requests) == 1, "Expected the shared validation endpoint to be called once." + assert len(saved_payloads) == 1, "Expected the workspace action save request to be submitted once." + + saved_plugin = saved_payloads[0][0] + assert saved_plugin["type"] == "log_analytics" + assert saved_plugin["name"] == "platform_logs" + assert saved_plugin["endpoint"] == "https://api.loganalytics.io" + assert saved_plugin["auth"]["type"] == "identity" + + additional_fields = saved_plugin["additionalFields"] + assert additional_fields["workspaceId"] == WORKSPACE_ID + assert additional_fields["cloud"] == "public" + assert additional_fields["query_history"] == [], ( + "New Log Analytics actions must save an empty query_history list." + ) + assert "authorityHost" not in additional_fields, ( + "Custom cloud fields must be omitted for non-custom clouds." + ) + assert "endpointOverride" not in additional_fields, ( + "Custom cloud fields must be omitted for non-custom clouds." + ) + finally: + context.close() + browser.close() diff --git a/ui_tests/test_workspace_mcp_action_modal.py b/ui_tests/test_workspace_mcp_action_modal.py index 73954654d..a69eb0097 100644 --- a/ui_tests/test_workspace_mcp_action_modal.py +++ b/ui_tests/test_workspace_mcp_action_modal.py @@ -302,6 +302,9 @@ def handle_preconfigurations(route): modal.get_by_role("button", name="Next").click() expect(page.locator("#mcp-config-section")).to_be_visible() + expect(page.locator("#mcp-test-connection-btn")).to_be_visible() + expect(page.locator("#mcp-test-connection-result")).to_be_hidden() + expect(page.locator("#mcp-discover-tools-btn")).to_be_visible() expect(page.locator("#generic-config-section")).to_be_hidden() expect(page.locator("#sql-config-section")).to_be_hidden()