fix: apply all PR review agent findings (silent failures, type safety, test gaps)

Addresses issues found by 4 parallel review agents (code-reviewer,
silent-failure-hunter, type-design-analyzer, pr-test-analyzer).

Source fixes:
- core/utils.py: add public safe_display_url() (moved from tools/health.py)
- core/client.py: rename _redact_sensitive → redact_sensitive (public API)
- core/types.py: add SubscriptionData.__post_init__ for tz-aware datetime
  enforcement; remove 6 unused type aliases (SystemHealth, APIResponse, etc.)
- subscriptions/manager.py: add exc_info=True to both except-Exception blocks;
  add except ValueError break-on-config-error before retry loop; import
  redact_sensitive by new public name
- subscriptions/resources.py: re-raise in autostart_subscriptions() so
  ensure_subscriptions_started() doesn't permanently set _subscriptions_started
- subscriptions/diagnostics.py: except ToolError: raise before broad except;
  use safe_display_url() instead of raw URL slice
- tools/health.py: move _safe_display_url to core/utils; add exc_info=True;
  raise ToolError (not return dict) on ImportError
- tools/info.py: use get_args(INFO_ACTIONS) instead of INFO_ACTIONS.__args__
- tools/{array,docker,keys,notifications,rclone,storage,virtualization}.py:
  add Literal-vs-ALL_ACTIONS sync check at import time

Test fixes:
- test_health.py: import safe_display_url from core.utils; update
  test_diagnose_import_error_internal to expect ToolError (not error dict)
- test_storage.py: add 3 safe_get tests for zero/False/empty-string values
- test_subscription_manager.py: add TestCapLogContentSingleMassiveLine (2 tests)
- test_client.py: rename _redact_sensitive → redact_sensitive; add tests for
  new sensitive keys and is_cacheable explicit-keyword form
This commit is contained in:
Jacob Magar
2026-02-19 02:23:04 -05:00
parent 348f4149a5
commit 1751bc2984
28 changed files with 354 additions and 187 deletions

View File

@@ -7,6 +7,7 @@ to the Unraid API with proper timeout handling and error management.
import asyncio
import hashlib
import json
import re
import time
from typing import Any, Final
@@ -47,14 +48,14 @@ def _is_sensitive_key(key: str) -> bool:
return any(s in key_lower for s in _SENSITIVE_KEYS)
def _redact_sensitive(obj: Any) -> Any:
def redact_sensitive(obj: Any) -> Any:
"""Recursively redact sensitive values from nested dicts/lists."""
if isinstance(obj, dict):
return {
k: ("***" if _is_sensitive_key(k) else _redact_sensitive(v)) for k, v in obj.items()
k: ("***" if _is_sensitive_key(k) else redact_sensitive(v)) for k, v in obj.items()
}
if isinstance(obj, list):
return [_redact_sensitive(item) for item in obj]
return [redact_sensitive(item) for item in obj]
return obj
@@ -139,6 +140,7 @@ _CACHEABLE_QUERY_PREFIXES = frozenset(
)
_CACHE_TTL_SECONDS = 60.0
_OPERATION_NAME_PATTERN = re.compile(r"^(?:query\s+)?([_A-Za-z][_0-9A-Za-z]*)\b")
class _QueryCache:
@@ -160,9 +162,13 @@ class _QueryCache:
@staticmethod
def is_cacheable(query: str) -> bool:
"""Check if a query is eligible for caching based on its operation name."""
if query.lstrip().startswith("mutation"):
normalized = query.lstrip()
if normalized.startswith("mutation"):
return False
return any(prefix in query for prefix in _CACHEABLE_QUERY_PREFIXES)
match = _OPERATION_NAME_PATTERN.match(normalized)
if not match:
return False
return match.group(1) in _CACHEABLE_QUERY_PREFIXES
def get(self, query: str, variables: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return cached result if present and not expired, else None."""
@@ -324,7 +330,7 @@ async def make_graphql_request(
logger.debug(f"Making GraphQL request to {UNRAID_API_URL}:")
logger.debug(f"Query: {query[:200]}{'...' if len(query) > 200 else ''}") # Log truncated query
if variables:
logger.debug(f"Variables: {_redact_sensitive(variables)}")
logger.debug(f"Variables: {redact_sensitive(variables)}")
try:
# Rate limit: consume a token before making the request

View File

@@ -45,13 +45,12 @@ def tool_error_handler(
except ToolError:
raise
except TimeoutError as e:
logger.error(
f"Timeout in unraid_{tool_name} action={action}: request exceeded time limit",
exc_info=True,
)
logger.exception(f"Timeout in unraid_{tool_name} action={action}: request exceeded time limit")
raise ToolError(
f"Request timed out executing {tool_name}/{action}. The Unraid API did not respond in time."
) from e
except Exception as e:
logger.error(f"Error in unraid_{tool_name} action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute {tool_name}/{action}: {e!s}") from e
logger.exception(f"Error in unraid_{tool_name} action={action}")
raise ToolError(
f"Failed to execute {tool_name}/{action}. Check server logs for details."
) from e

View File

@@ -20,33 +20,10 @@ class SubscriptionData:
last_updated: datetime # Must be timezone-aware (UTC)
subscription_type: str
@dataclass(slots=True)
class SystemHealth:
"""Container for system health status information.
Note: last_checked must be timezone-aware (use datetime.now(UTC)).
"""
is_healthy: bool
issues: list[str]
warnings: list[str]
last_checked: datetime # Must be timezone-aware (UTC)
component_status: dict[str, str]
@dataclass(slots=True)
class APIResponse:
"""Container for standardized API response data."""
success: bool
data: dict[str, Any] | None = None
error: str | None = None
metadata: dict[str, Any] | None = None
# Type aliases for common data structures
ConfigValue = str | int | bool | float | None
ConfigDict = dict[str, ConfigValue]
GraphQLVariables = dict[str, Any]
HealthStatus = dict[str, str | bool | int | list[Any]]
def __post_init__(self) -> None:
if self.last_updated.tzinfo is None:
raise ValueError(
"last_updated must be timezone-aware; use datetime.now(UTC)"
)
if not self.subscription_type.strip():
raise ValueError("subscription_type must be a non-empty string")

View File

@@ -1,6 +1,7 @@
"""Shared utility functions for Unraid MCP tools."""
from typing import Any
from urllib.parse import urlparse
def safe_get(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
@@ -45,6 +46,25 @@ def format_bytes(bytes_value: int | None) -> str:
return f"{value:.2f} EB"
def safe_display_url(url: str | None) -> str | None:
"""Return a redacted URL showing only scheme + host + port.
Strips path, query parameters, credentials, and fragments to avoid
leaking internal network topology or embedded secrets (CWE-200).
"""
if not url:
return None
try:
parsed = urlparse(url)
host = parsed.hostname or "unknown"
if parsed.port:
return f"{parsed.scheme}://{host}:{parsed.port}"
return f"{parsed.scheme}://{host}"
except ValueError:
# urlparse raises ValueError for invalid URLs (e.g. contains control chars)
return "<unparseable>"
def format_kb(k: Any) -> str:
"""Format kilobyte values into human-readable sizes.