mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 04:29:17 -07:00
Resolves review threads: - PRRT_kwDOO6Hdxs50fewG (setup.py): non-eliciting clients now return True from elicit_reset_confirmation so they can reconfigure without being blocked - PRRT_kwDOO6Hdxs50fewM (test-tools.sh): add notification/recalculate smoke test - PRRT_kwDOO6Hdxs50fewP (test-tools.sh): add system/array smoke test - PRRT_kwDOO6Hdxs50fewT (resources.py): surface manager error state instead of reporting 'connecting' for permanently failed subscriptions - PRRT_kwDOO6Hdxs50feAj (resources.py): use is not None check for empty cached dicts - PRRT_kwDOO6Hdxs50fewY (integration tests): remove duplicate snapshot-registration tests already covered in test_resources.py - PRRT_kwDOO6Hdxs50fewe (test_resources.py): replace brittle import-detail test with behavior tests for connecting/error states - PRRT_kwDOO6Hdxs50fewh (test_customization.py): strengthen public_theme assertion - PRRT_kwDOO6Hdxs50fewk (test_customization.py): strengthen theme assertion - PRRT_kwDOO6Hdxs50fewo (__init__.py): correct subaction count ~88 -> ~107 - PRRT_kwDOO6Hdxs50fewx (test_oidc.py): assert providers list value directly - PRRT_kwDOO6Hdxs50fewz (unraid.py): remove unreachable raise after vm handler - PRRT_kwDOO6Hdxs50few2 (unraid.py): remove unreachable raise after docker handler - PRRT_kwDOO6Hdxs50fev8 (CLAUDE.md): replace legacy 15-tool table with unified unraid action/subaction table - PRRT_kwDOO6Hdxs50fev_ (test_oidc.py): assert providers + defaultAllowedOrigins - PRRT_kwDOO6Hdxs50feAz (CLAUDE.md): update tool categories to unified API shape - PRRT_kwDOO6Hdxs50feBE (CLAUDE.md/setup.py): update unraid_health refs to unraid(action=health, subaction=setup)
756 lines
28 KiB
Python
756 lines
28 KiB
Python
"""Property-based tests for tool input validation.
|
|
|
|
Uses Hypothesis to fuzz tool inputs and verify the core invariant:
|
|
Tools MUST only raise ToolError (or return normally).
|
|
Any KeyError, AttributeError, TypeError, ValueError, IndexError, or
|
|
other unhandled exception from arbitrary inputs is a bug.
|
|
|
|
Each test class targets a distinct tool domain and strategy profile:
|
|
- Docker: arbitrary container IDs, subaction names, numeric params
|
|
- Notifications: importance strings, list_type strings, field lengths
|
|
- Keys: arbitrary key IDs, role lists, name strings
|
|
- VM: arbitrary VM IDs, subaction names
|
|
- Info: invalid subaction names (cross-tool invariant for the subaction guard)
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from fastmcp.exceptions import ToolError
|
|
from hypothesis import HealthCheck, given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
|
|
# Ensure tests/ is on sys.path so "from conftest import make_tool_fn" resolves
|
|
# the same way that top-level test files do.
|
|
_TESTS_DIR = str(Path(__file__).parent.parent)
|
|
if _TESTS_DIR not in sys.path:
|
|
sys.path.insert(0, _TESTS_DIR)
|
|
|
|
from conftest import make_tool_fn # noqa: E402
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ALLOWED_EXCEPTION_TYPES = (ToolError,)
|
|
"""Only ToolError (or a clean return) is acceptable from any tool call.
|
|
|
|
Any other exception is a bug — it means the tool let an internal error
|
|
surface to the caller instead of wrapping it in a user-friendly ToolError.
|
|
"""
|
|
|
|
|
|
def _run(coro) -> Any:
|
|
"""Run a coroutine synchronously so Hypothesis @given works with async tools."""
|
|
return asyncio.get_event_loop().run_until_complete(coro)
|
|
|
|
|
|
def _assert_only_tool_error(exc: BaseException) -> None:
|
|
"""Assert that an exception is a ToolError, not an internal crash."""
|
|
assert isinstance(exc, ToolError), (
|
|
f"Tool raised {type(exc).__name__} instead of ToolError: {exc!r}\n"
|
|
"This is a bug — all error paths must produce ToolError."
|
|
)
|
|
|
|
|
|
def _make_tool():
|
|
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Docker: arbitrary container IDs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDockerContainerIdFuzzing:
|
|
"""Fuzz the container_id parameter for Docker actions.
|
|
|
|
Invariant: no matter what string is supplied as container_id,
|
|
the tool must only raise ToolError or return normally — never crash
|
|
with a KeyError, AttributeError, or other internal exception.
|
|
"""
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_details_arbitrary_container_id(self, container_id: str) -> None:
|
|
"""Arbitrary container IDs for 'details' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {"docker": {"containers": []}}
|
|
with contextlib.suppress(ToolError):
|
|
# ToolError is the only acceptable exception — suppress it
|
|
await tool_fn(action="docker", subaction="details", container_id=container_id)
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_start_arbitrary_container_id(self, container_id: str) -> None:
|
|
"""Arbitrary container IDs for 'start' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {"docker": {"containers": []}}
|
|
with contextlib.suppress(ToolError):
|
|
await tool_fn(action="docker", subaction="start", container_id=container_id)
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_stop_arbitrary_container_id(self, container_id: str) -> None:
|
|
"""Arbitrary container IDs for 'stop' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {"docker": {"containers": []}}
|
|
with contextlib.suppress(ToolError):
|
|
await tool_fn(action="docker", subaction="stop", container_id=container_id)
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_restart_arbitrary_container_id(self, container_id: str) -> None:
|
|
"""Arbitrary container IDs for 'restart' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
# stop then start both need container list + mutation responses
|
|
mock.return_value = {"docker": {"containers": []}}
|
|
with contextlib.suppress(ToolError):
|
|
await tool_fn(action="docker", subaction="restart", container_id=container_id)
|
|
|
|
_run(_run_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Docker: invalid subaction names
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDockerInvalidActions:
|
|
"""Fuzz the subaction parameter with arbitrary strings for the docker domain.
|
|
|
|
Invariant: invalid subaction names raise ToolError, never KeyError or crash.
|
|
This validates the subaction guard that sits inside every domain handler.
|
|
"""
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
|
|
"""Any non-valid subaction string for docker must raise ToolError, not crash."""
|
|
valid_subactions = {
|
|
"list",
|
|
"details",
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"networks",
|
|
"network_details",
|
|
}
|
|
if subaction in valid_subactions:
|
|
return # Skip valid subactions — they have different semantics
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
|
|
try:
|
|
await tool_fn(action="docker", subaction=subaction)
|
|
except ToolError:
|
|
pass # Correct: invalid subaction raises ToolError
|
|
except Exception as exc:
|
|
# Any other exception is a bug
|
|
pytest.fail(
|
|
f"subaction={subaction!r} raised {type(exc).__name__} "
|
|
f"instead of ToolError: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notifications: importance and list_type enum fuzzing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNotificationsEnumFuzzing:
|
|
"""Fuzz notification enum parameters.
|
|
|
|
Invariant: invalid enum values must produce ToolError with a helpful message,
|
|
never crash with an AttributeError or unhandled exception.
|
|
"""
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=150, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_importance_raises_tool_error(self, importance: str) -> None:
|
|
"""Arbitrary importance strings must raise ToolError or be accepted if valid."""
|
|
valid_importances = {"INFO", "WARNING", "ALERT"}
|
|
if importance.upper() in valid_importances:
|
|
return # Skip valid values
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
mock.return_value = {
|
|
"createNotification": {"id": "1", "title": "t", "importance": "INFO"}
|
|
}
|
|
try:
|
|
await tool_fn(
|
|
action="notification",
|
|
subaction="create",
|
|
title="Test",
|
|
subject="Sub",
|
|
description="Desc",
|
|
importance=importance,
|
|
)
|
|
except ToolError:
|
|
pass # Expected for invalid importance
|
|
except Exception as exc:
|
|
pytest.fail(f"importance={importance!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=150, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_list_type_raises_tool_error(self, list_type: str) -> None:
|
|
"""Arbitrary list_type strings must raise ToolError or proceed if valid."""
|
|
valid_list_types = {"UNREAD", "ARCHIVE"}
|
|
if list_type.upper() in valid_list_types:
|
|
return # Skip valid values
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
mock.return_value = {"notifications": {"list": []}}
|
|
try:
|
|
await tool_fn(action="notification", subaction="list", list_type=list_type)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"list_type={list_type!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(
|
|
st.text(max_size=300), # title: limit is 200
|
|
st.text(max_size=600), # subject: limit is 500
|
|
st.text(max_size=2500), # description: limit is 2000
|
|
)
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_create_notification_field_lengths(
|
|
self, title: str, subject: str, description: str
|
|
) -> None:
|
|
"""Oversized title/subject/description must raise ToolError, not crash.
|
|
|
|
This tests the length-guard invariant: tools that have max-length checks
|
|
must raise ToolError for oversized values, never truncate silently or crash.
|
|
"""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
mock.return_value = {
|
|
"createNotification": {"id": "1", "title": "t", "importance": "INFO"}
|
|
}
|
|
try:
|
|
await tool_fn(
|
|
action="notification",
|
|
subaction="create",
|
|
title=title,
|
|
subject=subject,
|
|
description=description,
|
|
importance="INFO",
|
|
)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(
|
|
f"create with oversized fields raised {type(exc).__name__}: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_notification_type_raises_tool_error(self, notif_type: str) -> None:
|
|
"""Arbitrary notification_type strings must raise ToolError or proceed if valid."""
|
|
valid_types = {"UNREAD", "ARCHIVE"}
|
|
if notif_type.upper() in valid_types:
|
|
return
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
mock.return_value = {"deleteNotification": {}}
|
|
try:
|
|
await tool_fn(
|
|
action="notification",
|
|
subaction="delete",
|
|
notification_id="some-id",
|
|
notification_type=notif_type,
|
|
confirm=True,
|
|
)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(
|
|
f"notification_type={notif_type!r} raised {type(exc).__name__}: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
|
|
"""Invalid subaction names for notifications domain raise ToolError."""
|
|
valid_subactions = {
|
|
"overview",
|
|
"list",
|
|
"create",
|
|
"archive",
|
|
"mark_unread",
|
|
"delete",
|
|
"delete_archived",
|
|
"archive_all",
|
|
"archive_many",
|
|
"unarchive_many",
|
|
"unarchive_all",
|
|
"recalculate",
|
|
}
|
|
if subaction in valid_subactions:
|
|
return
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
):
|
|
try:
|
|
await tool_fn(action="notification", subaction=subaction)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(
|
|
f"subaction={subaction!r} raised {type(exc).__name__} "
|
|
f"instead of ToolError: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Keys: arbitrary key IDs and role lists
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestKeysInputFuzzing:
|
|
"""Fuzz API key management parameters.
|
|
|
|
Invariant: arbitrary key_id strings, names, and role lists never crash
|
|
the keys domain — only ToolError or clean return values are acceptable.
|
|
"""
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_get_arbitrary_key_id(self, key_id: str) -> None:
|
|
"""Arbitrary key_id for 'get' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {"apiKey": None}
|
|
try:
|
|
await tool_fn(action="key", subaction="get", key_id=key_id)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"key_id={key_id!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_create_arbitrary_key_name(self, name: str) -> None:
|
|
"""Arbitrary name strings for 'create' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {
|
|
"apiKey": {"create": {"id": "1", "name": name, "key": "k", "roles": []}}
|
|
}
|
|
try:
|
|
await tool_fn(action="key", subaction="create", name=name)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"name={name!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.lists(st.text(), min_size=1, max_size=10))
|
|
@settings(max_examples=80, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_add_role_arbitrary_roles(self, roles: list[str]) -> None:
|
|
"""Arbitrary role lists for 'add_role' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {"apiKey": {"addRole": True}}
|
|
try:
|
|
await tool_fn(
|
|
action="key", subaction="add_role", key_id="some-key-id", roles=roles
|
|
)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"roles={roles!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
|
|
"""Invalid subaction names for keys domain raise ToolError."""
|
|
valid_subactions = {"list", "get", "create", "update", "delete", "add_role", "remove_role"}
|
|
if subaction in valid_subactions:
|
|
return
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
|
|
try:
|
|
await tool_fn(action="key", subaction=subaction)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(
|
|
f"subaction={subaction!r} raised {type(exc).__name__} "
|
|
f"instead of ToolError: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# VM: arbitrary VM IDs and subaction names
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVMInputFuzzing:
|
|
"""Fuzz VM management parameters.
|
|
|
|
Invariant: arbitrary vm_id strings and subaction names must never crash
|
|
the VM domain — only ToolError or clean return values are acceptable.
|
|
"""
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_start_arbitrary_vm_id(self, vm_id: str) -> None:
|
|
"""Arbitrary vm_id for 'start' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
mock.return_value = {"vm": {"start": True}}
|
|
try:
|
|
await tool_fn(action="vm", subaction="start", vm_id=vm_id)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"vm_id={vm_id!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_stop_arbitrary_vm_id(self, vm_id: str) -> None:
|
|
"""Arbitrary vm_id for 'stop' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
mock.return_value = {"vm": {"stop": True}}
|
|
try:
|
|
await tool_fn(action="vm", subaction="stop", vm_id=vm_id)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"vm_id={vm_id!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_details_arbitrary_vm_id(self, vm_id: str) -> None:
|
|
"""Arbitrary vm_id for 'details' must not crash the tool."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
# Return an empty VM list so the lookup gracefully fails
|
|
mock.return_value = {"vms": {"domains": []}}
|
|
try:
|
|
await tool_fn(action="vm", subaction="details", vm_id=vm_id)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"vm_id={vm_id!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
|
|
"""Invalid subaction names for VM domain raise ToolError."""
|
|
valid_subactions = {
|
|
"list",
|
|
"details",
|
|
"start",
|
|
"stop",
|
|
"pause",
|
|
"resume",
|
|
"force_stop",
|
|
"reboot",
|
|
"reset",
|
|
}
|
|
if subaction in valid_subactions:
|
|
return
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
):
|
|
try:
|
|
await tool_fn(action="vm", subaction=subaction)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(
|
|
f"subaction={subaction!r} raised {type(exc).__name__} "
|
|
f"instead of ToolError: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-tool: boundary-value and unicode stress tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBoundaryValues:
|
|
"""Boundary-value and adversarial string tests across multiple tools.
|
|
|
|
These tests probe specific edge cases that have historically caused bugs
|
|
in similar systems: null bytes, very long strings, unicode surrogates,
|
|
and empty strings.
|
|
"""
|
|
|
|
@given(
|
|
st.one_of(
|
|
st.just(""),
|
|
st.just("\x00"),
|
|
st.just("\xff\xfe"),
|
|
st.just("a" * 10_001),
|
|
st.just("/" * 500),
|
|
st.just("'; DROP TABLE containers; --"),
|
|
st.just("${7*7}"),
|
|
st.just("\u0000\uffff"),
|
|
st.just("\n\r\t"),
|
|
st.binary().map(lambda b: b.decode("latin-1")),
|
|
)
|
|
)
|
|
@settings(max_examples=50, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_docker_details_adversarial_inputs(self, container_id: str) -> None:
|
|
"""Adversarial container_id values must not crash the Docker domain."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {"docker": {"containers": []}}
|
|
try:
|
|
await tool_fn(action="docker", subaction="details", container_id=container_id)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(
|
|
f"Adversarial input {container_id!r} raised {type(exc).__name__}: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|
|
|
|
@given(
|
|
st.one_of(
|
|
st.just(""),
|
|
st.just("\x00"),
|
|
st.just("a" * 100_000),
|
|
st.just("ALERT\x00"),
|
|
st.just("info"), # wrong case
|
|
st.just("Info"), # mixed case
|
|
st.just("UNKNOWN"),
|
|
st.just(" INFO "), # padded
|
|
)
|
|
)
|
|
@settings(max_examples=30, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_notifications_importance_adversarial(self, importance: str) -> None:
|
|
"""Adversarial importance values must raise ToolError, not crash."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request",
|
|
new_callable=AsyncMock,
|
|
) as mock:
|
|
mock.return_value = {
|
|
"createNotification": {"id": "1", "title": "t", "importance": "INFO"}
|
|
}
|
|
try:
|
|
await tool_fn(
|
|
action="notification",
|
|
subaction="create",
|
|
title="t",
|
|
subject="s",
|
|
description="d",
|
|
importance=importance,
|
|
)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"importance={importance!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
@given(
|
|
st.one_of(
|
|
st.just(""),
|
|
st.just("\x00"),
|
|
st.just("a" * 1_000_000), # extreme length
|
|
st.just("key with spaces"),
|
|
st.just("key\nnewline"),
|
|
)
|
|
)
|
|
@settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_keys_get_adversarial_key_ids(self, key_id: str) -> None:
|
|
"""Adversarial key_id values must not crash the keys get action."""
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch(
|
|
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
|
) as mock:
|
|
mock.return_value = {"apiKey": None}
|
|
try:
|
|
await tool_fn(action="key", subaction="get", key_id=key_id)
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(f"key_id={key_id!r} raised {type(exc).__name__}: {exc!r}")
|
|
|
|
_run(_run_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Top-level action guard (invalid domain names)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInfoActionGuard:
|
|
"""Fuzz the top-level action parameter (domain selector).
|
|
|
|
Invariant: the consolidated unraid tool must reject any invalid domain
|
|
with a ToolError rather than a KeyError crash.
|
|
"""
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invalid_action_raises_tool_error(self, action: str) -> None:
|
|
"""Invalid domain names raise ToolError."""
|
|
valid_actions = {
|
|
"array",
|
|
"customization",
|
|
"disk",
|
|
"docker",
|
|
"health",
|
|
"key",
|
|
"live",
|
|
"notification",
|
|
"oidc",
|
|
"plugin",
|
|
"rclone",
|
|
"setting",
|
|
"system",
|
|
"user",
|
|
"vm",
|
|
}
|
|
if action in valid_actions:
|
|
return
|
|
|
|
async def _run_test():
|
|
tool_fn = _make_tool()
|
|
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
|
|
try:
|
|
await tool_fn(action=action, subaction="list")
|
|
except ToolError:
|
|
pass
|
|
except Exception as exc:
|
|
pytest.fail(
|
|
f"Action {action!r} raised {type(exc).__name__} "
|
|
f"instead of ToolError: {exc!r}"
|
|
)
|
|
|
|
_run(_run_test())
|