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

@@ -3,7 +3,7 @@
Provides the `unraid_array` tool with 5 actions for parity check management.
"""
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -53,6 +53,14 @@ ARRAY_ACTIONS = Literal[
"parity_status",
]
if set(get_args(ARRAY_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(ARRAY_ACTIONS))
_extra = set(get_args(ARRAY_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"ARRAY_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
def register_array_tool(mcp: FastMCP) -> None:
"""Register the unraid_array tool with the FastMCP instance."""

View File

@@ -5,7 +5,7 @@ logs, networks, and update management.
"""
import re
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -135,6 +135,14 @@ DOCKER_ACTIONS = Literal[
"check_updates",
]
if set(get_args(DOCKER_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(DOCKER_ACTIONS))
_extra = set(get_args(DOCKER_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"DOCKER_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
# Full PrefixedID: 64 hex chars + optional suffix (e.g., ":local")
_DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$", re.IGNORECASE)
@@ -199,11 +207,6 @@ def get_available_container_names(containers: list[dict[str, Any]]) -> list[str]
return names
def _looks_like_container_id(identifier: str) -> bool:
"""Check if an identifier looks like a container ID (full or short hex prefix)."""
return bool(_DOCKER_ID_PATTERN.match(identifier) or _DOCKER_SHORT_ID_PATTERN.match(identifier))
async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str:
"""Resolve a container name/identifier to its actual PrefixedID.
@@ -233,12 +236,21 @@ async def _resolve_container_id(container_id: str, *, strict: bool = False) -> s
# Short hex prefix: match by ID prefix before trying name matching
if _DOCKER_SHORT_ID_PATTERN.match(container_id):
id_lower = container_id.lower()
matches: list[dict[str, Any]] = []
for c in containers:
cid = (c.get("id") or "").lower()
if cid.startswith(id_lower) or cid.split(":")[0].startswith(id_lower):
actual_id = str(c.get("id", ""))
logger.info(f"Resolved short ID '{container_id}' -> '{actual_id}'")
return actual_id
matches.append(c)
if len(matches) == 1:
actual_id = str(matches[0].get("id", ""))
logger.info(f"Resolved short ID '{container_id}' -> '{actual_id}'")
return actual_id
if len(matches) > 1:
candidate_ids = [str(c.get("id", "")) for c in matches[:5]]
raise ToolError(
f"Short container ID prefix '{container_id}' is ambiguous. "
f"Matches: {', '.join(candidate_ids)}. Use a longer ID or exact name."
)
resolved = find_container_by_identifier(container_id, containers, strict=strict)
if resolved:
@@ -303,7 +315,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
if action == "network_details" and not network_id:
raise ToolError("network_id is required for 'network_details' action")
if tail_lines < 1 or tail_lines > _MAX_TAIL_LINES:
if action == "logs" and (tail_lines < 1 or tail_lines > _MAX_TAIL_LINES):
raise ToolError(f"tail_lines must be between 1 and {_MAX_TAIL_LINES}, got {tail_lines}")
with tool_error_handler("docker", action, logger):
@@ -335,12 +347,12 @@ def register_docker_tool(mcp: FastMCP) -> None:
if action == "networks":
data = await make_graphql_request(QUERIES["networks"])
networks = data.get("dockerNetworks", [])
networks = safe_get(data, "dockerNetworks", default=[])
return {"networks": networks}
if action == "network_details":
data = await make_graphql_request(QUERIES["network_details"], {"id": network_id})
return dict(data.get("dockerNetwork") or {})
return dict(safe_get(data, "dockerNetwork", default={}) or {})
if action == "port_conflicts":
data = await make_graphql_request(QUERIES["port_conflicts"])

View File

@@ -6,8 +6,7 @@ connection testing, and subscription diagnostics.
import datetime
import time
from typing import Any, Literal
from urllib.parse import urlparse
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -21,31 +20,21 @@ from ..config.settings import (
)
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError, tool_error_handler
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>"
from ..core.utils import safe_display_url
ALL_ACTIONS = {"check", "test_connection", "diagnose"}
HEALTH_ACTIONS = Literal["check", "test_connection", "diagnose"]
if set(get_args(HEALTH_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(HEALTH_ACTIONS))
_extra = set(get_args(HEALTH_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
"HEALTH_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing in HEALTH_ACTIONS: {_missing}; extra in HEALTH_ACTIONS: {_extra}"
)
# Severity ordering: only upgrade, never downgrade
_SEVERITY = {"healthy": 0, "warning": 1, "degraded": 2, "unhealthy": 3}
@@ -149,7 +138,7 @@ async def _comprehensive_check() -> dict[str, Any]:
if info:
health_info["unraid_system"] = {
"status": "connected",
"url": _safe_display_url(UNRAID_API_URL),
"url": safe_display_url(UNRAID_API_URL),
"machine_id": info.get("machineId"),
"version": info.get("versions", {}).get("unraid"),
"uptime": info.get("os", {}).get("uptime"),
@@ -220,7 +209,7 @@ async def _comprehensive_check() -> dict[str, Any]:
except Exception as e:
# Intentionally broad: health checks must always return a result,
# even on unexpected failures, so callers never get an unhandled exception.
logger.error(f"Health check failed: {e}")
logger.error(f"Health check failed: {e}", exc_info=True)
return {
"status": "unhealthy",
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
@@ -293,10 +282,7 @@ async def _diagnose_subscriptions() -> dict[str, Any]:
},
}
except ImportError:
return {
"error": "Subscription modules not available",
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
}
except ImportError as e:
raise ToolError("Subscription modules not available") from e
except Exception as e:
raise ToolError(f"Failed to generate diagnostics: {e!s}") from e

View File

@@ -4,7 +4,7 @@ Provides the `unraid_info` tool with 19 read-only actions for retrieving
system information, array status, network config, and server metadata.
"""
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -180,9 +180,9 @@ INFO_ACTIONS = Literal[
"ups_config",
]
if set(INFO_ACTIONS.__args__) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(INFO_ACTIONS.__args__)
_extra = set(INFO_ACTIONS.__args__) - ALL_ACTIONS
if set(get_args(INFO_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(INFO_ACTIONS))
_extra = set(get_args(INFO_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"QUERIES keys and INFO_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
@@ -415,7 +415,8 @@ def register_info_tool(mcp: FastMCP) -> None:
if action in list_actions:
response_key, output_key = list_actions[action]
items = data.get(response_key) or []
return {output_key: items}
normalized_items = list(items) if isinstance(items, list) else []
return {output_key: normalized_items}
raise ToolError(f"Unhandled action '{action}' — this is a bug")

View File

@@ -4,7 +4,7 @@ Provides the `unraid_keys` tool with 5 actions for listing, viewing,
creating, updating, and deleting API keys.
"""
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -55,6 +55,14 @@ KEY_ACTIONS = Literal[
"delete",
]
if set(get_args(KEY_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(KEY_ACTIONS))
_extra = set(get_args(KEY_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"KEY_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
def register_keys_tool(mcp: FastMCP) -> None:
"""Register the unraid_keys tool with the FastMCP instance."""

View File

@@ -4,7 +4,7 @@ Provides the `unraid_notifications` tool with 9 actions for viewing,
creating, archiving, and deleting system notifications.
"""
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -91,6 +91,14 @@ NOTIFICATION_ACTIONS = Literal[
"archive_all",
]
if set(get_args(NOTIFICATION_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(NOTIFICATION_ACTIONS))
_extra = set(get_args(NOTIFICATION_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"NOTIFICATION_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
def register_notifications_tool(mcp: FastMCP) -> None:
"""Register the unraid_notifications tool with the FastMCP instance."""

View File

@@ -5,7 +5,7 @@ cloud storage remotes (S3, Google Drive, Dropbox, FTP, etc.).
"""
import re
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -50,10 +50,18 @@ RCLONE_ACTIONS = Literal[
"delete_remote",
]
if set(get_args(RCLONE_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(RCLONE_ACTIONS))
_extra = set(get_args(RCLONE_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"RCLONE_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
# Max config entries to prevent abuse
_MAX_CONFIG_KEYS = 50
# Pattern for suspicious key names (path traversal, shell metacharacters)
_DANGEROUS_KEY_PATTERN = re.compile(r"[.]{2}|[/\\;|`$(){}]")
_DANGEROUS_KEY_PATTERN = re.compile(r"\.\.|[/\\;|`$(){}]")
# Max length for individual config values
_MAX_VALUE_LENGTH = 4096

View File

@@ -5,7 +5,7 @@ unassigned devices, log files, and log content retrieval.
"""
import os
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -69,6 +69,14 @@ STORAGE_ACTIONS = Literal[
"logs",
]
if set(get_args(STORAGE_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(STORAGE_ACTIONS))
_extra = set(get_args(STORAGE_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"STORAGE_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
def register_storage_tool(mcp: FastMCP) -> None:
"""Register the unraid_storage tool with the FastMCP instance."""
@@ -96,7 +104,7 @@ def register_storage_tool(mcp: FastMCP) -> None:
if action == "disk_details" and not disk_id:
raise ToolError("disk_id is required for 'disk_details' action")
if tail_lines < 1 or tail_lines > _MAX_TAIL_LINES:
if action == "logs" and (tail_lines < 1 or tail_lines > _MAX_TAIL_LINES):
raise ToolError(f"tail_lines must be between 1 and {_MAX_TAIL_LINES}, got {tail_lines}")
if action == "logs":

View File

@@ -4,7 +4,7 @@ Provides the `unraid_vm` tool with 9 actions for VM lifecycle management
including start, stop, pause, resume, force stop, reboot, and reset.
"""
from typing import Any, Literal
from typing import Any, Literal, get_args
from fastmcp import FastMCP
@@ -73,6 +73,14 @@ VM_ACTIONS = Literal[
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
if set(get_args(VM_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(VM_ACTIONS))
_extra = set(get_args(VM_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"VM_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
def register_vm_tool(mcp: FastMCP) -> None:
"""Register the unraid_vm tool with the FastMCP instance."""