forked from HomeLab/unraid-mcp
refactor(tools)!: consolidate 15 individual tools into single unified unraid tool
BREAKING CHANGE: Replaces 15 separate MCP tools (unraid_info, unraid_array, unraid_storage, unraid_docker, unraid_vm, unraid_notifications, unraid_rclone, unraid_users, unraid_keys, unraid_health, unraid_settings, unraid_customization, unraid_plugins, unraid_oidc, unraid_live) with a single `unraid` tool using action (domain) + subaction (operation) routing. New interface: unraid(action="system", subaction="overview") replaces unraid_info(action="overview"). All 15 domains and ~108 subactions preserved. - Add unraid_mcp/tools/unraid.py (1891 lines, all domains consolidated) - Remove 15 individual tool files - Update tools/__init__.py to register single unified tool - Update server.py for new tool registration pattern - Update subscriptions/manager.py and resources.py for new tool names - Update all 25 test files + integration/contract/safety/schema/property tests - Update mcporter smoke-test script for new tool interface - Bump version 0.6.0 → 1.0.0 Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,7 @@ class DockerMutationResult(BaseModel):
|
||||
"""Shape returned by docker start/stop/pause/unpause mutations."""
|
||||
|
||||
success: bool
|
||||
action: str
|
||||
subaction: str
|
||||
container: Any = None
|
||||
|
||||
|
||||
@@ -287,48 +287,42 @@ class NotificationCreateResult(BaseModel):
|
||||
|
||||
@pytest.fixture
|
||||
def _docker_mock() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _info_mock() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.info.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _storage_mock() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.storage.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _notifications_mock() -> Generator[AsyncMock, None, None]:
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request", new_callable=AsyncMock
|
||||
) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _docker_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
def _info_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
def _storage_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
def _notifications_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -341,7 +335,7 @@ class TestDockerListContract:
|
||||
|
||||
async def test_list_result_has_containers_key(self, _docker_mock: AsyncMock) -> None:
|
||||
_docker_mock.return_value = {"docker": {"containers": []}}
|
||||
result = await _docker_tool()(action="list")
|
||||
result = await _docker_tool()(action="docker", subaction="list")
|
||||
DockerListResult(**result)
|
||||
|
||||
async def test_list_containers_conform_to_shape(self, _docker_mock: AsyncMock) -> None:
|
||||
@@ -353,14 +347,14 @@ class TestDockerListContract:
|
||||
]
|
||||
}
|
||||
}
|
||||
result = await _docker_tool()(action="list")
|
||||
result = await _docker_tool()(action="docker", subaction="list")
|
||||
validated = DockerListResult(**result)
|
||||
for container in validated.containers:
|
||||
DockerContainer(**container)
|
||||
|
||||
async def test_list_empty_containers_is_valid(self, _docker_mock: AsyncMock) -> None:
|
||||
_docker_mock.return_value = {"docker": {"containers": []}}
|
||||
result = await _docker_tool()(action="list")
|
||||
result = await _docker_tool()(action="docker", subaction="list")
|
||||
validated = DockerListResult(**result)
|
||||
assert validated.containers == []
|
||||
|
||||
@@ -369,7 +363,7 @@ class TestDockerListContract:
|
||||
_docker_mock.return_value = {
|
||||
"docker": {"containers": [{"id": "abc123", "names": ["plex"], "state": "running"}]}
|
||||
}
|
||||
result = await _docker_tool()(action="list")
|
||||
result = await _docker_tool()(action="docker", subaction="list")
|
||||
container_raw = result["containers"][0]
|
||||
DockerContainer(**container_raw)
|
||||
|
||||
@@ -378,7 +372,7 @@ class TestDockerListContract:
|
||||
_docker_mock.return_value = {
|
||||
"docker": {"containers": [{"id": "abc123", "state": "running"}]}
|
||||
}
|
||||
result = await _docker_tool()(action="list")
|
||||
result = await _docker_tool()(action="docker", subaction="list")
|
||||
with pytest.raises(ValidationError):
|
||||
DockerContainer(**result["containers"][0])
|
||||
|
||||
@@ -403,7 +397,7 @@ class TestDockerDetailsContract:
|
||||
]
|
||||
}
|
||||
}
|
||||
result = await _docker_tool()(action="details", container_id=cid)
|
||||
result = await _docker_tool()(action="docker", subaction="details", container_id=cid)
|
||||
DockerContainerDetails(**result)
|
||||
|
||||
async def test_details_has_required_fields(self, _docker_mock: AsyncMock) -> None:
|
||||
@@ -411,7 +405,7 @@ class TestDockerDetailsContract:
|
||||
_docker_mock.return_value = {
|
||||
"docker": {"containers": [{"id": cid, "names": ["sonarr"], "state": "exited"}]}
|
||||
}
|
||||
result = await _docker_tool()(action="details", container_id=cid)
|
||||
result = await _docker_tool()(action="docker", subaction="details", container_id=cid)
|
||||
assert "id" in result
|
||||
assert "names" in result
|
||||
assert "state" in result
|
||||
@@ -424,7 +418,7 @@ class TestDockerNetworksContract:
|
||||
_docker_mock.return_value = {
|
||||
"docker": {"networks": [{"id": "net:1", "name": "bridge", "driver": "bridge"}]}
|
||||
}
|
||||
result = await _docker_tool()(action="networks")
|
||||
result = await _docker_tool()(action="docker", subaction="networks")
|
||||
DockerNetworkListResult(**result)
|
||||
|
||||
async def test_network_entries_conform_to_shape(self, _docker_mock: AsyncMock) -> None:
|
||||
@@ -436,13 +430,13 @@ class TestDockerNetworksContract:
|
||||
]
|
||||
}
|
||||
}
|
||||
result = await _docker_tool()(action="networks")
|
||||
result = await _docker_tool()(action="docker", subaction="networks")
|
||||
for net in result["networks"]:
|
||||
DockerNetwork(**net)
|
||||
|
||||
async def test_empty_networks_is_valid(self, _docker_mock: AsyncMock) -> None:
|
||||
_docker_mock.return_value = {"docker": {"networks": []}}
|
||||
result = await _docker_tool()(action="networks")
|
||||
result = await _docker_tool()(action="docker", subaction="networks")
|
||||
validated = DockerNetworkListResult(**result)
|
||||
assert validated.networks == []
|
||||
|
||||
@@ -456,10 +450,10 @@ class TestDockerMutationContract:
|
||||
{"docker": {"containers": [{"id": cid, "names": ["plex"]}]}},
|
||||
{"docker": {"start": {"id": cid, "names": ["plex"], "state": "running"}}},
|
||||
]
|
||||
result = await _docker_tool()(action="start", container_id=cid)
|
||||
result = await _docker_tool()(action="docker", subaction="start", container_id=cid)
|
||||
validated = DockerMutationResult(**result)
|
||||
assert validated.success is True
|
||||
assert validated.action == "start"
|
||||
assert validated.subaction == "start"
|
||||
|
||||
async def test_stop_mutation_result_shape(self, _docker_mock: AsyncMock) -> None:
|
||||
cid = "d" * 64 + ":local"
|
||||
@@ -467,10 +461,10 @@ class TestDockerMutationContract:
|
||||
{"docker": {"containers": [{"id": cid, "names": ["nginx"]}]}},
|
||||
{"docker": {"stop": {"id": cid, "names": ["nginx"], "state": "exited"}}},
|
||||
]
|
||||
result = await _docker_tool()(action="stop", container_id=cid)
|
||||
result = await _docker_tool()(action="docker", subaction="stop", container_id=cid)
|
||||
validated = DockerMutationResult(**result)
|
||||
assert validated.success is True
|
||||
assert validated.action == "stop"
|
||||
assert validated.subaction == "stop"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -501,7 +495,7 @@ class TestInfoOverviewContract:
|
||||
"memory": {"layout": []},
|
||||
}
|
||||
}
|
||||
result = await _info_tool()(action="overview")
|
||||
result = await _info_tool()(action="system", subaction="overview")
|
||||
validated = InfoOverviewResult(**result)
|
||||
assert isinstance(validated.summary, dict)
|
||||
assert isinstance(validated.details, dict)
|
||||
@@ -521,7 +515,7 @@ class TestInfoOverviewContract:
|
||||
"memory": {"layout": []},
|
||||
}
|
||||
}
|
||||
result = await _info_tool()(action="overview")
|
||||
result = await _info_tool()(action="system", subaction="overview")
|
||||
InfoOverviewSummary(**result["summary"])
|
||||
assert result["summary"]["hostname"] == "myserver"
|
||||
|
||||
@@ -538,7 +532,7 @@ class TestInfoOverviewContract:
|
||||
"memory": {"layout": []},
|
||||
}
|
||||
_info_mock.return_value = {"info": raw_info}
|
||||
result = await _info_tool()(action="overview")
|
||||
result = await _info_tool()(action="system", subaction="overview")
|
||||
assert result["details"] == raw_info
|
||||
|
||||
|
||||
@@ -557,7 +551,7 @@ class TestInfoArrayContract:
|
||||
"boot": None,
|
||||
}
|
||||
}
|
||||
result = await _info_tool()(action="array")
|
||||
result = await _info_tool()(action="system", subaction="array")
|
||||
validated = InfoArrayResult(**result)
|
||||
assert isinstance(validated.summary, dict)
|
||||
assert isinstance(validated.details, dict)
|
||||
@@ -572,7 +566,7 @@ class TestInfoArrayContract:
|
||||
"caches": [],
|
||||
}
|
||||
}
|
||||
result = await _info_tool()(action="array")
|
||||
result = await _info_tool()(action="system", subaction="array")
|
||||
ArraySummary(**result["summary"])
|
||||
|
||||
async def test_array_health_overall_healthy(self, _info_mock: AsyncMock) -> None:
|
||||
@@ -585,7 +579,7 @@ class TestInfoArrayContract:
|
||||
"caches": [],
|
||||
}
|
||||
}
|
||||
result = await _info_tool()(action="array")
|
||||
result = await _info_tool()(action="system", subaction="array")
|
||||
assert result["summary"]["overall_health"] == "HEALTHY"
|
||||
|
||||
async def test_array_health_critical_with_failed_disk(self, _info_mock: AsyncMock) -> None:
|
||||
@@ -598,7 +592,7 @@ class TestInfoArrayContract:
|
||||
"caches": [],
|
||||
}
|
||||
}
|
||||
result = await _info_tool()(action="array")
|
||||
result = await _info_tool()(action="system", subaction="array")
|
||||
assert result["summary"]["overall_health"] == "CRITICAL"
|
||||
|
||||
|
||||
@@ -619,7 +613,7 @@ class TestInfoMetricsContract:
|
||||
},
|
||||
}
|
||||
}
|
||||
result = await _info_tool()(action="metrics")
|
||||
result = await _info_tool()(action="system", subaction="metrics")
|
||||
validated = InfoMetricsResult(**result)
|
||||
assert validated.cpu is not None
|
||||
assert validated.memory is not None
|
||||
@@ -628,7 +622,7 @@ class TestInfoMetricsContract:
|
||||
_info_mock.return_value = {
|
||||
"metrics": {"cpu": {"percentTotal": 75.3}, "memory": {"percentTotal": 60.0}}
|
||||
}
|
||||
result = await _info_tool()(action="metrics")
|
||||
result = await _info_tool()(action="system", subaction="metrics")
|
||||
cpu_pct = result["cpu"]["percentTotal"]
|
||||
assert 0.0 <= cpu_pct <= 100.0
|
||||
|
||||
@@ -643,14 +637,14 @@ class TestInfoServicesContract:
|
||||
{"name": "docker", "online": True, "version": "24.0"},
|
||||
]
|
||||
}
|
||||
result = await _info_tool()(action="services")
|
||||
result = await _info_tool()(action="system", subaction="services")
|
||||
validated = InfoServicesResult(**result)
|
||||
for svc in validated.services:
|
||||
ServiceEntry(**svc)
|
||||
|
||||
async def test_services_empty_list_is_valid(self, _info_mock: AsyncMock) -> None:
|
||||
_info_mock.return_value = {"services": []}
|
||||
result = await _info_tool()(action="services")
|
||||
result = await _info_tool()(action="system", subaction="services")
|
||||
InfoServicesResult(**result)
|
||||
assert result["services"] == []
|
||||
|
||||
@@ -660,13 +654,13 @@ class TestInfoOnlineContract:
|
||||
|
||||
async def test_online_true_shape(self, _info_mock: AsyncMock) -> None:
|
||||
_info_mock.return_value = {"online": True}
|
||||
result = await _info_tool()(action="online")
|
||||
result = await _info_tool()(action="system", subaction="online")
|
||||
validated = InfoOnlineResult(**result)
|
||||
assert validated.online is True
|
||||
|
||||
async def test_online_false_shape(self, _info_mock: AsyncMock) -> None:
|
||||
_info_mock.return_value = {"online": False}
|
||||
result = await _info_tool()(action="online")
|
||||
result = await _info_tool()(action="system", subaction="online")
|
||||
validated = InfoOnlineResult(**result)
|
||||
assert validated.online is False
|
||||
|
||||
@@ -687,7 +681,7 @@ class TestInfoNetworkContract:
|
||||
],
|
||||
"vars": {"port": 80, "portssl": 443, "localTld": "local", "useSsl": "no"},
|
||||
}
|
||||
result = await _info_tool()(action="network")
|
||||
result = await _info_tool()(action="system", subaction="network")
|
||||
validated = InfoNetworkResult(**result)
|
||||
assert isinstance(validated.accessUrls, list)
|
||||
|
||||
@@ -696,7 +690,7 @@ class TestInfoNetworkContract:
|
||||
"servers": [],
|
||||
"vars": {"port": 80, "portssl": 443, "localTld": "local", "useSsl": "no"},
|
||||
}
|
||||
result = await _info_tool()(action="network")
|
||||
result = await _info_tool()(action="system", subaction="network")
|
||||
validated = InfoNetworkResult(**result)
|
||||
assert validated.accessUrls == []
|
||||
|
||||
@@ -716,21 +710,21 @@ class TestStorageSharesContract:
|
||||
{"id": "share:2", "name": "appdata", "free": 200000, "used": 50000, "size": 250000},
|
||||
]
|
||||
}
|
||||
result = await _storage_tool()(action="shares")
|
||||
result = await _storage_tool()(action="disk", subaction="shares")
|
||||
validated = StorageSharesResult(**result)
|
||||
for share in validated.shares:
|
||||
ShareEntry(**share)
|
||||
|
||||
async def test_shares_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None:
|
||||
_storage_mock.return_value = {"shares": []}
|
||||
result = await _storage_tool()(action="shares")
|
||||
result = await _storage_tool()(action="disk", subaction="shares")
|
||||
StorageSharesResult(**result)
|
||||
assert result["shares"] == []
|
||||
|
||||
async def test_shares_missing_name_fails_contract(self, _storage_mock: AsyncMock) -> None:
|
||||
"""A share without required 'name' must fail contract validation."""
|
||||
_storage_mock.return_value = {"shares": [{"id": "share:1", "free": 100}]}
|
||||
result = await _storage_tool()(action="shares")
|
||||
result = await _storage_tool()(action="disk", subaction="shares")
|
||||
with pytest.raises(ValidationError):
|
||||
ShareEntry(**result["shares"][0])
|
||||
|
||||
@@ -745,14 +739,14 @@ class TestStorageDisksContract:
|
||||
{"id": "disk:2", "device": "sdb", "name": "Seagate_8TB"},
|
||||
]
|
||||
}
|
||||
result = await _storage_tool()(action="disks")
|
||||
result = await _storage_tool()(action="disk", subaction="disks")
|
||||
validated = StorageDisksResult(**result)
|
||||
for disk in validated.disks:
|
||||
DiskEntry(**disk)
|
||||
|
||||
async def test_disks_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None:
|
||||
_storage_mock.return_value = {"disks": []}
|
||||
result = await _storage_tool()(action="disks")
|
||||
result = await _storage_tool()(action="disk", subaction="disks")
|
||||
StorageDisksResult(**result)
|
||||
assert result["disks"] == []
|
||||
|
||||
@@ -771,7 +765,7 @@ class TestStorageDiskDetailsContract:
|
||||
"temperature": 35,
|
||||
}
|
||||
}
|
||||
result = await _storage_tool()(action="disk_details", disk_id="disk:1")
|
||||
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:1")
|
||||
validated = StorageDiskDetailsResult(**result)
|
||||
assert isinstance(validated.summary, dict)
|
||||
assert isinstance(validated.details, dict)
|
||||
@@ -787,7 +781,7 @@ class TestStorageDiskDetailsContract:
|
||||
"temperature": 40,
|
||||
}
|
||||
}
|
||||
result = await _storage_tool()(action="disk_details", disk_id="disk:2")
|
||||
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:2")
|
||||
DiskDetailsSummary(**result["summary"])
|
||||
|
||||
async def test_disk_details_temperature_formatted(self, _storage_mock: AsyncMock) -> None:
|
||||
@@ -801,7 +795,7 @@ class TestStorageDiskDetailsContract:
|
||||
"temperature": 38,
|
||||
}
|
||||
}
|
||||
result = await _storage_tool()(action="disk_details", disk_id="disk:3")
|
||||
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:3")
|
||||
assert "°C" in result["summary"]["temperature"]
|
||||
|
||||
async def test_disk_details_no_temperature_shows_na(self, _storage_mock: AsyncMock) -> None:
|
||||
@@ -815,7 +809,7 @@ class TestStorageDiskDetailsContract:
|
||||
"temperature": None,
|
||||
}
|
||||
}
|
||||
result = await _storage_tool()(action="disk_details", disk_id="disk:4")
|
||||
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:4")
|
||||
assert result["summary"]["temperature"] == "N/A"
|
||||
|
||||
|
||||
@@ -839,14 +833,14 @@ class TestStorageLogFilesContract:
|
||||
},
|
||||
]
|
||||
}
|
||||
result = await _storage_tool()(action="log_files")
|
||||
result = await _storage_tool()(action="disk", subaction="log_files")
|
||||
validated = StorageLogFilesResult(**result)
|
||||
for log_file in validated.log_files:
|
||||
LogFileEntry(**log_file)
|
||||
|
||||
async def test_log_files_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None:
|
||||
_storage_mock.return_value = {"logFiles": []}
|
||||
result = await _storage_tool()(action="log_files")
|
||||
result = await _storage_tool()(action="disk", subaction="log_files")
|
||||
StorageLogFilesResult(**result)
|
||||
assert result["log_files"] == []
|
||||
|
||||
@@ -868,7 +862,7 @@ class TestNotificationsOverviewContract:
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await _notifications_tool()(action="overview")
|
||||
result = await _notifications_tool()(action="notification", subaction="overview")
|
||||
validated = NotificationOverviewResult(**result)
|
||||
assert validated.unread is not None
|
||||
assert validated.archive is not None
|
||||
@@ -882,7 +876,7 @@ class TestNotificationsOverviewContract:
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await _notifications_tool()(action="overview")
|
||||
result = await _notifications_tool()(action="notification", subaction="overview")
|
||||
NotificationCountBucket(**result["unread"])
|
||||
NotificationCountBucket(**result["archive"])
|
||||
|
||||
@@ -895,7 +889,7 @@ class TestNotificationsOverviewContract:
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await _notifications_tool()(action="overview")
|
||||
result = await _notifications_tool()(action="notification", subaction="overview")
|
||||
NotificationOverviewResult(**result)
|
||||
|
||||
|
||||
@@ -920,14 +914,14 @@ class TestNotificationsListContract:
|
||||
]
|
||||
}
|
||||
}
|
||||
result = await _notifications_tool()(action="list")
|
||||
result = await _notifications_tool()(action="notification", subaction="list")
|
||||
validated = NotificationListResult(**result)
|
||||
for notif in validated.notifications:
|
||||
NotificationEntry(**notif)
|
||||
|
||||
async def test_list_empty_notifications_valid(self, _notifications_mock: AsyncMock) -> None:
|
||||
_notifications_mock.return_value = {"notifications": {"list": []}}
|
||||
result = await _notifications_tool()(action="list")
|
||||
result = await _notifications_tool()(action="notification", subaction="list")
|
||||
NotificationListResult(**result)
|
||||
assert result["notifications"] == []
|
||||
|
||||
@@ -938,7 +932,7 @@ class TestNotificationsListContract:
|
||||
_notifications_mock.return_value = {
|
||||
"notifications": {"list": [{"title": "No ID here", "importance": "INFO"}]}
|
||||
}
|
||||
result = await _notifications_tool()(action="list")
|
||||
result = await _notifications_tool()(action="notification", subaction="list")
|
||||
with pytest.raises(ValidationError):
|
||||
NotificationEntry(**result["notifications"][0])
|
||||
|
||||
@@ -955,7 +949,8 @@ class TestNotificationsCreateContract:
|
||||
}
|
||||
}
|
||||
result = await _notifications_tool()(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="Test notification",
|
||||
subject="Test subject",
|
||||
description="This is a test",
|
||||
@@ -970,7 +965,8 @@ class TestNotificationsCreateContract:
|
||||
"createNotification": {"id": "notif:42", "title": "Alert!", "importance": "ALERT"}
|
||||
}
|
||||
result = await _notifications_tool()(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="Alert!",
|
||||
subject="Critical issue",
|
||||
description="Something went wrong",
|
||||
|
||||
@@ -261,11 +261,11 @@ class TestGraphQLErrorHandling:
|
||||
|
||||
|
||||
class TestInfoToolRequests:
|
||||
"""Verify unraid_info tool constructs correct GraphQL queries."""
|
||||
"""Verify unraid system tool constructs correct GraphQL queries."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_overview_sends_correct_query(self) -> None:
|
||||
@@ -281,7 +281,7 @@ class TestInfoToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="overview")
|
||||
await tool(action="system", subaction="overview")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetSystemInfo" in body["query"]
|
||||
assert "info" in body["query"]
|
||||
@@ -292,7 +292,7 @@ class TestInfoToolRequests:
|
||||
return_value=_graphql_response({"array": {"state": "STARTED", "capacity": {}}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="array")
|
||||
await tool(action="system", subaction="array")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetArrayStatus" in body["query"]
|
||||
|
||||
@@ -302,7 +302,7 @@ class TestInfoToolRequests:
|
||||
return_value=_graphql_response({"network": {"id": "n1", "accessUrls": []}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="network")
|
||||
await tool(action="system", subaction="network")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetNetworkInfo" in body["query"]
|
||||
|
||||
@@ -314,7 +314,7 @@ class TestInfoToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="metrics")
|
||||
await tool(action="system", subaction="metrics")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetMetrics" in body["query"]
|
||||
|
||||
@@ -324,7 +324,7 @@ class TestInfoToolRequests:
|
||||
return_value=_graphql_response({"upsDeviceById": {"id": "ups1", "model": "APC"}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="ups_device", device_id="ups1")
|
||||
await tool(action="system", subaction="ups_device", device_id="ups1")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert body["variables"] == {"id": "ups1"}
|
||||
assert "GetUpsDevice" in body["query"]
|
||||
@@ -333,7 +333,7 @@ class TestInfoToolRequests:
|
||||
async def test_online_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"online": True}))
|
||||
tool = self._get_tool()
|
||||
await tool(action="online")
|
||||
await tool(action="system", subaction="online")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetOnline" in body["query"]
|
||||
|
||||
@@ -343,7 +343,7 @@ class TestInfoToolRequests:
|
||||
return_value=_graphql_response({"servers": [{"id": "s1", "name": "tower"}]})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="servers")
|
||||
await tool(action="system", subaction="servers")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetServers" in body["query"]
|
||||
|
||||
@@ -353,7 +353,7 @@ class TestInfoToolRequests:
|
||||
return_value=_graphql_response({"flash": {"id": "f1", "guid": "abc"}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="flash")
|
||||
await tool(action="system", subaction="flash")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetFlash" in body["query"]
|
||||
|
||||
@@ -364,11 +364,11 @@ class TestInfoToolRequests:
|
||||
|
||||
|
||||
class TestDockerToolRequests:
|
||||
"""Verify unraid_docker tool constructs correct requests."""
|
||||
"""Verify unraid docker tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_list_sends_correct_query(self) -> None:
|
||||
@@ -378,7 +378,7 @@ class TestDockerToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="list")
|
||||
await tool(action="docker", subaction="list")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListDockerContainers" in body["query"]
|
||||
|
||||
@@ -400,7 +400,7 @@ class TestDockerToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="start", container_id=container_id)
|
||||
await tool(action="docker", subaction="start", container_id=container_id)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "StartContainer" in body["query"]
|
||||
assert body["variables"] == {"id": container_id}
|
||||
@@ -423,7 +423,7 @@ class TestDockerToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="stop", container_id=container_id)
|
||||
await tool(action="docker", subaction="stop", container_id=container_id)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "StopContainer" in body["query"]
|
||||
assert body["variables"] == {"id": container_id}
|
||||
@@ -440,7 +440,7 @@ class TestDockerToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="networks")
|
||||
await tool(action="docker", subaction="networks")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetDockerNetworks" in body["query"]
|
||||
|
||||
@@ -484,9 +484,9 @@ class TestDockerToolRequests:
|
||||
|
||||
respx.post(API_URL).mock(side_effect=side_effect)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="restart", container_id=container_id)
|
||||
result = await tool(action="docker", subaction="restart", container_id=container_id)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "restart"
|
||||
assert result["subaction"] == "restart"
|
||||
assert call_count == 2
|
||||
|
||||
@respx.mock
|
||||
@@ -499,7 +499,8 @@ class TestDockerToolRequests:
|
||||
nonlocal call_count
|
||||
body = json.loads(request.content.decode())
|
||||
call_count += 1
|
||||
if "ResolveContainerID" in body["query"]:
|
||||
if "skipCache" in body["query"]:
|
||||
# Resolution query: docker { containers(skipCache: true) { id names } }
|
||||
return _graphql_response(
|
||||
{"docker": {"containers": [{"id": resolved_id, "names": ["plex"]}]}}
|
||||
)
|
||||
@@ -520,7 +521,7 @@ class TestDockerToolRequests:
|
||||
|
||||
respx.post(API_URL).mock(side_effect=side_effect)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="start", container_id="plex")
|
||||
result = await tool(action="docker", subaction="start", container_id="plex")
|
||||
assert call_count == 2 # resolve + start
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -531,11 +532,11 @@ class TestDockerToolRequests:
|
||||
|
||||
|
||||
class TestVMToolRequests:
|
||||
"""Verify unraid_vm tool constructs correct requests."""
|
||||
"""Verify unraid vm tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_list_sends_correct_query(self) -> None:
|
||||
@@ -549,7 +550,7 @@ class TestVMToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="list")
|
||||
result = await tool(action="vm", subaction="list")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListVMs" in body["query"]
|
||||
assert "vms" in result
|
||||
@@ -558,7 +559,7 @@ class TestVMToolRequests:
|
||||
async def test_start_sends_mutation_with_id(self) -> None:
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"vm": {"start": True}}))
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="start", vm_id="vm-123")
|
||||
result = await tool(action="vm", subaction="start", vm_id="vm-123")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "StartVM" in body["query"]
|
||||
assert body["variables"] == {"id": "vm-123"}
|
||||
@@ -568,7 +569,7 @@ class TestVMToolRequests:
|
||||
async def test_stop_sends_mutation_with_id(self) -> None:
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"vm": {"stop": True}}))
|
||||
tool = self._get_tool()
|
||||
await tool(action="stop", vm_id="vm-456")
|
||||
await tool(action="vm", subaction="stop", vm_id="vm-456")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "StopVM" in body["query"]
|
||||
assert body["variables"] == {"id": "vm-456"}
|
||||
@@ -577,7 +578,7 @@ class TestVMToolRequests:
|
||||
async def test_force_stop_requires_confirm(self) -> None:
|
||||
tool = self._get_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool(action="force_stop", vm_id="vm-789")
|
||||
await tool(action="vm", subaction="force_stop", vm_id="vm-789")
|
||||
|
||||
@respx.mock
|
||||
async def test_force_stop_sends_mutation_when_confirmed(self) -> None:
|
||||
@@ -585,7 +586,7 @@ class TestVMToolRequests:
|
||||
return_value=_graphql_response({"vm": {"forceStop": True}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="force_stop", vm_id="vm-789", confirm=True)
|
||||
result = await tool(action="vm", subaction="force_stop", vm_id="vm-789", confirm=True)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ForceStopVM" in body["query"]
|
||||
assert result["success"] is True
|
||||
@@ -594,7 +595,7 @@ class TestVMToolRequests:
|
||||
async def test_reset_requires_confirm(self) -> None:
|
||||
tool = self._get_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool(action="reset", vm_id="vm-abc")
|
||||
await tool(action="vm", subaction="reset", vm_id="vm-abc")
|
||||
|
||||
@respx.mock
|
||||
async def test_details_finds_vm_by_name(self) -> None:
|
||||
@@ -611,7 +612,7 @@ class TestVMToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="details", vm_id="ubuntu")
|
||||
result = await tool(action="vm", subaction="details", vm_id="ubuntu")
|
||||
assert result["name"] == "ubuntu"
|
||||
|
||||
|
||||
@@ -621,11 +622,11 @@ class TestVMToolRequests:
|
||||
|
||||
|
||||
class TestArrayToolRequests:
|
||||
"""Verify unraid_array tool constructs correct requests."""
|
||||
"""Verify unraid array tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_parity_status_sends_correct_query(self) -> None:
|
||||
@@ -643,7 +644,7 @@ class TestArrayToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="parity_status")
|
||||
result = await tool(action="array", subaction="parity_status")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetParityStatus" in body["query"]
|
||||
assert result["success"] is True
|
||||
@@ -654,7 +655,7 @@ class TestArrayToolRequests:
|
||||
return_value=_graphql_response({"parityCheck": {"start": True}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="parity_start", correct=False)
|
||||
result = await tool(action="array", subaction="parity_start", correct=False)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "StartParityCheck" in body["query"]
|
||||
assert body["variables"] == {"correct": False}
|
||||
@@ -666,7 +667,7 @@ class TestArrayToolRequests:
|
||||
return_value=_graphql_response({"parityCheck": {"start": True}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="parity_start", correct=True)
|
||||
await tool(action="array", subaction="parity_start", correct=True)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert body["variables"] == {"correct": True}
|
||||
|
||||
@@ -676,7 +677,7 @@ class TestArrayToolRequests:
|
||||
return_value=_graphql_response({"parityCheck": {"pause": True}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="parity_pause")
|
||||
await tool(action="array", subaction="parity_pause")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "PauseParityCheck" in body["query"]
|
||||
|
||||
@@ -686,7 +687,7 @@ class TestArrayToolRequests:
|
||||
return_value=_graphql_response({"parityCheck": {"cancel": True}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="parity_cancel")
|
||||
await tool(action="array", subaction="parity_cancel")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "CancelParityCheck" in body["query"]
|
||||
|
||||
@@ -697,11 +698,11 @@ class TestArrayToolRequests:
|
||||
|
||||
|
||||
class TestStorageToolRequests:
|
||||
"""Verify unraid_storage tool constructs correct requests."""
|
||||
"""Verify unraid disk tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_shares_sends_correct_query(self) -> None:
|
||||
@@ -709,7 +710,7 @@ class TestStorageToolRequests:
|
||||
return_value=_graphql_response({"shares": [{"id": "s1", "name": "appdata"}]})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="shares")
|
||||
result = await tool(action="disk", subaction="shares")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetSharesInfo" in body["query"]
|
||||
assert "shares" in result
|
||||
@@ -722,7 +723,7 @@ class TestStorageToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="disks")
|
||||
await tool(action="disk", subaction="disks")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListPhysicalDisks" in body["query"]
|
||||
|
||||
@@ -743,7 +744,7 @@ class TestStorageToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="disk_details", disk_id="d1")
|
||||
await tool(action="disk", subaction="disk_details", disk_id="d1")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetDiskDetails" in body["query"]
|
||||
assert body["variables"] == {"id": "d1"}
|
||||
@@ -756,7 +757,7 @@ class TestStorageToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="log_files")
|
||||
result = await tool(action="disk", subaction="log_files")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListLogFiles" in body["query"]
|
||||
assert "log_files" in result
|
||||
@@ -776,7 +777,7 @@ class TestStorageToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="logs", log_path="/var/log/syslog", tail_lines=50)
|
||||
await tool(action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=50)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetLogContent" in body["query"]
|
||||
assert body["variables"]["path"] == "/var/log/syslog"
|
||||
@@ -786,7 +787,7 @@ class TestStorageToolRequests:
|
||||
async def test_logs_rejects_path_traversal(self) -> None:
|
||||
tool = self._get_tool()
|
||||
with pytest.raises(ToolError, match="log_path must start with"):
|
||||
await tool(action="logs", log_path="/etc/shadow")
|
||||
await tool(action="disk", subaction="logs", log_path="/etc/shadow")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -795,15 +796,11 @@ class TestStorageToolRequests:
|
||||
|
||||
|
||||
class TestNotificationsToolRequests:
|
||||
"""Verify unraid_notifications tool constructs correct requests."""
|
||||
"""Verify unraid notification tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_overview_sends_correct_query(self) -> None:
|
||||
@@ -819,7 +816,7 @@ class TestNotificationsToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="overview")
|
||||
await tool(action="notification", subaction="overview")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetNotificationsOverview" in body["query"]
|
||||
|
||||
@@ -829,7 +826,14 @@ class TestNotificationsToolRequests:
|
||||
return_value=_graphql_response({"notifications": {"list": []}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="list", list_type="ARCHIVE", importance="WARNING", offset=5, limit=10)
|
||||
await tool(
|
||||
action="notification",
|
||||
subaction="list",
|
||||
list_type="ARCHIVE",
|
||||
importance="WARNING",
|
||||
offset=5,
|
||||
limit=10,
|
||||
)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListNotifications" in body["query"]
|
||||
filt = body["variables"]["filter"]
|
||||
@@ -853,7 +857,8 @@ class TestNotificationsToolRequests:
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="Test",
|
||||
subject="Sub",
|
||||
description="Desc",
|
||||
@@ -872,7 +877,7 @@ class TestNotificationsToolRequests:
|
||||
return_value=_graphql_response({"archiveNotification": {"id": "notif-1"}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="archive", notification_id="notif-1")
|
||||
await tool(action="notification", subaction="archive", notification_id="notif-1")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ArchiveNotification" in body["query"]
|
||||
assert body["variables"] == {"id": "notif-1"}
|
||||
@@ -881,7 +886,12 @@ class TestNotificationsToolRequests:
|
||||
async def test_delete_requires_confirm(self) -> None:
|
||||
tool = self._get_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool(action="delete", notification_id="n1", notification_type="UNREAD")
|
||||
await tool(
|
||||
action="notification",
|
||||
subaction="delete",
|
||||
notification_id="n1",
|
||||
notification_type="UNREAD",
|
||||
)
|
||||
|
||||
@respx.mock
|
||||
async def test_delete_sends_id_and_type(self) -> None:
|
||||
@@ -890,7 +900,8 @@ class TestNotificationsToolRequests:
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(
|
||||
action="delete",
|
||||
action="notification",
|
||||
subaction="delete",
|
||||
notification_id="n1",
|
||||
notification_type="unread",
|
||||
confirm=True,
|
||||
@@ -906,7 +917,7 @@ class TestNotificationsToolRequests:
|
||||
return_value=_graphql_response({"archiveAll": {"archive": {"total": 1}}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="archive_all", importance="warning")
|
||||
await tool(action="notification", subaction="archive_all", importance="warning")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ArchiveAllNotifications" in body["query"]
|
||||
assert body["variables"]["importance"] == "WARNING"
|
||||
@@ -918,11 +929,11 @@ class TestNotificationsToolRequests:
|
||||
|
||||
|
||||
class TestRCloneToolRequests:
|
||||
"""Verify unraid_rclone tool constructs correct requests."""
|
||||
"""Verify unraid rclone tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_list_remotes_sends_correct_query(self) -> None:
|
||||
@@ -932,7 +943,7 @@ class TestRCloneToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="list_remotes")
|
||||
result = await tool(action="rclone", subaction="list_remotes")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListRCloneRemotes" in body["query"]
|
||||
assert "remotes" in result
|
||||
@@ -953,7 +964,7 @@ class TestRCloneToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="config_form", provider_type="s3")
|
||||
await tool(action="rclone", subaction="config_form", provider_type="s3")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetRCloneConfigForm" in body["query"]
|
||||
assert body["variables"]["formOptions"]["providerType"] == "s3"
|
||||
@@ -975,7 +986,8 @@ class TestRCloneToolRequests:
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="my-s3",
|
||||
provider_type="s3",
|
||||
config_data={"bucket": "my-bucket"},
|
||||
@@ -991,7 +1003,7 @@ class TestRCloneToolRequests:
|
||||
async def test_delete_remote_requires_confirm(self) -> None:
|
||||
tool = self._get_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool(action="delete_remote", name="old-remote")
|
||||
await tool(action="rclone", subaction="delete_remote", name="old-remote")
|
||||
|
||||
@respx.mock
|
||||
async def test_delete_remote_sends_name_when_confirmed(self) -> None:
|
||||
@@ -999,7 +1011,9 @@ class TestRCloneToolRequests:
|
||||
return_value=_graphql_response({"rclone": {"deleteRCloneRemote": True}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="delete_remote", name="old-remote", confirm=True)
|
||||
result = await tool(
|
||||
action="rclone", subaction="delete_remote", name="old-remote", confirm=True
|
||||
)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "DeleteRCloneRemote" in body["query"]
|
||||
assert body["variables"]["input"]["name"] == "old-remote"
|
||||
@@ -1012,11 +1026,11 @@ class TestRCloneToolRequests:
|
||||
|
||||
|
||||
class TestUsersToolRequests:
|
||||
"""Verify unraid_users tool constructs correct requests."""
|
||||
"""Verify unraid user tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.users", "register_users_tool", "unraid_users")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_me_sends_correct_query(self) -> None:
|
||||
@@ -1033,7 +1047,7 @@ class TestUsersToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="me")
|
||||
result = await tool(action="user", subaction="me")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetMe" in body["query"]
|
||||
assert result["name"] == "admin"
|
||||
@@ -1045,11 +1059,11 @@ class TestUsersToolRequests:
|
||||
|
||||
|
||||
class TestKeysToolRequests:
|
||||
"""Verify unraid_keys tool constructs correct requests."""
|
||||
"""Verify unraid key tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_list_sends_correct_query(self) -> None:
|
||||
@@ -1057,7 +1071,7 @@ class TestKeysToolRequests:
|
||||
return_value=_graphql_response({"apiKeys": [{"id": "k1", "name": "my-key"}]})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="list")
|
||||
result = await tool(action="key", subaction="list")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListApiKeys" in body["query"]
|
||||
assert "keys" in result
|
||||
@@ -1070,7 +1084,7 @@ class TestKeysToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="get", key_id="k1")
|
||||
await tool(action="key", subaction="get", key_id="k1")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "GetApiKey" in body["query"]
|
||||
assert body["variables"] == {"id": "k1"}
|
||||
@@ -1092,7 +1106,7 @@ class TestKeysToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="create", name="new-key", roles=["read"])
|
||||
result = await tool(action="key", subaction="create", name="new-key", roles=["read"])
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "CreateApiKey" in body["query"]
|
||||
inp = body["variables"]["input"]
|
||||
@@ -1108,7 +1122,7 @@ class TestKeysToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="update", key_id="k1", name="renamed")
|
||||
await tool(action="key", subaction="update", key_id="k1", name="renamed")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "UpdateApiKey" in body["query"]
|
||||
inp = body["variables"]["input"]
|
||||
@@ -1119,7 +1133,7 @@ class TestKeysToolRequests:
|
||||
async def test_delete_requires_confirm(self) -> None:
|
||||
tool = self._get_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool(action="delete", key_id="k1")
|
||||
await tool(action="key", subaction="delete", key_id="k1")
|
||||
|
||||
@respx.mock
|
||||
async def test_delete_sends_ids_when_confirmed(self) -> None:
|
||||
@@ -1127,7 +1141,7 @@ class TestKeysToolRequests:
|
||||
return_value=_graphql_response({"apiKey": {"delete": True}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="delete", key_id="k1", confirm=True)
|
||||
result = await tool(action="key", subaction="delete", key_id="k1", confirm=True)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "DeleteApiKey" in body["query"]
|
||||
assert body["variables"]["input"]["ids"] == ["k1"]
|
||||
@@ -1140,17 +1154,17 @@ class TestKeysToolRequests:
|
||||
|
||||
|
||||
class TestHealthToolRequests:
|
||||
"""Verify unraid_health tool constructs correct requests."""
|
||||
"""Verify unraid health tool constructs correct requests."""
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.health", "register_health_tool", "unraid_health")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
@respx.mock
|
||||
async def test_test_connection_sends_online_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"online": True}))
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="test_connection")
|
||||
result = await tool(action="health", subaction="test_connection")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "online" in body["query"]
|
||||
assert result["status"] == "connected"
|
||||
@@ -1178,7 +1192,7 @@ class TestHealthToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="check")
|
||||
result = await tool(action="health", subaction="check")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ComprehensiveHealthCheck" in body["query"]
|
||||
assert result["status"] == "healthy"
|
||||
@@ -1188,7 +1202,7 @@ class TestHealthToolRequests:
|
||||
async def test_test_connection_measures_latency(self) -> None:
|
||||
respx.post(API_URL).mock(return_value=_graphql_response({"online": True}))
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="test_connection")
|
||||
result = await tool(action="health", subaction="test_connection")
|
||||
assert "latency_ms" in result
|
||||
assert isinstance(result["latency_ms"], float)
|
||||
|
||||
@@ -1212,7 +1226,7 @@ class TestHealthToolRequests:
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="check")
|
||||
result = await tool(action="health", subaction="check")
|
||||
assert result["status"] == "warning"
|
||||
assert any("alert" in issue for issue in result.get("issues", []))
|
||||
|
||||
@@ -1249,17 +1263,17 @@ class TestCrossCuttingConcerns:
|
||||
async def test_tool_error_from_http_layer_propagates(self) -> None:
|
||||
"""When an HTTP error occurs, the ToolError bubbles up through the tool."""
|
||||
respx.post(API_URL).mock(return_value=httpx.Response(500, text="Server Error"))
|
||||
tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
tool = make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
with pytest.raises(ToolError, match="Unraid API returned HTTP 500"):
|
||||
await tool(action="online")
|
||||
await tool(action="system", subaction="online")
|
||||
|
||||
@respx.mock
|
||||
async def test_network_error_propagates_through_tool(self) -> None:
|
||||
"""When a network error occurs, the ToolError bubbles up through the tool."""
|
||||
respx.post(API_URL).mock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
tool = make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
with pytest.raises(ToolError, match="Network error connecting to Unraid API"):
|
||||
await tool(action="online")
|
||||
await tool(action="system", subaction="online")
|
||||
|
||||
@respx.mock
|
||||
async def test_graphql_error_propagates_through_tool(self) -> None:
|
||||
@@ -1267,6 +1281,6 @@ class TestCrossCuttingConcerns:
|
||||
respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(errors=[{"message": "Permission denied"}])
|
||||
)
|
||||
tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
tool = make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
with pytest.raises(ToolError, match="Permission denied"):
|
||||
await tool(action="online")
|
||||
await tool(action="system", subaction="online")
|
||||
|
||||
@@ -125,6 +125,24 @@ class TestSubscriptionManagerInit:
|
||||
cfg = mgr.subscription_configs["logFileSubscription"]
|
||||
assert cfg.get("auto_start") is False
|
||||
|
||||
def test_subscription_configs_contain_all_snapshot_actions(self) -> None:
|
||||
from unraid_mcp.subscriptions.queries import SNAPSHOT_ACTIONS
|
||||
|
||||
mgr = SubscriptionManager()
|
||||
for action in SNAPSHOT_ACTIONS:
|
||||
assert action in mgr.subscription_configs, (
|
||||
f"'{action}' missing from subscription_configs"
|
||||
)
|
||||
|
||||
def test_snapshot_actions_all_auto_start(self) -> None:
|
||||
from unraid_mcp.subscriptions.queries import SNAPSHOT_ACTIONS
|
||||
|
||||
mgr = SubscriptionManager()
|
||||
for action in SNAPSHOT_ACTIONS:
|
||||
assert mgr.subscription_configs[action].get("auto_start") is True, (
|
||||
f"'{action}' missing auto_start=True"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection Lifecycle
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
# =============================================================================
|
||||
# test-tools.sh — Integration smoke-test for unraid-mcp MCP server tools
|
||||
#
|
||||
# Exercises every non-destructive action across all 10 tools using mcporter.
|
||||
# The server is launched ad-hoc via mcporter's --stdio flag so no persistent
|
||||
# process or registered server entry is required.
|
||||
# Exercises every non-destructive action using the consolidated `unraid` tool
|
||||
# (action + subaction pattern). The server is launched ad-hoc via mcporter's
|
||||
# --stdio flag so no persistent process or registered server entry is required.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-tools.sh [--timeout-ms N] [--parallel] [--verbose]
|
||||
# ./tests/mcporter/test-tools.sh [--timeout-ms N] [--parallel] [--verbose]
|
||||
#
|
||||
# Options:
|
||||
# --timeout-ms N Per-call timeout in milliseconds (default: 25000)
|
||||
@@ -146,9 +146,8 @@ check_prerequisites() {
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server startup smoke-test
|
||||
# Launches the stdio server and calls unraid_health action=check.
|
||||
# Returns 0 if the server responds (even with an API error — that still
|
||||
# means the Python process started cleanly), non-zero on import failure.
|
||||
# Launches the stdio server and calls unraid action=health subaction=check.
|
||||
# Returns 0 if the server responds, non-zero on import failure.
|
||||
# ---------------------------------------------------------------------------
|
||||
smoke_test_server() {
|
||||
log_info "Smoke-testing server startup..."
|
||||
@@ -159,14 +158,13 @@ smoke_test_server() {
|
||||
--stdio "uv run unraid-mcp-server" \
|
||||
--cwd "${PROJECT_DIR}" \
|
||||
--name "unraid-smoke" \
|
||||
--tool unraid_health \
|
||||
--args '{"action":"check"}' \
|
||||
--tool unraid \
|
||||
--args '{"action":"health","subaction":"check"}' \
|
||||
--timeout 30000 \
|
||||
--output json \
|
||||
2>&1
|
||||
)" || true
|
||||
|
||||
# If mcporter returns the offline error the server failed to import/start
|
||||
if printf '%s' "${output}" | grep -q '"kind": "offline"'; then
|
||||
log_error "Server failed to start. Output:"
|
||||
printf '%s\n' "${output}" >&2
|
||||
@@ -177,8 +175,6 @@ smoke_test_server() {
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Assert the response contains a valid tool response field, not a bare JSON error.
|
||||
# unraid_health action=check always returns {"status": ...} on success.
|
||||
local key_check
|
||||
key_check="$(
|
||||
printf '%s' "${output}" | python3 -c "
|
||||
@@ -206,19 +202,17 @@ except Exception as e:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mcporter call wrapper
|
||||
# Usage: mcporter_call <tool_name> <args_json>
|
||||
# Writes the mcporter JSON output to stdout.
|
||||
# Returns the mcporter exit code.
|
||||
# Usage: mcporter_call <args_json>
|
||||
# All calls go to the single `unraid` tool.
|
||||
# ---------------------------------------------------------------------------
|
||||
mcporter_call() {
|
||||
local tool_name="${1:?tool_name required}"
|
||||
local args_json="${2:?args_json required}"
|
||||
local args_json="${1:?args_json required}"
|
||||
|
||||
mcporter call \
|
||||
--stdio "uv run unraid-mcp-server" \
|
||||
--cwd "${PROJECT_DIR}" \
|
||||
--name "unraid" \
|
||||
--tool "${tool_name}" \
|
||||
--tool unraid \
|
||||
--args "${args_json}" \
|
||||
--timeout "${CALL_TIMEOUT_MS}" \
|
||||
--output json \
|
||||
@@ -227,25 +221,18 @@ mcporter_call() {
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test runner
|
||||
# Usage: run_test <label> <tool_name> <args_json> [expected_key]
|
||||
#
|
||||
# expected_key — optional jq-style python key path to validate in the
|
||||
# response (e.g. ".status" or ".containers"). If omitted,
|
||||
# any non-offline response is a PASS (tool errors from the
|
||||
# API — e.g. VMs disabled — are still considered PASS because
|
||||
# the tool itself responded correctly).
|
||||
# Usage: run_test <label> <args_json> [expected_key]
|
||||
# ---------------------------------------------------------------------------
|
||||
run_test() {
|
||||
local label="${1:?label required}"
|
||||
local tool="${2:?tool required}"
|
||||
local args="${3:?args required}"
|
||||
local expected_key="${4:-}"
|
||||
local args="${2:?args required}"
|
||||
local expected_key="${3:-}"
|
||||
|
||||
local t0
|
||||
t0="$(date +%s%N)"
|
||||
|
||||
local output
|
||||
output="$(mcporter_call "${tool}" "${args}" 2>&1)" || true
|
||||
output="$(mcporter_call "${args}" 2>&1)" || true
|
||||
|
||||
local elapsed_ms
|
||||
elapsed_ms="$(( ( $(date +%s%N) - t0 ) / 1000000 ))"
|
||||
@@ -302,7 +289,7 @@ except Exception as e:
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip helper — use when a prerequisite (like a list) returned empty
|
||||
# Skip helper
|
||||
# ---------------------------------------------------------------------------
|
||||
skip_test() {
|
||||
local label="${1:?label required}"
|
||||
@@ -313,14 +300,11 @@ skip_test() {
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID extractors
|
||||
# Each function calls the relevant list action and prints the first ID.
|
||||
# Prints nothing (empty string) if the list is empty or the call fails.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Extract first docker container ID
|
||||
get_docker_id() {
|
||||
local raw
|
||||
raw="$(mcporter_call unraid_docker '{"action":"list"}' 2>/dev/null)" || return 0
|
||||
raw="$(mcporter_call '{"action":"docker","subaction":"list"}' 2>/dev/null)" || return 0
|
||||
printf '%s' "${raw}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
@@ -333,10 +317,9 @@ except Exception:
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Extract first docker network ID
|
||||
get_network_id() {
|
||||
local raw
|
||||
raw="$(mcporter_call unraid_docker '{"action":"networks"}' 2>/dev/null)" || return 0
|
||||
raw="$(mcporter_call '{"action":"docker","subaction":"networks"}' 2>/dev/null)" || return 0
|
||||
printf '%s' "${raw}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
@@ -349,10 +332,9 @@ except Exception:
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Extract first VM ID
|
||||
get_vm_id() {
|
||||
local raw
|
||||
raw="$(mcporter_call unraid_vm '{"action":"list"}' 2>/dev/null)" || return 0
|
||||
raw="$(mcporter_call '{"action":"vm","subaction":"list"}' 2>/dev/null)" || return 0
|
||||
printf '%s' "${raw}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
@@ -365,10 +347,9 @@ except Exception:
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Extract first API key ID
|
||||
get_key_id() {
|
||||
local raw
|
||||
raw="$(mcporter_call unraid_keys '{"action":"list"}' 2>/dev/null)" || return 0
|
||||
raw="$(mcporter_call '{"action":"key","subaction":"list"}' 2>/dev/null)" || return 0
|
||||
printf '%s' "${raw}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
@@ -381,10 +362,9 @@ except Exception:
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Extract first disk ID
|
||||
get_disk_id() {
|
||||
local raw
|
||||
raw="$(mcporter_call unraid_storage '{"action":"disks"}' 2>/dev/null)" || return 0
|
||||
raw="$(mcporter_call '{"action":"disk","subaction":"disks"}' 2>/dev/null)" || return 0
|
||||
printf '%s' "${raw}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
@@ -397,16 +377,14 @@ except Exception:
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Extract first log file path
|
||||
get_log_path() {
|
||||
local raw
|
||||
raw="$(mcporter_call unraid_storage '{"action":"log_files"}' 2>/dev/null)" || return 0
|
||||
raw="$(mcporter_call '{"action":"disk","subaction":"log_files"}' 2>/dev/null)" || return 0
|
||||
printf '%s' "${raw}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
files = d.get('log_files', [])
|
||||
# Prefer a plain text log (not binary like btmp/lastlog)
|
||||
for f in files:
|
||||
p = f.get('path', '')
|
||||
if p.endswith('.log') or 'syslog' in p or 'messages' in p:
|
||||
@@ -420,35 +398,10 @@ except Exception:
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grouped test suites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
suite_unraid_info() {
|
||||
printf '\n%b== unraid_info (19 actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_info: overview" unraid_info '{"action":"overview"}'
|
||||
run_test "unraid_info: array" unraid_info '{"action":"array"}'
|
||||
run_test "unraid_info: network" unraid_info '{"action":"network"}'
|
||||
run_test "unraid_info: registration" unraid_info '{"action":"registration"}'
|
||||
run_test "unraid_info: connect" unraid_info '{"action":"connect"}'
|
||||
run_test "unraid_info: variables" unraid_info '{"action":"variables"}'
|
||||
run_test "unraid_info: metrics" unraid_info '{"action":"metrics"}'
|
||||
run_test "unraid_info: services" unraid_info '{"action":"services"}'
|
||||
run_test "unraid_info: display" unraid_info '{"action":"display"}'
|
||||
run_test "unraid_info: config" unraid_info '{"action":"config"}'
|
||||
run_test "unraid_info: online" unraid_info '{"action":"online"}'
|
||||
run_test "unraid_info: owner" unraid_info '{"action":"owner"}'
|
||||
run_test "unraid_info: settings" unraid_info '{"action":"settings"}'
|
||||
run_test "unraid_info: server" unraid_info '{"action":"server"}'
|
||||
run_test "unraid_info: servers" unraid_info '{"action":"servers"}'
|
||||
run_test "unraid_info: flash" unraid_info '{"action":"flash"}'
|
||||
run_test "unraid_info: ups_devices" unraid_info '{"action":"ups_devices"}'
|
||||
# ups_device and ups_config require a device_id — skip if no UPS devices found
|
||||
local ups_raw
|
||||
ups_raw="$(mcporter_call unraid_info '{"action":"ups_devices"}' 2>/dev/null)" || ups_raw=''
|
||||
local ups_id
|
||||
ups_id="$(printf '%s' "${ups_raw}" | python3 -c "
|
||||
get_ups_id() {
|
||||
local raw
|
||||
raw="$(mcporter_call '{"action":"system","subaction":"ups_devices"}' 2>/dev/null)" || return 0
|
||||
printf '%s' "${raw}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
@@ -457,153 +410,193 @@ try:
|
||||
print(devs[0].get('id', devs[0].get('name', '')))
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null)" || ups_id=''
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grouped test suites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
suite_system() {
|
||||
printf '\n%b== system (info/metrics/UPS) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "system: overview" '{"action":"system","subaction":"overview"}'
|
||||
run_test "system: network" '{"action":"system","subaction":"network"}'
|
||||
run_test "system: registration" '{"action":"system","subaction":"registration"}'
|
||||
run_test "system: variables" '{"action":"system","subaction":"variables"}'
|
||||
run_test "system: metrics" '{"action":"system","subaction":"metrics"}'
|
||||
run_test "system: services" '{"action":"system","subaction":"services"}'
|
||||
run_test "system: display" '{"action":"system","subaction":"display"}'
|
||||
run_test "system: config" '{"action":"system","subaction":"config"}'
|
||||
run_test "system: online" '{"action":"system","subaction":"online"}'
|
||||
run_test "system: owner" '{"action":"system","subaction":"owner"}'
|
||||
run_test "system: settings" '{"action":"system","subaction":"settings"}'
|
||||
run_test "system: server" '{"action":"system","subaction":"server"}'
|
||||
run_test "system: servers" '{"action":"system","subaction":"servers"}'
|
||||
run_test "system: flash" '{"action":"system","subaction":"flash"}'
|
||||
run_test "system: ups_devices" '{"action":"system","subaction":"ups_devices"}'
|
||||
|
||||
local ups_id
|
||||
ups_id="$(get_ups_id)" || ups_id=''
|
||||
if [[ -n "${ups_id}" ]]; then
|
||||
run_test "unraid_info: ups_device" unraid_info \
|
||||
"$(printf '{"action":"ups_device","device_id":"%s"}' "${ups_id}")"
|
||||
run_test "unraid_info: ups_config" unraid_info \
|
||||
"$(printf '{"action":"ups_config","device_id":"%s"}' "${ups_id}")"
|
||||
run_test "system: ups_device" \
|
||||
"$(printf '{"action":"system","subaction":"ups_device","device_id":"%s"}' "${ups_id}")"
|
||||
run_test "system: ups_config" \
|
||||
"$(printf '{"action":"system","subaction":"ups_config","device_id":"%s"}' "${ups_id}")"
|
||||
else
|
||||
skip_test "unraid_info: ups_device" "no UPS devices found"
|
||||
skip_test "unraid_info: ups_config" "no UPS devices found"
|
||||
skip_test "system: ups_device" "no UPS devices found"
|
||||
skip_test "system: ups_config" "no UPS devices found"
|
||||
fi
|
||||
}
|
||||
|
||||
suite_unraid_array() {
|
||||
printf '\n%b== unraid_array (1 read-only action) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
run_test "unraid_array: parity_status" unraid_array '{"action":"parity_status"}'
|
||||
# Destructive actions (parity_start/pause/resume/cancel) skipped
|
||||
suite_array() {
|
||||
printf '\n%b== array (read-only) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
run_test "array: parity_status" '{"action":"array","subaction":"parity_status"}'
|
||||
run_test "array: parity_history" '{"action":"array","subaction":"parity_history"}'
|
||||
# Destructive: parity_start/pause/resume/cancel, start_array, stop_array,
|
||||
# add_disk, remove_disk, mount_disk, unmount_disk, clear_disk_stats — skipped
|
||||
}
|
||||
|
||||
suite_unraid_storage() {
|
||||
printf '\n%b== unraid_storage (6 actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
suite_disk() {
|
||||
printf '\n%b== disk (storage/shares/logs) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_storage: shares" unraid_storage '{"action":"shares"}'
|
||||
run_test "unraid_storage: disks" unraid_storage '{"action":"disks"}'
|
||||
run_test "unraid_storage: unassigned" unraid_storage '{"action":"unassigned"}'
|
||||
run_test "unraid_storage: log_files" unraid_storage '{"action":"log_files"}'
|
||||
run_test "disk: shares" '{"action":"disk","subaction":"shares"}'
|
||||
run_test "disk: disks" '{"action":"disk","subaction":"disks"}'
|
||||
run_test "disk: log_files" '{"action":"disk","subaction":"log_files"}'
|
||||
|
||||
# disk_details needs a disk ID
|
||||
local disk_id
|
||||
disk_id="$(get_disk_id)" || disk_id=''
|
||||
if [[ -n "${disk_id}" ]]; then
|
||||
run_test "unraid_storage: disk_details" unraid_storage \
|
||||
"$(printf '{"action":"disk_details","disk_id":"%s"}' "${disk_id}")"
|
||||
run_test "disk: disk_details" \
|
||||
"$(printf '{"action":"disk","subaction":"disk_details","disk_id":"%s"}' "${disk_id}")"
|
||||
else
|
||||
skip_test "unraid_storage: disk_details" "no disks found"
|
||||
skip_test "disk: disk_details" "no disks found"
|
||||
fi
|
||||
|
||||
# logs needs a valid log path
|
||||
local log_path
|
||||
log_path="$(get_log_path)" || log_path=''
|
||||
if [[ -n "${log_path}" ]]; then
|
||||
run_test "unraid_storage: logs" unraid_storage \
|
||||
"$(printf '{"action":"logs","log_path":"%s","tail_lines":20}' "${log_path}")"
|
||||
run_test "disk: logs" \
|
||||
"$(printf '{"action":"disk","subaction":"logs","log_path":"%s","tail_lines":20}' "${log_path}")"
|
||||
else
|
||||
skip_test "unraid_storage: logs" "no log files found"
|
||||
skip_test "disk: logs" "no log files found"
|
||||
fi
|
||||
# Destructive: flash_backup — skipped
|
||||
}
|
||||
|
||||
suite_unraid_docker() {
|
||||
printf '\n%b== unraid_docker (7 read-only actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
suite_docker() {
|
||||
printf '\n%b== docker ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_docker: list" unraid_docker '{"action":"list"}'
|
||||
run_test "unraid_docker: networks" unraid_docker '{"action":"networks"}'
|
||||
run_test "unraid_docker: port_conflicts" unraid_docker '{"action":"port_conflicts"}'
|
||||
run_test "unraid_docker: check_updates" unraid_docker '{"action":"check_updates"}'
|
||||
run_test "docker: list" '{"action":"docker","subaction":"list"}'
|
||||
run_test "docker: networks" '{"action":"docker","subaction":"networks"}'
|
||||
|
||||
# details, logs, network_details need IDs
|
||||
local container_id
|
||||
container_id="$(get_docker_id)" || container_id=''
|
||||
if [[ -n "${container_id}" ]]; then
|
||||
run_test "unraid_docker: details" unraid_docker \
|
||||
"$(printf '{"action":"details","container_id":"%s"}' "${container_id}")"
|
||||
run_test "unraid_docker: logs" unraid_docker \
|
||||
"$(printf '{"action":"logs","container_id":"%s","tail_lines":20}' "${container_id}")"
|
||||
run_test "docker: details" \
|
||||
"$(printf '{"action":"docker","subaction":"details","container_id":"%s"}' "${container_id}")"
|
||||
else
|
||||
skip_test "unraid_docker: details" "no containers found"
|
||||
skip_test "unraid_docker: logs" "no containers found"
|
||||
skip_test "docker: details" "no containers found"
|
||||
fi
|
||||
|
||||
local network_id
|
||||
network_id="$(get_network_id)" || network_id=''
|
||||
if [[ -n "${network_id}" ]]; then
|
||||
run_test "unraid_docker: network_details" unraid_docker \
|
||||
"$(printf '{"action":"network_details","network_id":"%s"}' "${network_id}")"
|
||||
run_test "docker: network_details" \
|
||||
"$(printf '{"action":"docker","subaction":"network_details","network_id":"%s"}' "${network_id}")"
|
||||
else
|
||||
skip_test "unraid_docker: network_details" "no networks found"
|
||||
skip_test "docker: network_details" "no networks found"
|
||||
fi
|
||||
|
||||
# Destructive actions (start/stop/restart/pause/unpause/remove/update/update_all) skipped
|
||||
# Destructive/mutating: start/stop/restart — skipped
|
||||
}
|
||||
|
||||
suite_unraid_vm() {
|
||||
printf '\n%b== unraid_vm (2 read-only actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
suite_vm() {
|
||||
printf '\n%b== vm ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_vm: list" unraid_vm '{"action":"list"}'
|
||||
run_test "vm: list" '{"action":"vm","subaction":"list"}'
|
||||
|
||||
local vm_id
|
||||
vm_id="$(get_vm_id)" || vm_id=''
|
||||
if [[ -n "${vm_id}" ]]; then
|
||||
run_test "unraid_vm: details" unraid_vm \
|
||||
"$(printf '{"action":"details","vm_id":"%s"}' "${vm_id}")"
|
||||
run_test "vm: details" \
|
||||
"$(printf '{"action":"vm","subaction":"details","vm_id":"%s"}' "${vm_id}")"
|
||||
else
|
||||
skip_test "unraid_vm: details" "no VMs found (or VM service unavailable)"
|
||||
skip_test "vm: details" "no VMs found (or VM service unavailable)"
|
||||
fi
|
||||
|
||||
# Destructive actions (start/stop/pause/resume/force_stop/reboot/reset) skipped
|
||||
# Destructive: start/stop/pause/resume/force_stop/reboot/reset — skipped
|
||||
}
|
||||
|
||||
suite_unraid_notifications() {
|
||||
printf '\n%b== unraid_notifications (4 read-only actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
suite_notification() {
|
||||
printf '\n%b== notification ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_notifications: overview" unraid_notifications '{"action":"overview"}'
|
||||
run_test "unraid_notifications: list" unraid_notifications '{"action":"list"}'
|
||||
run_test "unraid_notifications: warnings" unraid_notifications '{"action":"warnings"}'
|
||||
run_test "unraid_notifications: unread" unraid_notifications '{"action":"unread"}'
|
||||
|
||||
# Destructive actions (create/archive/delete/delete_archived/archive_all/etc.) skipped
|
||||
run_test "notification: overview" '{"action":"notification","subaction":"overview"}'
|
||||
run_test "notification: list" '{"action":"notification","subaction":"list"}'
|
||||
run_test "notification: unread" '{"action":"notification","subaction":"unread"}'
|
||||
# Mutating: create/archive/delete/delete_archived/archive_all/etc. — skipped
|
||||
}
|
||||
|
||||
suite_unraid_rclone() {
|
||||
printf '\n%b== unraid_rclone (2 read-only actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
suite_rclone() {
|
||||
printf '\n%b== rclone ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_rclone: list_remotes" unraid_rclone '{"action":"list_remotes"}'
|
||||
# config_form requires a provider_type — use "s3" as a safe, always-available provider
|
||||
run_test "unraid_rclone: config_form" unraid_rclone '{"action":"config_form","provider_type":"s3"}'
|
||||
|
||||
# Destructive actions (create_remote/delete_remote) skipped
|
||||
run_test "rclone: list_remotes" '{"action":"rclone","subaction":"list_remotes"}'
|
||||
run_test "rclone: config_form" '{"action":"rclone","subaction":"config_form","provider_type":"s3"}'
|
||||
# Destructive: create_remote/delete_remote — skipped
|
||||
}
|
||||
|
||||
suite_unraid_users() {
|
||||
printf '\n%b== unraid_users (1 action) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
run_test "unraid_users: me" unraid_users '{"action":"me"}'
|
||||
suite_user() {
|
||||
printf '\n%b== user ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
run_test "user: me" '{"action":"user","subaction":"me"}'
|
||||
}
|
||||
|
||||
suite_unraid_keys() {
|
||||
printf '\n%b== unraid_keys (2 read-only actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
suite_key() {
|
||||
printf '\n%b== key (API keys) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_keys: list" unraid_keys '{"action":"list"}'
|
||||
run_test "key: list" '{"action":"key","subaction":"list"}'
|
||||
|
||||
local key_id
|
||||
key_id="$(get_key_id)" || key_id=''
|
||||
if [[ -n "${key_id}" ]]; then
|
||||
run_test "unraid_keys: get" unraid_keys \
|
||||
"$(printf '{"action":"get","key_id":"%s"}' "${key_id}")"
|
||||
run_test "key: get" \
|
||||
"$(printf '{"action":"key","subaction":"get","key_id":"%s"}' "${key_id}")"
|
||||
else
|
||||
skip_test "unraid_keys: get" "no API keys found"
|
||||
skip_test "key: get" "no API keys found"
|
||||
fi
|
||||
|
||||
# Destructive actions (create/update/delete) skipped
|
||||
# Destructive: create/update/delete/add_role/remove_role — skipped
|
||||
}
|
||||
|
||||
suite_unraid_health() {
|
||||
printf '\n%b== unraid_health (3 actions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
suite_health() {
|
||||
printf '\n%b== health ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "unraid_health: check" unraid_health '{"action":"check"}'
|
||||
run_test "unraid_health: test_connection" unraid_health '{"action":"test_connection"}'
|
||||
run_test "unraid_health: diagnose" unraid_health '{"action":"diagnose"}'
|
||||
run_test "health: check" '{"action":"health","subaction":"check"}'
|
||||
run_test "health: test_connection" '{"action":"health","subaction":"test_connection"}'
|
||||
run_test "health: diagnose" '{"action":"health","subaction":"diagnose"}'
|
||||
# setup triggers elicitation — skipped
|
||||
}
|
||||
|
||||
suite_customization() {
|
||||
printf '\n%b== customization ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "customization: theme" '{"action":"customization","subaction":"theme"}'
|
||||
run_test "customization: public_theme" '{"action":"customization","subaction":"public_theme"}'
|
||||
run_test "customization: sso_enabled" '{"action":"customization","subaction":"sso_enabled"}'
|
||||
run_test "customization: is_initial_setup" '{"action":"customization","subaction":"is_initial_setup"}'
|
||||
# Mutating: set_theme — skipped
|
||||
}
|
||||
|
||||
suite_plugin() {
|
||||
printf '\n%b== plugin ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "plugin: list" '{"action":"plugin","subaction":"list"}'
|
||||
# Destructive: add/remove — skipped
|
||||
}
|
||||
|
||||
suite_oidc() {
|
||||
printf '\n%b== oidc ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
|
||||
|
||||
run_test "oidc: providers" '{"action":"oidc","subaction":"providers"}'
|
||||
run_test "oidc: public_providers" '{"action":"oidc","subaction":"public_providers"}'
|
||||
run_test "oidc: configuration" '{"action":"oidc","subaction":"configuration"}'
|
||||
# provider and validate_session require IDs — skipped
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -633,13 +626,9 @@ print_summary() {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parallel runner — wraps each suite in a background subshell and waits
|
||||
# Parallel runner
|
||||
# ---------------------------------------------------------------------------
|
||||
run_parallel() {
|
||||
# Each suite is independent (only cross-suite dependency: IDs are fetched
|
||||
# fresh inside each suite function, not shared across suites).
|
||||
# Counter updates from subshells won't propagate to the parent — collect
|
||||
# results via temp files instead.
|
||||
log_warn "--parallel mode: per-suite counters aggregated via temp files."
|
||||
|
||||
local tmp_dir
|
||||
@@ -647,23 +636,25 @@ run_parallel() {
|
||||
trap 'rm -rf -- "${tmp_dir}"' RETURN
|
||||
|
||||
local suites=(
|
||||
suite_unraid_info
|
||||
suite_unraid_array
|
||||
suite_unraid_storage
|
||||
suite_unraid_docker
|
||||
suite_unraid_vm
|
||||
suite_unraid_notifications
|
||||
suite_unraid_rclone
|
||||
suite_unraid_users
|
||||
suite_unraid_keys
|
||||
suite_unraid_health
|
||||
suite_system
|
||||
suite_array
|
||||
suite_disk
|
||||
suite_docker
|
||||
suite_vm
|
||||
suite_notification
|
||||
suite_rclone
|
||||
suite_user
|
||||
suite_key
|
||||
suite_health
|
||||
suite_customization
|
||||
suite_plugin
|
||||
suite_oidc
|
||||
)
|
||||
|
||||
local pids=()
|
||||
local suite
|
||||
for suite in "${suites[@]}"; do
|
||||
(
|
||||
# Reset counters in subshell
|
||||
PASS_COUNT=0; FAIL_COUNT=0; SKIP_COUNT=0; FAIL_NAMES=()
|
||||
"${suite}"
|
||||
printf '%d %d %d\n' "${PASS_COUNT}" "${FAIL_COUNT}" "${SKIP_COUNT}" \
|
||||
@@ -673,13 +664,11 @@ run_parallel() {
|
||||
pids+=($!)
|
||||
done
|
||||
|
||||
# Wait for all background suites
|
||||
local pid
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "${pid}" || true
|
||||
done
|
||||
|
||||
# Aggregate counters
|
||||
local f
|
||||
for f in "${tmp_dir}"/*.counts; do
|
||||
[[ -f "${f}" ]] || continue
|
||||
@@ -702,16 +691,19 @@ run_parallel() {
|
||||
# Sequential runner
|
||||
# ---------------------------------------------------------------------------
|
||||
run_sequential() {
|
||||
suite_unraid_info
|
||||
suite_unraid_array
|
||||
suite_unraid_storage
|
||||
suite_unraid_docker
|
||||
suite_unraid_vm
|
||||
suite_unraid_notifications
|
||||
suite_unraid_rclone
|
||||
suite_unraid_users
|
||||
suite_unraid_keys
|
||||
suite_unraid_health
|
||||
suite_system
|
||||
suite_array
|
||||
suite_disk
|
||||
suite_docker
|
||||
suite_vm
|
||||
suite_notification
|
||||
suite_rclone
|
||||
suite_user
|
||||
suite_key
|
||||
suite_health
|
||||
suite_customization
|
||||
suite_plugin
|
||||
suite_oidc
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -721,29 +713,21 @@ main() {
|
||||
parse_args "$@"
|
||||
|
||||
printf '%b%s%b\n' "${C_BOLD}" "$(printf '=%.0s' {1..65})" "${C_RESET}"
|
||||
printf '%b unraid-mcp integration smoke-test%b\n' "${C_BOLD}" "${C_RESET}"
|
||||
printf '%b unraid-mcp integration smoke-test (single unraid tool)%b\n' "${C_BOLD}" "${C_RESET}"
|
||||
printf '%b Project: %s%b\n' "${C_BOLD}" "${PROJECT_DIR}" "${C_RESET}"
|
||||
printf '%b Timeout: %dms/call | Parallel: %s%b\n' \
|
||||
"${C_BOLD}" "${CALL_TIMEOUT_MS}" "${USE_PARALLEL}" "${C_RESET}"
|
||||
printf '%b Log: %s%b\n' "${C_BOLD}" "${LOG_FILE}" "${C_RESET}"
|
||||
printf '%b%s%b\n\n' "${C_BOLD}" "$(printf '=%.0s' {1..65})" "${C_RESET}"
|
||||
|
||||
# Prerequisite gate
|
||||
check_prerequisites || exit 2
|
||||
|
||||
# Server startup gate — fail fast if the Python process can't start
|
||||
smoke_test_server || {
|
||||
log_error ""
|
||||
log_error "Server startup failed. Aborting — no tests will run."
|
||||
log_error ""
|
||||
log_error "To diagnose, run:"
|
||||
log_error " cd ${PROJECT_DIR} && uv run unraid-mcp-server"
|
||||
log_error ""
|
||||
log_error "If server.py has a broken import (e.g. missing tools/settings.py),"
|
||||
log_error "stash or revert the uncommitted server.py change first:"
|
||||
log_error " git stash -- unraid_mcp/server.py"
|
||||
log_error " ./scripts/test-tools.sh"
|
||||
log_error " git stash pop"
|
||||
exit 2
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ Uses Hypothesis to fuzz tool inputs and verify the core invariant:
|
||||
other unhandled exception from arbitrary inputs is a bug.
|
||||
|
||||
Each test class targets a distinct tool domain and strategy profile:
|
||||
- Docker: arbitrary container IDs, action names, numeric params
|
||||
- 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, action names
|
||||
- Info: invalid action names (cross-tool invariant for the action guard)
|
||||
- VM: arbitrary VM IDs, subaction names
|
||||
- Info: invalid subaction names (cross-tool invariant for the subaction guard)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -60,6 +60,10 @@ def _assert_only_tool_error(exc: BaseException) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker: arbitrary container IDs
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -79,16 +83,14 @@ class TestDockerContainerIdFuzzing:
|
||||
"""Arbitrary container IDs for 'details' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
|
||||
"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="details", container_id=container_id)
|
||||
await tool_fn(action="docker", subaction="details", container_id=container_id)
|
||||
|
||||
_run(_run_test())
|
||||
|
||||
@@ -98,15 +100,13 @@ class TestDockerContainerIdFuzzing:
|
||||
"""Arbitrary container IDs for 'start' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
|
||||
"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="start", container_id=container_id)
|
||||
await tool_fn(action="docker", subaction="start", container_id=container_id)
|
||||
|
||||
_run(_run_test())
|
||||
|
||||
@@ -116,15 +116,13 @@ class TestDockerContainerIdFuzzing:
|
||||
"""Arbitrary container IDs for 'stop' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
|
||||
"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="stop", container_id=container_id)
|
||||
await tool_fn(action="docker", subaction="stop", container_id=container_id)
|
||||
|
||||
_run(_run_test())
|
||||
|
||||
@@ -134,80 +132,57 @@ class TestDockerContainerIdFuzzing:
|
||||
"""Arbitrary container IDs for 'restart' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
|
||||
"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="restart", container_id=container_id)
|
||||
await tool_fn(action="docker", subaction="restart", container_id=container_id)
|
||||
|
||||
_run(_run_test())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker: invalid action names
|
||||
# Docker: invalid subaction names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDockerInvalidActions:
|
||||
"""Fuzz the action parameter with arbitrary strings.
|
||||
"""Fuzz the subaction parameter with arbitrary strings for the docker domain.
|
||||
|
||||
Invariant: invalid action names raise ToolError, never KeyError or crash.
|
||||
This validates the action guard that sits at the top of every tool function.
|
||||
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, action: str) -> None:
|
||||
"""Any non-valid action string must raise ToolError, not crash."""
|
||||
valid_actions = {
|
||||
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",
|
||||
"pause",
|
||||
"unpause",
|
||||
"remove",
|
||||
"update",
|
||||
"update_all",
|
||||
"logs",
|
||||
"networks",
|
||||
"network_details",
|
||||
"port_conflicts",
|
||||
"check_updates",
|
||||
"create_folder",
|
||||
"set_folder_children",
|
||||
"delete_entries",
|
||||
"move_to_folder",
|
||||
"move_to_position",
|
||||
"rename_folder",
|
||||
"create_folder_with_items",
|
||||
"update_view_prefs",
|
||||
"sync_templates",
|
||||
"reset_template_mappings",
|
||||
"refresh_digests",
|
||||
}
|
||||
if action in valid_actions:
|
||||
return # Skip valid actions — they have different semantics
|
||||
if subaction in valid_subactions:
|
||||
return # Skip valid subactions — they have different semantics
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
|
||||
)
|
||||
with patch("unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock):
|
||||
tool_fn = _make_tool()
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
|
||||
try:
|
||||
await tool_fn(action=action)
|
||||
await tool_fn(action="docker", subaction=subaction)
|
||||
except ToolError:
|
||||
pass # Correct: invalid action raises ToolError
|
||||
pass # Correct: invalid subaction raises ToolError
|
||||
except Exception as exc:
|
||||
# Any other exception is a bug
|
||||
pytest.fail(
|
||||
f"Action '{action!r}' raised {type(exc).__name__} "
|
||||
f"subaction={subaction!r} raised {type(exc).__name__} "
|
||||
f"instead of ToolError: {exc!r}"
|
||||
)
|
||||
|
||||
@@ -235,13 +210,9 @@ class TestNotificationsEnumFuzzing:
|
||||
return # Skip valid values
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
mock.return_value = {
|
||||
@@ -249,7 +220,8 @@ class TestNotificationsEnumFuzzing:
|
||||
}
|
||||
try:
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="Test",
|
||||
subject="Sub",
|
||||
description="Desc",
|
||||
@@ -271,18 +243,14 @@ class TestNotificationsEnumFuzzing:
|
||||
return # Skip valid values
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
mock.return_value = {"notifications": {"list": []}}
|
||||
try:
|
||||
await tool_fn(action="list", list_type=list_type)
|
||||
await tool_fn(action="notification", subaction="list", list_type=list_type)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -306,13 +274,9 @@ class TestNotificationsEnumFuzzing:
|
||||
"""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
mock.return_value = {
|
||||
@@ -320,7 +284,8 @@ class TestNotificationsEnumFuzzing:
|
||||
}
|
||||
try:
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title=title,
|
||||
subject=subject,
|
||||
description=description,
|
||||
@@ -344,19 +309,16 @@ class TestNotificationsEnumFuzzing:
|
||||
return
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
mock.return_value = {"deleteNotification": {}}
|
||||
try:
|
||||
await tool_fn(
|
||||
action="delete",
|
||||
action="notification",
|
||||
subaction="delete",
|
||||
notification_id="some-id",
|
||||
notification_type=notif_type,
|
||||
confirm=True,
|
||||
@@ -372,12 +334,11 @@ class TestNotificationsEnumFuzzing:
|
||||
|
||||
@given(st.text())
|
||||
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_invalid_action_raises_tool_error(self, action: str) -> None:
|
||||
"""Invalid action names for notifications tool raise ToolError."""
|
||||
valid_actions = {
|
||||
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
|
||||
"""Invalid subaction names for notifications domain raise ToolError."""
|
||||
valid_subactions = {
|
||||
"overview",
|
||||
"list",
|
||||
"warnings",
|
||||
"create",
|
||||
"archive",
|
||||
"unread",
|
||||
@@ -385,31 +346,26 @@ class TestNotificationsEnumFuzzing:
|
||||
"delete_archived",
|
||||
"archive_all",
|
||||
"archive_many",
|
||||
"create_unique",
|
||||
"unarchive_many",
|
||||
"unarchive_all",
|
||||
"recalculate",
|
||||
}
|
||||
if action in valid_actions:
|
||||
if subaction in valid_subactions:
|
||||
return
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
try:
|
||||
await tool_fn(action=action)
|
||||
await tool_fn(action="notification", subaction=subaction)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
pytest.fail(
|
||||
f"Action {action!r} raised {type(exc).__name__} "
|
||||
f"subaction={subaction!r} raised {type(exc).__name__} "
|
||||
f"instead of ToolError: {exc!r}"
|
||||
)
|
||||
|
||||
@@ -425,7 +381,7 @@ class TestKeysInputFuzzing:
|
||||
"""Fuzz API key management parameters.
|
||||
|
||||
Invariant: arbitrary key_id strings, names, and role lists never crash
|
||||
the keys tool — only ToolError or clean return values are acceptable.
|
||||
the keys domain — only ToolError or clean return values are acceptable.
|
||||
"""
|
||||
|
||||
@given(st.text())
|
||||
@@ -434,13 +390,13 @@ class TestKeysInputFuzzing:
|
||||
"""Arbitrary key_id for 'get' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
|
||||
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = {"apiKey": None}
|
||||
try:
|
||||
await tool_fn(action="get", key_id=key_id)
|
||||
await tool_fn(action="key", subaction="get", key_id=key_id)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -454,15 +410,15 @@ class TestKeysInputFuzzing:
|
||||
"""Arbitrary name strings for 'create' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
|
||||
"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="create", name=name)
|
||||
await tool_fn(action="key", subaction="create", name=name)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -476,13 +432,15 @@ class TestKeysInputFuzzing:
|
||||
"""Arbitrary role lists for 'add_role' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
|
||||
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = {"apiKey": {"addRole": True}}
|
||||
try:
|
||||
await tool_fn(action="add_role", key_id="some-key-id", roles=roles)
|
||||
await tool_fn(
|
||||
action="key", subaction="add_role", key_id="some-key-id", roles=roles
|
||||
)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -492,22 +450,22 @@ class TestKeysInputFuzzing:
|
||||
|
||||
@given(st.text())
|
||||
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_invalid_action_raises_tool_error(self, action: str) -> None:
|
||||
"""Invalid action names for keys tool raise ToolError."""
|
||||
valid_actions = {"list", "get", "create", "update", "delete", "add_role", "remove_role"}
|
||||
if action in valid_actions:
|
||||
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_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
with patch("unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock):
|
||||
tool_fn = _make_tool()
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
|
||||
try:
|
||||
await tool_fn(action=action)
|
||||
await tool_fn(action="key", subaction=subaction)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
pytest.fail(
|
||||
f"Action {action!r} raised {type(exc).__name__} "
|
||||
f"subaction={subaction!r} raised {type(exc).__name__} "
|
||||
f"instead of ToolError: {exc!r}"
|
||||
)
|
||||
|
||||
@@ -515,15 +473,15 @@ class TestKeysInputFuzzing:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VM: arbitrary VM IDs and action names
|
||||
# VM: arbitrary VM IDs and subaction names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVMInputFuzzing:
|
||||
"""Fuzz VM management parameters.
|
||||
|
||||
Invariant: arbitrary vm_id strings and action names must never crash
|
||||
the VM tool — only ToolError or clean return values are acceptable.
|
||||
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())
|
||||
@@ -532,16 +490,14 @@ class TestVMInputFuzzing:
|
||||
"""Arbitrary vm_id for 'start' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.virtualization.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
mock.return_value = {"vm": {"start": True}}
|
||||
try:
|
||||
await tool_fn(action="start", vm_id=vm_id)
|
||||
await tool_fn(action="vm", subaction="start", vm_id=vm_id)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -555,16 +511,14 @@ class TestVMInputFuzzing:
|
||||
"""Arbitrary vm_id for 'stop' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.virtualization.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
mock.return_value = {"vm": {"stop": True}}
|
||||
try:
|
||||
await tool_fn(action="stop", vm_id=vm_id)
|
||||
await tool_fn(action="vm", subaction="stop", vm_id=vm_id)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -578,17 +532,15 @@ class TestVMInputFuzzing:
|
||||
"""Arbitrary vm_id for 'details' must not crash the tool."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.virtualization.make_graphql_request",
|
||||
"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="details", vm_id=vm_id)
|
||||
await tool_fn(action="vm", subaction="details", vm_id=vm_id)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -598,9 +550,9 @@ class TestVMInputFuzzing:
|
||||
|
||||
@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 action names for VM tool raise ToolError."""
|
||||
valid_actions = {
|
||||
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
|
||||
"""Invalid subaction names for VM domain raise ToolError."""
|
||||
valid_subactions = {
|
||||
"list",
|
||||
"details",
|
||||
"start",
|
||||
@@ -611,24 +563,22 @@ class TestVMInputFuzzing:
|
||||
"reboot",
|
||||
"reset",
|
||||
}
|
||||
if action in valid_actions:
|
||||
if subaction in valid_subactions:
|
||||
return
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.virtualization.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
try:
|
||||
await tool_fn(action=action)
|
||||
await tool_fn(action="vm", subaction=subaction)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
pytest.fail(
|
||||
f"Action {action!r} raised {type(exc).__name__} "
|
||||
f"subaction={subaction!r} raised {type(exc).__name__} "
|
||||
f"instead of ToolError: {exc!r}"
|
||||
)
|
||||
|
||||
@@ -664,18 +614,16 @@ class TestBoundaryValues:
|
||||
)
|
||||
@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 tool."""
|
||||
"""Adversarial container_id values must not crash the Docker domain."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
|
||||
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = {"docker": {"containers": []}}
|
||||
try:
|
||||
await tool_fn(action="details", container_id=container_id)
|
||||
await tool_fn(action="docker", subaction="details", container_id=container_id)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -702,13 +650,9 @@ class TestBoundaryValues:
|
||||
"""Adversarial importance values must raise ToolError, not crash."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
mock.return_value = {
|
||||
@@ -716,7 +660,8 @@ class TestBoundaryValues:
|
||||
}
|
||||
try:
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="t",
|
||||
subject="s",
|
||||
description="d",
|
||||
@@ -743,13 +688,13 @@ class TestBoundaryValues:
|
||||
"""Adversarial key_id values must not crash the keys get action."""
|
||||
|
||||
async def _run_test():
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
tool_fn = _make_tool()
|
||||
with patch(
|
||||
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
|
||||
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = {"apiKey": None}
|
||||
try:
|
||||
await tool_fn(action="get", key_id=key_id)
|
||||
await tool_fn(action="key", subaction="get", key_id=key_id)
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -759,49 +704,46 @@ class TestBoundaryValues:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Info: action guard (invalid actions on a read-only tool)
|
||||
# Top-level action guard (invalid domain names)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInfoActionGuard:
|
||||
"""Fuzz the action parameter on unraid_info.
|
||||
"""Fuzz the top-level action parameter (domain selector).
|
||||
|
||||
Invariant: the info tool exposes no mutations and its action guard must
|
||||
reject any invalid action with a ToolError rather than a KeyError crash.
|
||||
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 action names for the info tool raise ToolError."""
|
||||
"""Invalid domain names raise ToolError."""
|
||||
valid_actions = {
|
||||
"overview",
|
||||
"array",
|
||||
"network",
|
||||
"registration",
|
||||
"variables",
|
||||
"metrics",
|
||||
"services",
|
||||
"display",
|
||||
"config",
|
||||
"online",
|
||||
"owner",
|
||||
"settings",
|
||||
"server",
|
||||
"servers",
|
||||
"flash",
|
||||
"ups_devices",
|
||||
"ups_device",
|
||||
"ups_config",
|
||||
"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_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
with patch("unraid_mcp.tools.info.make_graphql_request", new_callable=AsyncMock):
|
||||
tool_fn = _make_tool()
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
|
||||
try:
|
||||
await tool_fn(action=action)
|
||||
await tool_fn(action=action, subaction="list")
|
||||
except ToolError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Safety audit tests for destructive action confirmation guards.
|
||||
|
||||
Verifies that all destructive operations across every tool require
|
||||
Verifies that all destructive operations across every domain require
|
||||
explicit `confirm=True` before execution, and that the DESTRUCTIVE_ACTIONS
|
||||
registries are complete and consistent.
|
||||
"""
|
||||
@@ -9,97 +9,75 @@ from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# conftest.py is the shared test-helper module for this project.
|
||||
# pytest automatically adds tests/ to sys.path, making it importable here
|
||||
# without a package __init__.py. Do NOT add tests/__init__.py — it breaks
|
||||
# conftest.py's fixture auto-discovery.
|
||||
from conftest import make_tool_fn
|
||||
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
# Import DESTRUCTIVE_ACTIONS sets from every tool module that defines one
|
||||
from unraid_mcp.tools.array import DESTRUCTIVE_ACTIONS as ARRAY_DESTRUCTIVE
|
||||
from unraid_mcp.tools.array import MUTATIONS as ARRAY_MUTATIONS
|
||||
from unraid_mcp.tools.keys import DESTRUCTIVE_ACTIONS as KEYS_DESTRUCTIVE
|
||||
from unraid_mcp.tools.keys import MUTATIONS as KEYS_MUTATIONS
|
||||
from unraid_mcp.tools.notifications import DESTRUCTIVE_ACTIONS as NOTIF_DESTRUCTIVE
|
||||
from unraid_mcp.tools.notifications import MUTATIONS as NOTIF_MUTATIONS
|
||||
from unraid_mcp.tools.plugins import DESTRUCTIVE_ACTIONS as PLUGINS_DESTRUCTIVE
|
||||
from unraid_mcp.tools.plugins import MUTATIONS as PLUGINS_MUTATIONS
|
||||
from unraid_mcp.tools.rclone import DESTRUCTIVE_ACTIONS as RCLONE_DESTRUCTIVE
|
||||
from unraid_mcp.tools.rclone import MUTATIONS as RCLONE_MUTATIONS
|
||||
from unraid_mcp.tools.settings import DESTRUCTIVE_ACTIONS as SETTINGS_DESTRUCTIVE
|
||||
from unraid_mcp.tools.settings import MUTATIONS as SETTINGS_MUTATIONS
|
||||
from unraid_mcp.tools.storage import DESTRUCTIVE_ACTIONS as STORAGE_DESTRUCTIVE
|
||||
from unraid_mcp.tools.storage import MUTATIONS as STORAGE_MUTATIONS
|
||||
from unraid_mcp.tools.virtualization import DESTRUCTIVE_ACTIONS as VM_DESTRUCTIVE
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS as VM_MUTATIONS
|
||||
# Import DESTRUCTIVE_ACTIONS and MUTATIONS sets from the consolidated unraid module
|
||||
from unraid_mcp.tools.unraid import (
|
||||
_ARRAY_DESTRUCTIVE,
|
||||
_ARRAY_MUTATIONS,
|
||||
_DISK_DESTRUCTIVE,
|
||||
_DISK_MUTATIONS,
|
||||
_KEY_DESTRUCTIVE,
|
||||
_KEY_MUTATIONS,
|
||||
_NOTIFICATION_DESTRUCTIVE,
|
||||
_NOTIFICATION_MUTATIONS,
|
||||
_PLUGIN_DESTRUCTIVE,
|
||||
_PLUGIN_MUTATIONS,
|
||||
_RCLONE_DESTRUCTIVE,
|
||||
_RCLONE_MUTATIONS,
|
||||
_SETTING_DESTRUCTIVE,
|
||||
_SETTING_MUTATIONS,
|
||||
_VM_DESTRUCTIVE,
|
||||
_VM_MUTATIONS,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known destructive actions registry (ground truth for this audit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Every destructive action in the codebase, keyed by (tool_module, tool_name)
|
||||
KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = {
|
||||
KNOWN_DESTRUCTIVE: dict[str, dict] = {
|
||||
"array": {
|
||||
"module": "unraid_mcp.tools.array",
|
||||
"register_fn": "register_array_tool",
|
||||
"tool_name": "unraid_array",
|
||||
"actions": {"remove_disk", "clear_disk_stats", "stop_array"},
|
||||
"runtime_set": ARRAY_DESTRUCTIVE,
|
||||
"runtime_set": _ARRAY_DESTRUCTIVE,
|
||||
"mutations": _ARRAY_MUTATIONS,
|
||||
},
|
||||
"vm": {
|
||||
"module": "unraid_mcp.tools.virtualization",
|
||||
"register_fn": "register_vm_tool",
|
||||
"tool_name": "unraid_vm",
|
||||
"actions": {"force_stop", "reset"},
|
||||
"runtime_set": VM_DESTRUCTIVE,
|
||||
"runtime_set": _VM_DESTRUCTIVE,
|
||||
"mutations": _VM_MUTATIONS,
|
||||
},
|
||||
"notifications": {
|
||||
"module": "unraid_mcp.tools.notifications",
|
||||
"register_fn": "register_notifications_tool",
|
||||
"tool_name": "unraid_notifications",
|
||||
"notification": {
|
||||
"actions": {"delete", "delete_archived"},
|
||||
"runtime_set": NOTIF_DESTRUCTIVE,
|
||||
"runtime_set": _NOTIFICATION_DESTRUCTIVE,
|
||||
"mutations": _NOTIFICATION_MUTATIONS,
|
||||
},
|
||||
"rclone": {
|
||||
"module": "unraid_mcp.tools.rclone",
|
||||
"register_fn": "register_rclone_tool",
|
||||
"tool_name": "unraid_rclone",
|
||||
"actions": {"delete_remote"},
|
||||
"runtime_set": RCLONE_DESTRUCTIVE,
|
||||
"runtime_set": _RCLONE_DESTRUCTIVE,
|
||||
"mutations": _RCLONE_MUTATIONS,
|
||||
},
|
||||
"keys": {
|
||||
"module": "unraid_mcp.tools.keys",
|
||||
"register_fn": "register_keys_tool",
|
||||
"tool_name": "unraid_keys",
|
||||
"key": {
|
||||
"actions": {"delete"},
|
||||
"runtime_set": KEYS_DESTRUCTIVE,
|
||||
"runtime_set": _KEY_DESTRUCTIVE,
|
||||
"mutations": _KEY_MUTATIONS,
|
||||
},
|
||||
"storage": {
|
||||
"module": "unraid_mcp.tools.storage",
|
||||
"register_fn": "register_storage_tool",
|
||||
"tool_name": "unraid_storage",
|
||||
"disk": {
|
||||
"actions": {"flash_backup"},
|
||||
"runtime_set": STORAGE_DESTRUCTIVE,
|
||||
"runtime_set": _DISK_DESTRUCTIVE,
|
||||
"mutations": _DISK_MUTATIONS,
|
||||
},
|
||||
"settings": {
|
||||
"module": "unraid_mcp.tools.settings",
|
||||
"register_fn": "register_settings_tool",
|
||||
"tool_name": "unraid_settings",
|
||||
"actions": {
|
||||
"configure_ups",
|
||||
},
|
||||
"runtime_set": SETTINGS_DESTRUCTIVE,
|
||||
"setting": {
|
||||
"actions": {"configure_ups"},
|
||||
"runtime_set": _SETTING_DESTRUCTIVE,
|
||||
"mutations": _SETTING_MUTATIONS,
|
||||
},
|
||||
"plugins": {
|
||||
"module": "unraid_mcp.tools.plugins",
|
||||
"register_fn": "register_plugins_tool",
|
||||
"tool_name": "unraid_plugins",
|
||||
"plugin": {
|
||||
"actions": {"remove"},
|
||||
"runtime_set": PLUGINS_DESTRUCTIVE,
|
||||
"runtime_set": _PLUGIN_DESTRUCTIVE,
|
||||
"mutations": _PLUGIN_MUTATIONS,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -112,90 +90,53 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = {
|
||||
class TestDestructiveActionRegistries:
|
||||
"""Verify that DESTRUCTIVE_ACTIONS sets in source code match the audit."""
|
||||
|
||||
@pytest.mark.parametrize("tool_key", list(KNOWN_DESTRUCTIVE.keys()))
|
||||
def test_destructive_set_matches_audit(self, tool_key: str) -> None:
|
||||
"""Each tool's DESTRUCTIVE_ACTIONS must exactly match the audited set."""
|
||||
info = KNOWN_DESTRUCTIVE[tool_key]
|
||||
@pytest.mark.parametrize("domain", list(KNOWN_DESTRUCTIVE.keys()))
|
||||
def test_destructive_set_matches_audit(self, domain: str) -> None:
|
||||
info = KNOWN_DESTRUCTIVE[domain]
|
||||
assert info["runtime_set"] == info["actions"], (
|
||||
f"{tool_key}: DESTRUCTIVE_ACTIONS is {info['runtime_set']}, expected {info['actions']}"
|
||||
f"{domain}: DESTRUCTIVE_ACTIONS is {info['runtime_set']}, expected {info['actions']}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("tool_key", list(KNOWN_DESTRUCTIVE.keys()))
|
||||
def test_destructive_actions_are_valid_mutations(self, tool_key: str) -> None:
|
||||
"""Every destructive action must correspond to an actual mutation."""
|
||||
info = KNOWN_DESTRUCTIVE[tool_key]
|
||||
mutations_map = {
|
||||
"array": ARRAY_MUTATIONS,
|
||||
"vm": VM_MUTATIONS,
|
||||
"notifications": NOTIF_MUTATIONS,
|
||||
"rclone": RCLONE_MUTATIONS,
|
||||
"keys": KEYS_MUTATIONS,
|
||||
"storage": STORAGE_MUTATIONS,
|
||||
"settings": SETTINGS_MUTATIONS,
|
||||
"plugins": PLUGINS_MUTATIONS,
|
||||
}
|
||||
mutations = mutations_map[tool_key]
|
||||
@pytest.mark.parametrize("domain", list(KNOWN_DESTRUCTIVE.keys()))
|
||||
def test_destructive_actions_are_valid_mutations(self, domain: str) -> None:
|
||||
info = KNOWN_DESTRUCTIVE[domain]
|
||||
for action in info["actions"]:
|
||||
assert action in mutations, (
|
||||
f"{tool_key}: destructive action '{action}' is not in MUTATIONS"
|
||||
assert action in info["mutations"], (
|
||||
f"{domain}: destructive action '{action}' is not in MUTATIONS"
|
||||
)
|
||||
|
||||
def test_no_delete_or_remove_mutations_missing_from_destructive(self) -> None:
|
||||
"""Any mutation with 'delete' or 'remove' in its name should be destructive.
|
||||
|
||||
Exceptions (documented, intentional):
|
||||
keys/remove_role — fully reversible; the role can always be re-added via add_role.
|
||||
No data is lost and there is no irreversible side-effect.
|
||||
key/remove_role — fully reversible; the role can always be re-added via add_role.
|
||||
"""
|
||||
# Mutations explicitly exempted from the delete/remove heuristic with justification.
|
||||
# Add entries here only when the action is demonstrably reversible and non-destructive.
|
||||
_HEURISTIC_EXCEPTIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
"keys/remove_role", # reversible — role can be re-added via add_role
|
||||
"key/remove_role", # reversible — role can be re-added via add_role
|
||||
}
|
||||
)
|
||||
|
||||
all_mutations = {
|
||||
"array": ARRAY_MUTATIONS,
|
||||
"vm": VM_MUTATIONS,
|
||||
"notifications": NOTIF_MUTATIONS,
|
||||
"rclone": RCLONE_MUTATIONS,
|
||||
"keys": KEYS_MUTATIONS,
|
||||
"storage": STORAGE_MUTATIONS,
|
||||
"settings": SETTINGS_MUTATIONS,
|
||||
"plugins": PLUGINS_MUTATIONS,
|
||||
}
|
||||
all_destructive = {
|
||||
"array": ARRAY_DESTRUCTIVE,
|
||||
"vm": VM_DESTRUCTIVE,
|
||||
"notifications": NOTIF_DESTRUCTIVE,
|
||||
"rclone": RCLONE_DESTRUCTIVE,
|
||||
"keys": KEYS_DESTRUCTIVE,
|
||||
"storage": STORAGE_DESTRUCTIVE,
|
||||
"settings": SETTINGS_DESTRUCTIVE,
|
||||
"plugins": PLUGINS_DESTRUCTIVE,
|
||||
}
|
||||
missing: list[str] = []
|
||||
for tool_key, mutations in all_mutations.items():
|
||||
destructive = all_destructive[tool_key]
|
||||
missing.extend(
|
||||
f"{tool_key}/{action_name}"
|
||||
for action_name in mutations
|
||||
if ("delete" in action_name or "remove" in action_name)
|
||||
and action_name not in destructive
|
||||
and f"{tool_key}/{action_name}" not in _HEURISTIC_EXCEPTIONS
|
||||
)
|
||||
for domain, info in KNOWN_DESTRUCTIVE.items():
|
||||
destructive = info["runtime_set"]
|
||||
for action_name in info["mutations"]:
|
||||
if (
|
||||
("delete" in action_name or "remove" in action_name)
|
||||
and action_name not in destructive
|
||||
and f"{domain}/{action_name}" not in _HEURISTIC_EXCEPTIONS
|
||||
):
|
||||
missing.append(f"{domain}/{action_name}")
|
||||
assert not missing, (
|
||||
f"Mutations with 'delete'/'remove' not in DESTRUCTIVE_ACTIONS: {missing}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confirmation guard tests: calling without confirm=True raises ToolError
|
||||
# Confirmation guard tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Build parametrized test cases: (tool_key, action, kwargs_without_confirm)
|
||||
# Each destructive action needs the minimum required params (minus confirm)
|
||||
# (action, subaction, extra_kwargs)
|
||||
_DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
|
||||
# Array
|
||||
("array", "remove_disk", {"disk_id": "abc123:local"}),
|
||||
@@ -205,161 +146,112 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
|
||||
("vm", "force_stop", {"vm_id": "test-vm-uuid"}),
|
||||
("vm", "reset", {"vm_id": "test-vm-uuid"}),
|
||||
# Notifications
|
||||
("notifications", "delete", {"notification_id": "notif-1", "notification_type": "UNREAD"}),
|
||||
("notifications", "delete_archived", {}),
|
||||
("notification", "delete", {"notification_id": "notif-1", "notification_type": "UNREAD"}),
|
||||
("notification", "delete_archived", {}),
|
||||
# RClone
|
||||
("rclone", "delete_remote", {"name": "my-remote"}),
|
||||
# Keys
|
||||
("keys", "delete", {"key_id": "key-123"}),
|
||||
# Storage
|
||||
("key", "delete", {"key_id": "key-123"}),
|
||||
# Disk (flash_backup)
|
||||
(
|
||||
"storage",
|
||||
"disk",
|
||||
"flash_backup",
|
||||
{"remote_name": "r", "source_path": "/boot", "destination_path": "r:b"},
|
||||
),
|
||||
# Settings
|
||||
("settings", "configure_ups", {"ups_config": {"mode": "slave"}}),
|
||||
("setting", "configure_ups", {"ups_config": {"mode": "slave"}}),
|
||||
# Plugins
|
||||
("plugins", "remove", {"names": ["my-plugin"]}),
|
||||
("plugin", "remove", {"names": ["my-plugin"]}),
|
||||
]
|
||||
|
||||
|
||||
_CASE_IDS = [f"{c[0]}/{c[1]}" for c in _DESTRUCTIVE_TEST_CASES]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_array_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.array.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
_MODULE = "unraid_mcp.tools.unraid"
|
||||
_REGISTER_FN = "register_unraid_tool"
|
||||
_TOOL_NAME = "unraid"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_vm_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.virtualization.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch(f"{_MODULE}.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_notif_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.notifications.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_rclone_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.rclone.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_keys_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_storage_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.storage.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_settings_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.settings.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_plugins_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.plugins.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
# Map tool_key -> (module path, register fn, tool name)
|
||||
_TOOL_REGISTRY = {
|
||||
"array": ("unraid_mcp.tools.array", "register_array_tool", "unraid_array"),
|
||||
"vm": ("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"),
|
||||
"notifications": (
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
),
|
||||
"rclone": ("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone"),
|
||||
"keys": ("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys"),
|
||||
"storage": ("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"),
|
||||
"settings": ("unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"),
|
||||
"plugins": ("unraid_mcp.tools.plugins", "register_plugins_tool", "unraid_plugins"),
|
||||
}
|
||||
|
||||
|
||||
class TestConfirmationGuards:
|
||||
"""Every destructive action must reject calls without confirm=True."""
|
||||
|
||||
@pytest.mark.parametrize("tool_key,action,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
@pytest.mark.parametrize("action,subaction,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_rejects_without_confirm(
|
||||
self,
|
||||
tool_key: str,
|
||||
action: str,
|
||||
subaction: str,
|
||||
kwargs: dict,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
_mock_plugins_graphql: AsyncMock,
|
||||
_mock_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""Calling a destructive action without confirm=True must raise ToolError."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
tool_fn = make_tool_fn(module_path, register_fn, tool_name)
|
||||
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
with pytest.raises(ToolError, match="confirm=True"):
|
||||
await tool_fn(action=action, **kwargs)
|
||||
await tool_fn(action=action, subaction=subaction, **kwargs)
|
||||
|
||||
@pytest.mark.parametrize("tool_key,action,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
@pytest.mark.parametrize("action,subaction,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_rejects_with_confirm_false(
|
||||
self,
|
||||
tool_key: str,
|
||||
action: str,
|
||||
subaction: str,
|
||||
kwargs: dict,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
_mock_plugins_graphql: AsyncMock,
|
||||
_mock_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""Explicitly passing confirm=False must still raise ToolError."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
tool_fn = make_tool_fn(module_path, register_fn, tool_name)
|
||||
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
with pytest.raises(ToolError, match="confirm=True"):
|
||||
await tool_fn(action=action, confirm=False, **kwargs)
|
||||
await tool_fn(action=action, subaction=subaction, confirm=False, **kwargs)
|
||||
|
||||
@pytest.mark.parametrize("tool_key,action,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_error_message_includes_action_name(
|
||||
@pytest.mark.parametrize("action,subaction,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_error_message_includes_subaction_name(
|
||||
self,
|
||||
tool_key: str,
|
||||
action: str,
|
||||
subaction: str,
|
||||
kwargs: dict,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
_mock_plugins_graphql: AsyncMock,
|
||||
_mock_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""The error message should include the action name for clarity."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
tool_fn = make_tool_fn(module_path, register_fn, tool_name)
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
with pytest.raises(ToolError, match=subaction):
|
||||
await tool_fn(action=action, subaction=subaction, **kwargs)
|
||||
|
||||
with pytest.raises(ToolError, match=action):
|
||||
await tool_fn(action=action, **kwargs)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strict guard tests: no network calls escape when unconfirmed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoGraphQLCallsWhenUnconfirmed:
|
||||
"""The most critical safety property: when confirm is missing/False,
|
||||
NO GraphQL request must ever reach the network layer.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("action,subaction,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_no_graphql_call_without_confirm(
|
||||
self,
|
||||
action: str,
|
||||
subaction: str,
|
||||
kwargs: dict,
|
||||
_mock_graphql: AsyncMock,
|
||||
) -> None:
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
with pytest.raises(ToolError):
|
||||
await tool_fn(action=action, subaction=subaction, **kwargs)
|
||||
_mock_graphql.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("action,subaction,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_no_graphql_call_with_confirm_false(
|
||||
self,
|
||||
action: str,
|
||||
subaction: str,
|
||||
kwargs: dict,
|
||||
_mock_graphql: AsyncMock,
|
||||
) -> None:
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
with pytest.raises(ToolError):
|
||||
await tool_fn(action=action, subaction=subaction, confirm=False, **kwargs)
|
||||
_mock_graphql.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -370,30 +262,29 @@ class TestConfirmationGuards:
|
||||
class TestConfirmAllowsExecution:
|
||||
"""Destructive actions with confirm=True should reach the GraphQL layer."""
|
||||
|
||||
async def test_vm_force_stop_with_confirm(self, _mock_vm_graphql: AsyncMock) -> None:
|
||||
_mock_vm_graphql.return_value = {"vm": {"forceStop": True}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm")
|
||||
result = await tool_fn(action="force_stop", vm_id="test-uuid", confirm=True)
|
||||
async def test_vm_force_stop_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {"forceStop": True}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(action="vm", subaction="force_stop", vm_id="test-uuid", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_vm_reset_with_confirm(self, _mock_vm_graphql: AsyncMock) -> None:
|
||||
_mock_vm_graphql.return_value = {"vm": {"reset": True}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm")
|
||||
result = await tool_fn(action="reset", vm_id="test-uuid", confirm=True)
|
||||
async def test_vm_reset_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {"reset": True}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(action="vm", subaction="reset", vm_id="test-uuid", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_notifications_delete_with_confirm(self, _mock_notif_graphql: AsyncMock) -> None:
|
||||
_mock_notif_graphql.return_value = {
|
||||
async def test_notifications_delete_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"deleteNotification": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"
|
||||
)
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(
|
||||
action="delete",
|
||||
action="notification",
|
||||
subaction="delete",
|
||||
notification_id="notif-1",
|
||||
notification_type="UNREAD",
|
||||
confirm=True,
|
||||
@@ -401,43 +292,38 @@ class TestConfirmAllowsExecution:
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_notifications_delete_archived_with_confirm(
|
||||
self, _mock_notif_graphql: AsyncMock
|
||||
self, _mock_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_notif_graphql.return_value = {
|
||||
_mock_graphql.return_value = {
|
||||
"deleteArchivedNotifications": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"
|
||||
)
|
||||
result = await tool_fn(action="delete_archived", confirm=True)
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(action="notification", subaction="delete_archived", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_rclone_delete_remote_with_confirm(self, _mock_rclone_graphql: AsyncMock) -> None:
|
||||
_mock_rclone_graphql.return_value = {"rclone": {"deleteRCloneRemote": True}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone")
|
||||
result = await tool_fn(action="delete_remote", name="my-remote", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_keys_delete_with_confirm(self, _mock_keys_graphql: AsyncMock) -> None:
|
||||
_mock_keys_graphql.return_value = {"apiKey": {"delete": True}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
result = await tool_fn(action="delete", key_id="key-123", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_storage_flash_backup_with_confirm(
|
||||
self, _mock_storage_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_storage_graphql.return_value = {
|
||||
"initiateFlashBackup": {"status": "started", "jobId": "j:1"}
|
||||
}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"
|
||||
)
|
||||
async def test_rclone_delete_remote_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"rclone": {"deleteRCloneRemote": True}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(
|
||||
action="flash_backup",
|
||||
action="rclone", subaction="delete_remote", name="my-remote", confirm=True
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_keys_delete_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"apiKey": {"delete": True}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(action="key", subaction="delete", key_id="key-123", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_disk_flash_backup_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"initiateFlashBackup": {"status": "started", "jobId": "j:1"}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
@@ -445,125 +331,46 @@ class TestConfirmAllowsExecution:
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_settings_configure_ups_with_confirm(
|
||||
self, _mock_settings_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_settings_graphql.return_value = {"configureUps": True}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"
|
||||
)
|
||||
async def test_settings_configure_ups_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"configureUps": True}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(
|
||||
action="configure_ups", confirm=True, ups_config={"mode": "master", "cable": "usb"}
|
||||
action="setting",
|
||||
subaction="configure_ups",
|
||||
confirm=True,
|
||||
ups_config={"mode": "master", "cable": "usb"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_array_remove_disk_with_confirm(self, _mock_array_graphql: AsyncMock) -> None:
|
||||
_mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||
result = await tool_fn(action="remove_disk", disk_id="abc:local", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_array_clear_disk_stats_with_confirm(
|
||||
self, _mock_array_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_array_graphql.return_value = {"array": {"clearArrayDiskStatistics": True}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||
result = await tool_fn(action="clear_disk_stats", disk_id="abc:local", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_array_stop_array_with_confirm(self, _mock_array_graphql: AsyncMock) -> None:
|
||||
_mock_array_graphql.return_value = {"array": {"setState": {"state": "STOPPED"}}}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||
result = await tool_fn(action="stop_array", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_plugins_remove_with_confirm(self, _mock_plugins_graphql: AsyncMock) -> None:
|
||||
_mock_plugins_graphql.return_value = {"removePlugin": True}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.plugins", "register_plugins_tool", "unraid_plugins"
|
||||
async def test_array_remove_disk_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(
|
||||
action="array", subaction="remove_disk", disk_id="abc:local", confirm=True
|
||||
)
|
||||
result = await tool_fn(action="remove", names=["my-plugin"], confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_array_clear_disk_stats_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"array": {"clearArrayDiskStatistics": True}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(
|
||||
action="array", subaction="clear_disk_stats", disk_id="abc:local", confirm=True
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strict guard tests: no network calls escape when unconfirmed
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_array_stop_array_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"array": {"setState": {"state": "STOPPED"}}}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(action="array", subaction="stop_array", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestNoGraphQLCallsWhenUnconfirmed:
|
||||
"""The most critical safety property: when confirm is missing/False,
|
||||
NO GraphQL request must ever reach the network layer. This verifies that
|
||||
the guard fires before any I/O, not just that a ToolError is raised.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("tool_key,action,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_no_graphql_call_without_confirm(
|
||||
self,
|
||||
tool_key: str,
|
||||
action: str,
|
||||
kwargs: dict,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
_mock_plugins_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""make_graphql_request must NOT be called when confirm is absent."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
tool_fn = make_tool_fn(module_path, register_fn, tool_name)
|
||||
mock_map = {
|
||||
"array": _mock_array_graphql,
|
||||
"vm": _mock_vm_graphql,
|
||||
"notifications": _mock_notif_graphql,
|
||||
"rclone": _mock_rclone_graphql,
|
||||
"keys": _mock_keys_graphql,
|
||||
"storage": _mock_storage_graphql,
|
||||
"settings": _mock_settings_graphql,
|
||||
"plugins": _mock_plugins_graphql,
|
||||
}
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
await tool_fn(action=action, **kwargs)
|
||||
|
||||
mock_map[tool_key].assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("tool_key,action,kwargs", _DESTRUCTIVE_TEST_CASES, ids=_CASE_IDS)
|
||||
async def test_no_graphql_call_with_confirm_false(
|
||||
self,
|
||||
tool_key: str,
|
||||
action: str,
|
||||
kwargs: dict,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
_mock_plugins_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""make_graphql_request must NOT be called when confirm=False."""
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
tool_fn = make_tool_fn(module_path, register_fn, tool_name)
|
||||
mock_map = {
|
||||
"array": _mock_array_graphql,
|
||||
"vm": _mock_vm_graphql,
|
||||
"notifications": _mock_notif_graphql,
|
||||
"rclone": _mock_rclone_graphql,
|
||||
"keys": _mock_keys_graphql,
|
||||
"storage": _mock_storage_graphql,
|
||||
"settings": _mock_settings_graphql,
|
||||
"plugins": _mock_plugins_graphql,
|
||||
}
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
await tool_fn(action=action, confirm=False, **kwargs)
|
||||
|
||||
mock_map[tool_key].assert_not_called()
|
||||
async def test_plugins_remove_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"removePlugin": True}
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(
|
||||
action="plugin", subaction="remove", names=["my-plugin"], confirm=True
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -572,57 +379,35 @@ class TestNoGraphQLCallsWhenUnconfirmed:
|
||||
|
||||
|
||||
class TestNonDestructiveActionsNeverRequireConfirm:
|
||||
"""Guard regression test: non-destructive mutations must work without confirm.
|
||||
|
||||
If a non-destructive action starts requiring confirm=True (over-guarding),
|
||||
it would break normal use cases. This test class prevents that regression.
|
||||
"""
|
||||
"""Guard regression: non-destructive ops must work without confirm."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_key,action,kwargs,mock_return",
|
||||
"action,subaction,kwargs,mock_return",
|
||||
[
|
||||
("array", "parity_cancel", {}, {"parityCheck": {"cancel": True}}),
|
||||
("vm", "start", {"vm_id": "test-uuid"}, {"vm": {"start": True}}),
|
||||
("notifications", "archive_all", {}, {"archiveAll": {"info": 0, "total": 0}}),
|
||||
("notification", "archive_all", {}, {"archiveAll": {"info": 0, "total": 0}}),
|
||||
("rclone", "list_remotes", {}, {"rclone": {"remotes": []}}),
|
||||
("keys", "list", {}, {"apiKeys": []}),
|
||||
("key", "list", {}, {"apiKeys": []}),
|
||||
],
|
||||
ids=[
|
||||
"array/parity_cancel",
|
||||
"vm/start",
|
||||
"notifications/archive_all",
|
||||
"notification/archive_all",
|
||||
"rclone/list_remotes",
|
||||
"keys/list",
|
||||
"key/list",
|
||||
],
|
||||
)
|
||||
async def test_non_destructive_action_works_without_confirm(
|
||||
self,
|
||||
tool_key: str,
|
||||
action: str,
|
||||
subaction: str,
|
||||
kwargs: dict,
|
||||
mock_return: dict,
|
||||
_mock_array_graphql: AsyncMock,
|
||||
_mock_vm_graphql: AsyncMock,
|
||||
_mock_notif_graphql: AsyncMock,
|
||||
_mock_rclone_graphql: AsyncMock,
|
||||
_mock_keys_graphql: AsyncMock,
|
||||
_mock_storage_graphql: AsyncMock,
|
||||
_mock_settings_graphql: AsyncMock,
|
||||
_mock_plugins_graphql: AsyncMock,
|
||||
_mock_graphql: AsyncMock,
|
||||
) -> None:
|
||||
"""Non-destructive actions must not raise ToolError for missing confirm."""
|
||||
mock_map = {
|
||||
"array": _mock_array_graphql,
|
||||
"vm": _mock_vm_graphql,
|
||||
"notifications": _mock_notif_graphql,
|
||||
"rclone": _mock_rclone_graphql,
|
||||
"keys": _mock_keys_graphql,
|
||||
}
|
||||
mock_map[tool_key].return_value = mock_return
|
||||
|
||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||
tool_fn = make_tool_fn(module_path, register_fn, tool_name)
|
||||
# Just verify no ToolError is raised for missing confirm — return shape varies by action
|
||||
result = await tool_fn(action=action, **kwargs)
|
||||
_mock_graphql.return_value = mock_return
|
||||
tool_fn = make_tool_fn(_MODULE, _REGISTER_FN, _TOOL_NAME)
|
||||
result = await tool_fn(action=action, subaction=subaction, **kwargs)
|
||||
assert result is not None
|
||||
mock_map[tool_key].assert_called_once()
|
||||
_mock_graphql.assert_called_once()
|
||||
|
||||
@@ -35,116 +35,116 @@ class TestInfoQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/info.py."""
|
||||
|
||||
def test_overview_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["overview"])
|
||||
assert not errors, f"overview query validation failed: {errors}"
|
||||
|
||||
def test_array_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["array"])
|
||||
assert not errors, f"array query validation failed: {errors}"
|
||||
|
||||
def test_network_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["network"])
|
||||
assert not errors, f"network query validation failed: {errors}"
|
||||
|
||||
def test_registration_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["registration"])
|
||||
assert not errors, f"registration query validation failed: {errors}"
|
||||
|
||||
def test_variables_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["variables"])
|
||||
assert not errors, f"variables query validation failed: {errors}"
|
||||
|
||||
def test_metrics_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["metrics"])
|
||||
assert not errors, f"metrics query validation failed: {errors}"
|
||||
|
||||
def test_services_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["services"])
|
||||
assert not errors, f"services query validation failed: {errors}"
|
||||
|
||||
def test_display_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["display"])
|
||||
assert not errors, f"display query validation failed: {errors}"
|
||||
|
||||
def test_config_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["config"])
|
||||
assert not errors, f"config query validation failed: {errors}"
|
||||
|
||||
def test_online_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["online"])
|
||||
assert not errors, f"online query validation failed: {errors}"
|
||||
|
||||
def test_owner_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["owner"])
|
||||
assert not errors, f"owner query validation failed: {errors}"
|
||||
|
||||
def test_settings_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["settings"])
|
||||
assert not errors, f"settings query validation failed: {errors}"
|
||||
|
||||
def test_server_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["server"])
|
||||
assert not errors, f"server query validation failed: {errors}"
|
||||
|
||||
def test_servers_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["servers"])
|
||||
assert not errors, f"servers query validation failed: {errors}"
|
||||
|
||||
def test_flash_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["flash"])
|
||||
assert not errors, f"flash query validation failed: {errors}"
|
||||
|
||||
def test_ups_devices_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["ups_devices"])
|
||||
assert not errors, f"ups_devices query validation failed: {errors}"
|
||||
|
||||
def test_ups_device_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["ups_device"])
|
||||
assert not errors, f"ups_device query validation failed: {errors}"
|
||||
|
||||
def test_ups_config_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["ups_config"])
|
||||
assert not errors, f"ups_config query validation failed: {errors}"
|
||||
|
||||
def test_all_info_actions_covered(self, schema: GraphQLSchema) -> None:
|
||||
"""Ensure every key in QUERIES has a corresponding test."""
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
from unraid_mcp.tools.unraid import _SYSTEM_QUERIES as QUERIES
|
||||
|
||||
expected_actions = {
|
||||
"overview",
|
||||
@@ -177,19 +177,19 @@ class TestArrayQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/array.py."""
|
||||
|
||||
def test_parity_status_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import QUERIES
|
||||
from unraid_mcp.tools.unraid import _ARRAY_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["parity_status"])
|
||||
assert not errors, f"parity_status query validation failed: {errors}"
|
||||
|
||||
def test_parity_history_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import QUERIES
|
||||
from unraid_mcp.tools.unraid import _ARRAY_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["parity_history"])
|
||||
assert not errors, f"parity_history query validation failed: {errors}"
|
||||
|
||||
def test_all_array_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import QUERIES
|
||||
from unraid_mcp.tools.unraid import _ARRAY_QUERIES as QUERIES
|
||||
|
||||
assert set(QUERIES.keys()) == {"parity_status", "parity_history"}
|
||||
|
||||
@@ -198,73 +198,73 @@ class TestArrayMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/array.py."""
|
||||
|
||||
def test_parity_start_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["parity_start"])
|
||||
assert not errors, f"parity_start mutation validation failed: {errors}"
|
||||
|
||||
def test_parity_pause_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["parity_pause"])
|
||||
assert not errors, f"parity_pause mutation validation failed: {errors}"
|
||||
|
||||
def test_parity_resume_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["parity_resume"])
|
||||
assert not errors, f"parity_resume mutation validation failed: {errors}"
|
||||
|
||||
def test_parity_cancel_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["parity_cancel"])
|
||||
assert not errors, f"parity_cancel mutation validation failed: {errors}"
|
||||
|
||||
def test_start_array_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["start_array"])
|
||||
assert not errors, f"start_array mutation validation failed: {errors}"
|
||||
|
||||
def test_stop_array_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["stop_array"])
|
||||
assert not errors, f"stop_array mutation validation failed: {errors}"
|
||||
|
||||
def test_add_disk_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["add_disk"])
|
||||
assert not errors, f"add_disk mutation validation failed: {errors}"
|
||||
|
||||
def test_remove_disk_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["remove_disk"])
|
||||
assert not errors, f"remove_disk mutation validation failed: {errors}"
|
||||
|
||||
def test_mount_disk_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["mount_disk"])
|
||||
assert not errors, f"mount_disk mutation validation failed: {errors}"
|
||||
|
||||
def test_unmount_disk_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["unmount_disk"])
|
||||
assert not errors, f"unmount_disk mutation validation failed: {errors}"
|
||||
|
||||
def test_clear_disk_stats_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["clear_disk_stats"])
|
||||
assert not errors, f"clear_disk_stats mutation validation failed: {errors}"
|
||||
|
||||
def test_all_array_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.array import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _ARRAY_MUTATIONS as MUTATIONS
|
||||
|
||||
expected = {
|
||||
"parity_start",
|
||||
@@ -289,37 +289,37 @@ class TestStorageQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/storage.py."""
|
||||
|
||||
def test_shares_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DISK_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["shares"])
|
||||
assert not errors, f"shares query validation failed: {errors}"
|
||||
|
||||
def test_disks_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DISK_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["disks"])
|
||||
assert not errors, f"disks query validation failed: {errors}"
|
||||
|
||||
def test_disk_details_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DISK_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["disk_details"])
|
||||
assert not errors, f"disk_details query validation failed: {errors}"
|
||||
|
||||
def test_log_files_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DISK_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["log_files"])
|
||||
assert not errors, f"log_files query validation failed: {errors}"
|
||||
|
||||
def test_logs_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DISK_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["logs"])
|
||||
assert not errors, f"logs query validation failed: {errors}"
|
||||
|
||||
def test_all_storage_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DISK_QUERIES as QUERIES
|
||||
|
||||
expected = {"shares", "disks", "disk_details", "log_files", "logs"}
|
||||
assert set(QUERIES.keys()) == expected
|
||||
@@ -329,13 +329,13 @@ class TestStorageMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/storage.py."""
|
||||
|
||||
def test_flash_backup_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _DISK_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["flash_backup"])
|
||||
assert not errors, f"flash_backup mutation validation failed: {errors}"
|
||||
|
||||
def test_all_storage_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.storage import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _DISK_MUTATIONS as MUTATIONS
|
||||
|
||||
assert set(MUTATIONS.keys()) == {"flash_backup"}
|
||||
|
||||
@@ -347,31 +347,31 @@ class TestDockerQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/docker.py."""
|
||||
|
||||
def test_list_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DOCKER_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["list"])
|
||||
assert not errors, f"list query validation failed: {errors}"
|
||||
|
||||
def test_details_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DOCKER_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["details"])
|
||||
assert not errors, f"details query validation failed: {errors}"
|
||||
|
||||
def test_networks_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DOCKER_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["networks"])
|
||||
assert not errors, f"networks query validation failed: {errors}"
|
||||
|
||||
def test_network_details_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DOCKER_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["network_details"])
|
||||
assert not errors, f"network_details query validation failed: {errors}"
|
||||
|
||||
def test_all_docker_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import QUERIES
|
||||
from unraid_mcp.tools.unraid import _DOCKER_QUERIES as QUERIES
|
||||
|
||||
expected = {
|
||||
"list",
|
||||
@@ -386,19 +386,19 @@ class TestDockerMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/docker.py."""
|
||||
|
||||
def test_start_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _DOCKER_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["start"])
|
||||
assert not errors, f"start mutation validation failed: {errors}"
|
||||
|
||||
def test_stop_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _DOCKER_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["stop"])
|
||||
assert not errors, f"stop mutation validation failed: {errors}"
|
||||
|
||||
def test_all_docker_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.docker import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _DOCKER_MUTATIONS as MUTATIONS
|
||||
|
||||
expected = {
|
||||
"start",
|
||||
@@ -414,19 +414,19 @@ class TestVmQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/virtualization.py."""
|
||||
|
||||
def test_list_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import QUERIES
|
||||
from unraid_mcp.tools.unraid import _VM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["list"])
|
||||
assert not errors, f"list query validation failed: {errors}"
|
||||
|
||||
def test_details_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import QUERIES
|
||||
from unraid_mcp.tools.unraid import _VM_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["details"])
|
||||
assert not errors, f"details query validation failed: {errors}"
|
||||
|
||||
def test_all_vm_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import QUERIES
|
||||
from unraid_mcp.tools.unraid import _VM_QUERIES as QUERIES
|
||||
|
||||
assert set(QUERIES.keys()) == {"list", "details"}
|
||||
|
||||
@@ -435,49 +435,49 @@ class TestVmMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/virtualization.py."""
|
||||
|
||||
def test_start_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["start"])
|
||||
assert not errors, f"start mutation validation failed: {errors}"
|
||||
|
||||
def test_stop_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["stop"])
|
||||
assert not errors, f"stop mutation validation failed: {errors}"
|
||||
|
||||
def test_pause_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["pause"])
|
||||
assert not errors, f"pause mutation validation failed: {errors}"
|
||||
|
||||
def test_resume_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["resume"])
|
||||
assert not errors, f"resume mutation validation failed: {errors}"
|
||||
|
||||
def test_force_stop_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["force_stop"])
|
||||
assert not errors, f"force_stop mutation validation failed: {errors}"
|
||||
|
||||
def test_reboot_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["reboot"])
|
||||
assert not errors, f"reboot mutation validation failed: {errors}"
|
||||
|
||||
def test_reset_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["reset"])
|
||||
assert not errors, f"reset mutation validation failed: {errors}"
|
||||
|
||||
def test_all_vm_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.virtualization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _VM_MUTATIONS as MUTATIONS
|
||||
|
||||
expected = {"start", "stop", "pause", "resume", "force_stop", "reboot", "reset"}
|
||||
assert set(MUTATIONS.keys()) == expected
|
||||
@@ -490,19 +490,19 @@ class TestNotificationQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/notifications.py."""
|
||||
|
||||
def test_overview_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import QUERIES
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["overview"])
|
||||
assert not errors, f"overview query validation failed: {errors}"
|
||||
|
||||
def test_list_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import QUERIES
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["list"])
|
||||
assert not errors, f"list query validation failed: {errors}"
|
||||
|
||||
def test_all_notification_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import QUERIES
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_QUERIES as QUERIES
|
||||
|
||||
assert set(QUERIES.keys()) == {"overview", "list"}
|
||||
|
||||
@@ -511,67 +511,67 @@ class TestNotificationMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/notifications.py."""
|
||||
|
||||
def test_create_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["create"])
|
||||
assert not errors, f"create mutation validation failed: {errors}"
|
||||
|
||||
def test_archive_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["archive"])
|
||||
assert not errors, f"archive mutation validation failed: {errors}"
|
||||
|
||||
def test_unread_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["unread"])
|
||||
assert not errors, f"unread mutation validation failed: {errors}"
|
||||
|
||||
def test_delete_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["delete"])
|
||||
assert not errors, f"delete mutation validation failed: {errors}"
|
||||
|
||||
def test_delete_archived_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["delete_archived"])
|
||||
assert not errors, f"delete_archived mutation validation failed: {errors}"
|
||||
|
||||
def test_archive_all_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["archive_all"])
|
||||
assert not errors, f"archive_all mutation validation failed: {errors}"
|
||||
|
||||
def test_archive_many_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["archive_many"])
|
||||
assert not errors, f"archive_many mutation validation failed: {errors}"
|
||||
|
||||
def test_unarchive_many_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["unarchive_many"])
|
||||
assert not errors, f"unarchive_many mutation validation failed: {errors}"
|
||||
|
||||
def test_unarchive_all_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["unarchive_all"])
|
||||
assert not errors, f"unarchive_all mutation validation failed: {errors}"
|
||||
|
||||
def test_recalculate_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["recalculate"])
|
||||
assert not errors, f"recalculate mutation validation failed: {errors}"
|
||||
|
||||
def test_all_notification_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _NOTIFICATION_MUTATIONS as MUTATIONS
|
||||
|
||||
expected = {
|
||||
"create",
|
||||
@@ -595,19 +595,19 @@ class TestRcloneQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/rclone.py."""
|
||||
|
||||
def test_list_remotes_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.rclone import QUERIES
|
||||
from unraid_mcp.tools.unraid import _RCLONE_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["list_remotes"])
|
||||
assert not errors, f"list_remotes query validation failed: {errors}"
|
||||
|
||||
def test_config_form_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.rclone import QUERIES
|
||||
from unraid_mcp.tools.unraid import _RCLONE_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["config_form"])
|
||||
assert not errors, f"config_form query validation failed: {errors}"
|
||||
|
||||
def test_all_rclone_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.rclone import QUERIES
|
||||
from unraid_mcp.tools.unraid import _RCLONE_QUERIES as QUERIES
|
||||
|
||||
assert set(QUERIES.keys()) == {"list_remotes", "config_form"}
|
||||
|
||||
@@ -616,19 +616,19 @@ class TestRcloneMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/rclone.py."""
|
||||
|
||||
def test_create_remote_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.rclone import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _RCLONE_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["create_remote"])
|
||||
assert not errors, f"create_remote mutation validation failed: {errors}"
|
||||
|
||||
def test_delete_remote_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.rclone import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _RCLONE_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["delete_remote"])
|
||||
assert not errors, f"delete_remote mutation validation failed: {errors}"
|
||||
|
||||
def test_all_rclone_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.rclone import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _RCLONE_MUTATIONS as MUTATIONS
|
||||
|
||||
assert set(MUTATIONS.keys()) == {"create_remote", "delete_remote"}
|
||||
|
||||
@@ -640,13 +640,13 @@ class TestUsersQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/users.py."""
|
||||
|
||||
def test_me_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.users import QUERIES
|
||||
from unraid_mcp.tools.unraid import _USER_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["me"])
|
||||
assert not errors, f"me query validation failed: {errors}"
|
||||
|
||||
def test_all_users_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.users import QUERIES
|
||||
from unraid_mcp.tools.unraid import _USER_QUERIES as QUERIES
|
||||
|
||||
assert set(QUERIES.keys()) == {"me"}
|
||||
|
||||
@@ -658,19 +658,19 @@ class TestKeysQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/keys.py."""
|
||||
|
||||
def test_list_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import QUERIES
|
||||
from unraid_mcp.tools.unraid import _KEY_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["list"])
|
||||
assert not errors, f"list query validation failed: {errors}"
|
||||
|
||||
def test_get_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import QUERIES
|
||||
from unraid_mcp.tools.unraid import _KEY_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["get"])
|
||||
assert not errors, f"get query validation failed: {errors}"
|
||||
|
||||
def test_all_keys_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import QUERIES
|
||||
from unraid_mcp.tools.unraid import _KEY_QUERIES as QUERIES
|
||||
|
||||
assert set(QUERIES.keys()) == {"list", "get"}
|
||||
|
||||
@@ -679,37 +679,37 @@ class TestKeysMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/keys.py."""
|
||||
|
||||
def test_create_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _KEY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["create"])
|
||||
assert not errors, f"create mutation validation failed: {errors}"
|
||||
|
||||
def test_update_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _KEY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["update"])
|
||||
assert not errors, f"update mutation validation failed: {errors}"
|
||||
|
||||
def test_delete_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _KEY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["delete"])
|
||||
assert not errors, f"delete mutation validation failed: {errors}"
|
||||
|
||||
def test_add_role_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _KEY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["add_role"])
|
||||
assert not errors, f"add_role mutation validation failed: {errors}"
|
||||
|
||||
def test_remove_role_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _KEY_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["remove_role"])
|
||||
assert not errors, f"remove_role mutation validation failed: {errors}"
|
||||
|
||||
def test_all_keys_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.keys import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _KEY_MUTATIONS as MUTATIONS
|
||||
|
||||
assert set(MUTATIONS.keys()) == {"create", "update", "delete", "add_role", "remove_role"}
|
||||
|
||||
@@ -721,19 +721,19 @@ class TestSettingsMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/settings.py."""
|
||||
|
||||
def test_update_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.settings import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _SETTING_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["update"])
|
||||
assert not errors, f"update mutation validation failed: {errors}"
|
||||
|
||||
def test_configure_ups_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.settings import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _SETTING_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["configure_ups"])
|
||||
assert not errors, f"configure_ups mutation validation failed: {errors}"
|
||||
|
||||
def test_all_settings_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.settings import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _SETTING_MUTATIONS as MUTATIONS
|
||||
|
||||
expected = {
|
||||
"update",
|
||||
@@ -790,7 +790,7 @@ class TestCustomizationQueries:
|
||||
assert not errors, f"is_initial_setup (isFreshInstall) query validation failed: {errors}"
|
||||
|
||||
def test_sso_enabled_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.customization import QUERIES
|
||||
from unraid_mcp.tools.unraid import _CUSTOMIZATION_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["sso_enabled"])
|
||||
assert not errors, f"sso_enabled query validation failed: {errors}"
|
||||
@@ -805,13 +805,13 @@ class TestCustomizationMutations:
|
||||
"""Validate mutations from unraid_mcp/tools/customization.py."""
|
||||
|
||||
def test_set_theme_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.customization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _CUSTOMIZATION_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["set_theme"])
|
||||
assert not errors, f"set_theme mutation validation failed: {errors}"
|
||||
|
||||
def test_all_customization_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.customization import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _CUSTOMIZATION_MUTATIONS as MUTATIONS
|
||||
|
||||
assert set(MUTATIONS.keys()) == {"set_theme"}
|
||||
|
||||
@@ -823,13 +823,13 @@ class TestPluginsQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/plugins.py."""
|
||||
|
||||
def test_list_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.plugins import QUERIES
|
||||
from unraid_mcp.tools.unraid import _PLUGIN_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["list"])
|
||||
assert not errors, f"plugins list query validation failed: {errors}"
|
||||
|
||||
def test_all_plugins_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.plugins import QUERIES
|
||||
from unraid_mcp.tools.unraid import _PLUGIN_QUERIES as QUERIES
|
||||
|
||||
assert set(QUERIES.keys()) == {"list"}
|
||||
|
||||
@@ -838,19 +838,19 @@ class TestPluginsMutations:
|
||||
"""Validate all mutations from unraid_mcp/tools/plugins.py."""
|
||||
|
||||
def test_add_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.plugins import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _PLUGIN_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["add"])
|
||||
assert not errors, f"plugins add mutation validation failed: {errors}"
|
||||
|
||||
def test_remove_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.plugins import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _PLUGIN_MUTATIONS as MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["remove"])
|
||||
assert not errors, f"plugins remove mutation validation failed: {errors}"
|
||||
|
||||
def test_all_plugins_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.plugins import MUTATIONS
|
||||
from unraid_mcp.tools.unraid import _PLUGIN_MUTATIONS as MUTATIONS
|
||||
|
||||
assert set(MUTATIONS.keys()) == {"add", "remove"}
|
||||
|
||||
@@ -862,37 +862,37 @@ class TestOidcQueries:
|
||||
"""Validate all queries from unraid_mcp/tools/oidc.py."""
|
||||
|
||||
def test_providers_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.oidc import QUERIES
|
||||
from unraid_mcp.tools.unraid import _OIDC_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["providers"])
|
||||
assert not errors, f"oidc providers query validation failed: {errors}"
|
||||
|
||||
def test_provider_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.oidc import QUERIES
|
||||
from unraid_mcp.tools.unraid import _OIDC_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["provider"])
|
||||
assert not errors, f"oidc provider query validation failed: {errors}"
|
||||
|
||||
def test_configuration_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.oidc import QUERIES
|
||||
from unraid_mcp.tools.unraid import _OIDC_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["configuration"])
|
||||
assert not errors, f"oidc configuration query validation failed: {errors}"
|
||||
|
||||
def test_public_providers_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.oidc import QUERIES
|
||||
from unraid_mcp.tools.unraid import _OIDC_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["public_providers"])
|
||||
assert not errors, f"oidc public_providers query validation failed: {errors}"
|
||||
|
||||
def test_validate_session_query(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.oidc import QUERIES
|
||||
from unraid_mcp.tools.unraid import _OIDC_QUERIES as QUERIES
|
||||
|
||||
errors = _validate_operation(schema, QUERIES["validate_session"])
|
||||
assert not errors, f"oidc validate_session query validation failed: {errors}"
|
||||
|
||||
def test_all_oidc_queries_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.oidc import QUERIES
|
||||
from unraid_mcp.tools.unraid import _OIDC_QUERIES as QUERIES
|
||||
|
||||
expected = {
|
||||
"providers",
|
||||
@@ -911,36 +911,43 @@ class TestSchemaCompleteness:
|
||||
"""Validate that all tool operations are covered by the schema."""
|
||||
|
||||
def test_all_tool_queries_validate(self, schema: GraphQLSchema) -> None:
|
||||
"""Bulk-validate every query across all tools.
|
||||
"""Bulk-validate every query/mutation across all domains in the consolidated unraid module.
|
||||
|
||||
Known schema mismatches are tracked in KNOWN_SCHEMA_ISSUES and excluded
|
||||
from the assertion so the test suite stays green while the underlying
|
||||
tool queries are fixed incrementally.
|
||||
"""
|
||||
import importlib
|
||||
import unraid_mcp.tools.unraid as unraid_mod
|
||||
|
||||
tool_modules = [
|
||||
"unraid_mcp.tools.info",
|
||||
"unraid_mcp.tools.array",
|
||||
"unraid_mcp.tools.storage",
|
||||
"unraid_mcp.tools.docker",
|
||||
"unraid_mcp.tools.virtualization",
|
||||
"unraid_mcp.tools.notifications",
|
||||
"unraid_mcp.tools.rclone",
|
||||
"unraid_mcp.tools.users",
|
||||
"unraid_mcp.tools.keys",
|
||||
"unraid_mcp.tools.settings",
|
||||
"unraid_mcp.tools.customization",
|
||||
"unraid_mcp.tools.plugins",
|
||||
"unraid_mcp.tools.oidc",
|
||||
# All query/mutation dicts in the consolidated module, keyed by domain/type label
|
||||
all_operation_dicts: list[tuple[str, dict[str, str]]] = [
|
||||
("system/QUERIES", unraid_mod._SYSTEM_QUERIES),
|
||||
("array/QUERIES", unraid_mod._ARRAY_QUERIES),
|
||||
("array/MUTATIONS", unraid_mod._ARRAY_MUTATIONS),
|
||||
("disk/QUERIES", unraid_mod._DISK_QUERIES),
|
||||
("disk/MUTATIONS", unraid_mod._DISK_MUTATIONS),
|
||||
("docker/QUERIES", unraid_mod._DOCKER_QUERIES),
|
||||
("docker/MUTATIONS", unraid_mod._DOCKER_MUTATIONS),
|
||||
("vm/QUERIES", unraid_mod._VM_QUERIES),
|
||||
("vm/MUTATIONS", unraid_mod._VM_MUTATIONS),
|
||||
("notification/QUERIES", unraid_mod._NOTIFICATION_QUERIES),
|
||||
("notification/MUTATIONS", unraid_mod._NOTIFICATION_MUTATIONS),
|
||||
("rclone/QUERIES", unraid_mod._RCLONE_QUERIES),
|
||||
("rclone/MUTATIONS", unraid_mod._RCLONE_MUTATIONS),
|
||||
("user/QUERIES", unraid_mod._USER_QUERIES),
|
||||
("key/QUERIES", unraid_mod._KEY_QUERIES),
|
||||
("key/MUTATIONS", unraid_mod._KEY_MUTATIONS),
|
||||
("setting/MUTATIONS", unraid_mod._SETTING_MUTATIONS),
|
||||
("customization/QUERIES", unraid_mod._CUSTOMIZATION_QUERIES),
|
||||
("customization/MUTATIONS", unraid_mod._CUSTOMIZATION_MUTATIONS),
|
||||
("plugin/QUERIES", unraid_mod._PLUGIN_QUERIES),
|
||||
("plugin/MUTATIONS", unraid_mod._PLUGIN_MUTATIONS),
|
||||
("oidc/QUERIES", unraid_mod._OIDC_QUERIES),
|
||||
]
|
||||
|
||||
# Known schema mismatches in tool QUERIES/MUTATIONS dicts.
|
||||
# These represent bugs in the tool implementation, not in the tests.
|
||||
# Remove entries from this set as they are fixed.
|
||||
# Known schema mismatches — bugs in tool implementation, not in tests.
|
||||
# Remove entries as they are fixed.
|
||||
KNOWN_SCHEMA_ISSUES: set[str] = {
|
||||
# storage: unassignedDevices not in Query type
|
||||
"storage/QUERIES/unassigned",
|
||||
# customization: Customization.theme field does not exist
|
||||
"customization/QUERIES/theme",
|
||||
# customization: publicPartnerInfo not in Query type
|
||||
@@ -953,26 +960,10 @@ class TestSchemaCompleteness:
|
||||
unexpected_passes: list[str] = []
|
||||
total = 0
|
||||
|
||||
for module_path in tool_modules:
|
||||
mod = importlib.import_module(module_path)
|
||||
tool_name = module_path.split(".")[-1]
|
||||
|
||||
queries = getattr(mod, "QUERIES", {})
|
||||
for action, query_str in queries.items():
|
||||
for label, ops_dict in all_operation_dicts:
|
||||
for action, query_str in ops_dict.items():
|
||||
total += 1
|
||||
key = f"{tool_name}/QUERIES/{action}"
|
||||
errors = _validate_operation(schema, query_str)
|
||||
if errors:
|
||||
if key not in KNOWN_SCHEMA_ISSUES:
|
||||
failures.append(f"{key}: {errors[0]}")
|
||||
else:
|
||||
if key in KNOWN_SCHEMA_ISSUES:
|
||||
unexpected_passes.append(key)
|
||||
|
||||
mutations = getattr(mod, "MUTATIONS", {})
|
||||
for action, query_str in mutations.items():
|
||||
total += 1
|
||||
key = f"{tool_name}/MUTATIONS/{action}"
|
||||
key = f"{label}/{action}"
|
||||
errors = _validate_operation(schema, query_str)
|
||||
if errors:
|
||||
if key not in KNOWN_SCHEMA_ISSUES:
|
||||
@@ -982,7 +973,6 @@ class TestSchemaCompleteness:
|
||||
unexpected_passes.append(key)
|
||||
|
||||
if unexpected_passes:
|
||||
# A known issue was fixed — remove it from KNOWN_SCHEMA_ISSUES
|
||||
raise AssertionError(
|
||||
"The following operations are listed in KNOWN_SCHEMA_ISSUES but now pass — "
|
||||
"remove them from the set:\n" + "\n".join(unexpected_passes)
|
||||
@@ -1003,29 +993,32 @@ class TestSchemaCompleteness:
|
||||
|
||||
def test_total_operations_count(self, schema: GraphQLSchema) -> None:
|
||||
"""Verify the expected number of tool operations exist."""
|
||||
import importlib
|
||||
import unraid_mcp.tools.unraid as unraid_mod
|
||||
|
||||
tool_modules = [
|
||||
"unraid_mcp.tools.info",
|
||||
"unraid_mcp.tools.array",
|
||||
"unraid_mcp.tools.storage",
|
||||
"unraid_mcp.tools.docker",
|
||||
"unraid_mcp.tools.virtualization",
|
||||
"unraid_mcp.tools.notifications",
|
||||
"unraid_mcp.tools.rclone",
|
||||
"unraid_mcp.tools.users",
|
||||
"unraid_mcp.tools.keys",
|
||||
"unraid_mcp.tools.settings",
|
||||
"unraid_mcp.tools.customization",
|
||||
"unraid_mcp.tools.plugins",
|
||||
"unraid_mcp.tools.oidc",
|
||||
all_dicts = [
|
||||
unraid_mod._SYSTEM_QUERIES,
|
||||
unraid_mod._ARRAY_QUERIES,
|
||||
unraid_mod._ARRAY_MUTATIONS,
|
||||
unraid_mod._DISK_QUERIES,
|
||||
unraid_mod._DISK_MUTATIONS,
|
||||
unraid_mod._DOCKER_QUERIES,
|
||||
unraid_mod._DOCKER_MUTATIONS,
|
||||
unraid_mod._VM_QUERIES,
|
||||
unraid_mod._VM_MUTATIONS,
|
||||
unraid_mod._NOTIFICATION_QUERIES,
|
||||
unraid_mod._NOTIFICATION_MUTATIONS,
|
||||
unraid_mod._RCLONE_QUERIES,
|
||||
unraid_mod._RCLONE_MUTATIONS,
|
||||
unraid_mod._USER_QUERIES,
|
||||
unraid_mod._KEY_QUERIES,
|
||||
unraid_mod._KEY_MUTATIONS,
|
||||
unraid_mod._SETTING_MUTATIONS,
|
||||
unraid_mod._CUSTOMIZATION_QUERIES,
|
||||
unraid_mod._CUSTOMIZATION_MUTATIONS,
|
||||
unraid_mod._PLUGIN_QUERIES,
|
||||
unraid_mod._PLUGIN_MUTATIONS,
|
||||
unraid_mod._OIDC_QUERIES,
|
||||
]
|
||||
|
||||
total = 0
|
||||
for module_path in tool_modules:
|
||||
mod = importlib.import_module(module_path)
|
||||
total += len(getattr(mod, "QUERIES", {}))
|
||||
total += len(getattr(mod, "MUTATIONS", {}))
|
||||
|
||||
# Operations across all tools (queries + mutations in dicts)
|
||||
total = sum(len(d) for d in all_dicts)
|
||||
assert total >= 50, f"Expected at least 50 operations, found {total}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for unraid_array tool."""
|
||||
"""Tests for array subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -11,36 +11,36 @@ from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.array.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestArrayValidation:
|
||||
async def test_invalid_action_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
async def test_invalid_subaction_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="start")
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="array", subaction="start")
|
||||
|
||||
async def test_removed_actions_are_invalid(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
for action in (
|
||||
for subaction in (
|
||||
"start",
|
||||
"stop",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"clear_stats",
|
||||
):
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action=action)
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="array", subaction=subaction)
|
||||
|
||||
async def test_parity_start_requires_correct(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="correct is required"):
|
||||
await tool_fn(action="parity_start")
|
||||
await tool_fn(action="array", subaction="parity_start")
|
||||
_mock_graphql.assert_not_called()
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ class TestArrayActions:
|
||||
async def test_parity_start(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"start": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_start", correct=False)
|
||||
result = await tool_fn(action="array", subaction="parity_start", correct=False)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "parity_start"
|
||||
assert result["subaction"] == "parity_start"
|
||||
_mock_graphql.assert_called_once()
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"correct": False}
|
||||
@@ -58,7 +58,7 @@ class TestArrayActions:
|
||||
async def test_parity_start_with_correct(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"start": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_start", correct=True)
|
||||
result = await tool_fn(action="array", subaction="parity_start", correct=True)
|
||||
assert result["success"] is True
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"correct": True}
|
||||
@@ -66,32 +66,32 @@ class TestArrayActions:
|
||||
async def test_parity_status(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"array": {"parityCheckStatus": {"progress": 50}}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_status")
|
||||
result = await tool_fn(action="array", subaction="parity_status")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_parity_pause(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"pause": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_pause")
|
||||
result = await tool_fn(action="array", subaction="parity_pause")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_parity_resume(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"resume": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_resume")
|
||||
result = await tool_fn(action="array", subaction="parity_resume")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_parity_cancel(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"cancel": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_cancel")
|
||||
result = await tool_fn(action="array", subaction="parity_cancel")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.side_effect = RuntimeError("disk error")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to execute array/parity_status"):
|
||||
await tool_fn(action="parity_status")
|
||||
await tool_fn(action="array", subaction="parity_status")
|
||||
|
||||
|
||||
class TestArrayMutationFailures:
|
||||
@@ -100,14 +100,14 @@ class TestArrayMutationFailures:
|
||||
async def test_parity_start_mutation_returns_false(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"start": False}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_start", correct=False)
|
||||
result = await tool_fn(action="array", subaction="parity_start", correct=False)
|
||||
assert result["success"] is True
|
||||
assert result["data"] == {"parityCheck": {"start": False}}
|
||||
|
||||
async def test_parity_start_mutation_returns_null(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"start": None}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_start", correct=False)
|
||||
result = await tool_fn(action="array", subaction="parity_start", correct=False)
|
||||
assert result["success"] is True
|
||||
assert result["data"] == {"parityCheck": {"start": None}}
|
||||
|
||||
@@ -116,7 +116,7 @@ class TestArrayMutationFailures:
|
||||
) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"start": {}}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="parity_start", correct=False)
|
||||
result = await tool_fn(action="array", subaction="parity_start", correct=False)
|
||||
assert result["success"] is True
|
||||
assert result["data"] == {"parityCheck": {"start": {}}}
|
||||
|
||||
@@ -124,7 +124,7 @@ class TestArrayMutationFailures:
|
||||
_mock_graphql.side_effect = TimeoutError("operation timed out")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="timed out"):
|
||||
await tool_fn(action="parity_cancel")
|
||||
await tool_fn(action="array", subaction="parity_cancel")
|
||||
|
||||
|
||||
class TestArrayNetworkErrors:
|
||||
@@ -134,13 +134,13 @@ class TestArrayNetworkErrors:
|
||||
_mock_graphql.side_effect = ToolError("HTTP error 500: Internal Server Error")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="HTTP error 500"):
|
||||
await tool_fn(action="parity_start", correct=False)
|
||||
await tool_fn(action="array", subaction="parity_start", correct=False)
|
||||
|
||||
async def test_connection_refused(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.side_effect = ToolError("Network connection error: Connection refused")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Network connection error"):
|
||||
await tool_fn(action="parity_status")
|
||||
await tool_fn(action="array", subaction="parity_status")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -156,7 +156,7 @@ async def test_parity_history_returns_history(_mock_graphql):
|
||||
_mock_graphql.return_value = {
|
||||
"parityHistory": [{"date": "2026-03-01T00:00:00Z", "status": "COMPLETED", "errors": 0}]
|
||||
}
|
||||
result = await _make_tool()(action="parity_history")
|
||||
result = await _make_tool()(action="array", subaction="parity_history")
|
||||
assert result["success"] is True
|
||||
assert len(result["data"]["parityHistory"]) == 1
|
||||
|
||||
@@ -167,20 +167,20 @@ async def test_parity_history_returns_history(_mock_graphql):
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_array(_mock_graphql):
|
||||
_mock_graphql.return_value = {"array": {"setState": {"state": "STARTED"}}}
|
||||
result = await _make_tool()(action="start_array")
|
||||
result = await _make_tool()(action="array", subaction="start_array")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_array_requires_confirm(_mock_graphql):
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await _make_tool()(action="stop_array", confirm=False)
|
||||
await _make_tool()(action="array", subaction="stop_array", confirm=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_array_with_confirm(_mock_graphql):
|
||||
_mock_graphql.return_value = {"array": {"setState": {"state": "STOPPED"}}}
|
||||
result = await _make_tool()(action="stop_array", confirm=True)
|
||||
result = await _make_tool()(action="array", subaction="stop_array", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@@ -190,13 +190,13 @@ async def test_stop_array_with_confirm(_mock_graphql):
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_disk_requires_disk_id(_mock_graphql):
|
||||
with pytest.raises(ToolError, match="disk_id"):
|
||||
await _make_tool()(action="add_disk")
|
||||
await _make_tool()(action="array", subaction="add_disk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_disk_success(_mock_graphql):
|
||||
_mock_graphql.return_value = {"array": {"addDiskToArray": {"state": "STARTED"}}}
|
||||
result = await _make_tool()(action="add_disk", disk_id="abc123:local")
|
||||
result = await _make_tool()(action="array", subaction="add_disk", disk_id="abc123:local")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@@ -206,13 +206,17 @@ async def test_add_disk_success(_mock_graphql):
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_disk_requires_confirm(_mock_graphql):
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await _make_tool()(action="remove_disk", disk_id="abc123:local", confirm=False)
|
||||
await _make_tool()(
|
||||
action="array", subaction="remove_disk", disk_id="abc123:local", confirm=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_disk_with_confirm(_mock_graphql):
|
||||
_mock_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
|
||||
result = await _make_tool()(action="remove_disk", disk_id="abc123:local", confirm=True)
|
||||
result = await _make_tool()(
|
||||
action="array", subaction="remove_disk", disk_id="abc123:local", confirm=True
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@@ -222,13 +226,13 @@ async def test_remove_disk_with_confirm(_mock_graphql):
|
||||
@pytest.mark.asyncio
|
||||
async def test_mount_disk_requires_disk_id(_mock_graphql):
|
||||
with pytest.raises(ToolError, match="disk_id"):
|
||||
await _make_tool()(action="mount_disk")
|
||||
await _make_tool()(action="array", subaction="mount_disk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmount_disk_success(_mock_graphql):
|
||||
_mock_graphql.return_value = {"array": {"unmountArrayDisk": {"id": "abc123:local"}}}
|
||||
result = await _make_tool()(action="unmount_disk", disk_id="abc123:local")
|
||||
result = await _make_tool()(action="array", subaction="unmount_disk", disk_id="abc123:local")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@@ -238,11 +242,15 @@ async def test_unmount_disk_success(_mock_graphql):
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_disk_stats_requires_confirm(_mock_graphql):
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await _make_tool()(action="clear_disk_stats", disk_id="abc123:local", confirm=False)
|
||||
await _make_tool()(
|
||||
action="array", subaction="clear_disk_stats", disk_id="abc123:local", confirm=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_disk_stats_with_confirm(_mock_graphql):
|
||||
_mock_graphql.return_value = {"array": {"clearArrayDiskStatistics": True}}
|
||||
result = await _make_tool()(action="clear_disk_stats", disk_id="abc123:local", confirm=True)
|
||||
result = await _make_tool()(
|
||||
action="array", subaction="clear_disk_stats", disk_id="abc123:local", confirm=True
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# tests/test_customization.py
|
||||
"""Tests for unraid_customization tool."""
|
||||
"""Tests for customization subactions of the consolidated unraid tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,16 +11,12 @@ from conftest import make_tool_fn
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql():
|
||||
with patch("unraid_mcp.tools.customization.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.customization",
|
||||
"register_customization_tool",
|
||||
"unraid_customization",
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -28,23 +24,22 @@ async def test_theme_returns_customization(_mock_graphql):
|
||||
_mock_graphql.return_value = {
|
||||
"customization": {"theme": {"name": "azure"}, "partnerInfo": None, "activationCode": None}
|
||||
}
|
||||
result = await _make_tool()(action="theme")
|
||||
assert result["success"] is True
|
||||
result = await _make_tool()(action="customization", subaction="theme")
|
||||
assert "customization" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_theme(_mock_graphql):
|
||||
_mock_graphql.return_value = {"publicTheme": {"name": "black"}}
|
||||
result = await _make_tool()(action="public_theme")
|
||||
assert result["success"] is True
|
||||
result = await _make_tool()(action="customization", subaction="public_theme")
|
||||
assert "publicTheme" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_initial_setup(_mock_graphql):
|
||||
_mock_graphql.return_value = {"isInitialSetup": False}
|
||||
result = await _make_tool()(action="is_initial_setup")
|
||||
assert result["success"] is True
|
||||
assert result["data"]["isInitialSetup"] is False
|
||||
result = await _make_tool()(action="customization", subaction="is_initial_setup")
|
||||
assert result["isInitialSetup"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -52,7 +47,7 @@ async def test_set_theme_requires_theme(_mock_graphql):
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
with pytest.raises(ToolError, match="theme_name"):
|
||||
await _make_tool()(action="set_theme")
|
||||
await _make_tool()(action="customization", subaction="set_theme")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -60,5 +55,5 @@ async def test_set_theme_success(_mock_graphql):
|
||||
_mock_graphql.return_value = {
|
||||
"customization": {"setTheme": {"name": "azure", "showBannerImage": True}}
|
||||
}
|
||||
result = await _make_tool()(action="set_theme", theme_name="azure")
|
||||
result = await _make_tool()(action="customization", subaction="set_theme", theme_name="azure")
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
"""Tests for unraid_docker tool."""
|
||||
"""Tests for docker subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import get_args
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import make_tool_fn
|
||||
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
from unraid_mcp.tools.docker import (
|
||||
DOCKER_ACTIONS,
|
||||
find_container_by_identifier,
|
||||
get_available_container_names,
|
||||
)
|
||||
|
||||
|
||||
# --- Unit tests for helpers ---
|
||||
|
||||
|
||||
class TestFindContainerByIdentifier:
|
||||
def test_by_exact_id(self) -> None:
|
||||
containers = [{"id": "abc123", "names": ["plex"]}]
|
||||
assert find_container_by_identifier("abc123", containers) == containers[0]
|
||||
|
||||
def test_by_exact_name(self) -> None:
|
||||
containers = [{"id": "abc123", "names": ["plex"]}]
|
||||
assert find_container_by_identifier("plex", containers) == containers[0]
|
||||
|
||||
def test_fuzzy_match(self) -> None:
|
||||
containers = [{"id": "abc123", "names": ["plex-media-server"]}]
|
||||
result = find_container_by_identifier("plex", containers)
|
||||
assert result == containers[0]
|
||||
|
||||
def test_not_found(self) -> None:
|
||||
containers = [{"id": "abc123", "names": ["plex"]}]
|
||||
assert find_container_by_identifier("sonarr", containers) is None
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
assert find_container_by_identifier("plex", []) is None
|
||||
|
||||
|
||||
class TestGetAvailableContainerNames:
|
||||
def test_extracts_names(self) -> None:
|
||||
containers = [
|
||||
{"names": ["plex"]},
|
||||
{"names": ["sonarr", "sonarr-v3"]},
|
||||
]
|
||||
names = get_available_container_names(containers)
|
||||
assert "plex" in names
|
||||
assert "sonarr" in names
|
||||
assert "sonarr-v3" in names
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert get_available_container_names([]) == []
|
||||
|
||||
|
||||
# --- Integration tests ---
|
||||
@@ -60,55 +14,34 @@ class TestGetAvailableContainerNames:
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestDockerValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
[
|
||||
"logs",
|
||||
"port_conflicts",
|
||||
"check_updates",
|
||||
"pause",
|
||||
"unpause",
|
||||
"remove",
|
||||
"update",
|
||||
"update_all",
|
||||
"create_folder",
|
||||
"delete_entries",
|
||||
"reset_template_mappings",
|
||||
],
|
||||
)
|
||||
def test_removed_actions_are_gone(self, action: str) -> None:
|
||||
assert action not in get_args(DOCKER_ACTIONS), (
|
||||
f"Action '{action}' should have been removed from DOCKER_ACTIONS"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("action", ["start", "stop", "details"])
|
||||
@pytest.mark.parametrize("subaction", ["start", "stop", "details"])
|
||||
async def test_container_actions_require_id(
|
||||
self, _mock_graphql: AsyncMock, action: str
|
||||
self, _mock_graphql: AsyncMock, subaction: str
|
||||
) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="container_id"):
|
||||
await tool_fn(action=action)
|
||||
await tool_fn(action="docker", subaction=subaction)
|
||||
|
||||
async def test_network_details_requires_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="network_id"):
|
||||
await tool_fn(action="network_details")
|
||||
await tool_fn(action="docker", subaction="network_details")
|
||||
|
||||
async def test_non_logs_action_ignores_tail_lines_validation(
|
||||
self, _mock_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_graphql.return_value = {"docker": {"containers": []}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list")
|
||||
result = await tool_fn(action="docker", subaction="list")
|
||||
assert result["containers"] == []
|
||||
|
||||
|
||||
@@ -118,7 +51,7 @@ class TestDockerActions:
|
||||
"docker": {"containers": [{"id": "c1", "names": ["plex"], "state": "running"}]}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list")
|
||||
result = await tool_fn(action="docker", subaction="list")
|
||||
assert len(result["containers"]) == 1
|
||||
|
||||
async def test_start_container(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -136,13 +69,13 @@ class TestDockerActions:
|
||||
},
|
||||
]
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="start", container_id="plex")
|
||||
result = await tool_fn(action="docker", subaction="start", container_id="plex")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_networks(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"docker": {"networks": [{"id": "net:1", "name": "bridge"}]}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="networks")
|
||||
result = await tool_fn(action="docker", subaction="networks")
|
||||
assert len(result["networks"]) == 1
|
||||
|
||||
async def test_idempotent_start(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -152,7 +85,7 @@ class TestDockerActions:
|
||||
{"idempotent_success": True, "docker": {}},
|
||||
]
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="start", container_id="plex")
|
||||
result = await tool_fn(action="docker", subaction="start", container_id="plex")
|
||||
assert result["idempotent"] is True
|
||||
|
||||
async def test_restart(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -163,9 +96,9 @@ class TestDockerActions:
|
||||
{"docker": {"start": {"id": cid, "state": "running"}}},
|
||||
]
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="restart", container_id="plex")
|
||||
result = await tool_fn(action="docker", subaction="restart", container_id="plex")
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "restart"
|
||||
assert result["subaction"] == "restart"
|
||||
|
||||
async def test_restart_idempotent_stop(self, _mock_graphql: AsyncMock) -> None:
|
||||
cid = "a" * 64 + ":local"
|
||||
@@ -175,7 +108,7 @@ class TestDockerActions:
|
||||
{"docker": {"start": {"id": cid, "state": "running"}}},
|
||||
]
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="restart", container_id="plex")
|
||||
result = await tool_fn(action="docker", subaction="restart", container_id="plex")
|
||||
assert result["success"] is True
|
||||
assert "note" in result
|
||||
|
||||
@@ -188,14 +121,14 @@ class TestDockerActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="details", container_id="plex")
|
||||
result = await tool_fn(action="docker", subaction="details", container_id="plex")
|
||||
assert result["names"] == ["plex"]
|
||||
|
||||
async def test_generic_exception_wraps_in_tool_error(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.side_effect = RuntimeError("unexpected failure")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to execute docker/list"):
|
||||
await tool_fn(action="list")
|
||||
await tool_fn(action="docker", subaction="list")
|
||||
|
||||
async def test_short_id_prefix_ambiguous_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
@@ -214,7 +147,7 @@ class TestDockerActions:
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="ambiguous"):
|
||||
await tool_fn(action="details", container_id="abcdef123456")
|
||||
await tool_fn(action="docker", subaction="details", container_id="abcdef123456")
|
||||
|
||||
|
||||
class TestDockerMutationFailures:
|
||||
@@ -228,7 +161,7 @@ class TestDockerMutationFailures:
|
||||
{"docker": {}},
|
||||
]
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="start", container_id="plex")
|
||||
result = await tool_fn(action="docker", subaction="start", container_id="plex")
|
||||
assert result["success"] is True
|
||||
assert result["container"] is None
|
||||
|
||||
@@ -240,7 +173,7 @@ class TestDockerMutationFailures:
|
||||
{"docker": {"stop": {"id": cid, "state": "running"}}},
|
||||
]
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="stop", container_id="plex")
|
||||
result = await tool_fn(action="docker", subaction="stop", container_id="plex")
|
||||
assert result["success"] is True
|
||||
assert result["container"]["state"] == "running"
|
||||
|
||||
@@ -254,7 +187,7 @@ class TestDockerMutationFailures:
|
||||
]
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="timed out"):
|
||||
await tool_fn(action="start", container_id="plex")
|
||||
await tool_fn(action="docker", subaction="start", container_id="plex")
|
||||
|
||||
|
||||
class TestDockerNetworkErrors:
|
||||
@@ -267,14 +200,14 @@ class TestDockerNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Connection refused"):
|
||||
await tool_fn(action="list")
|
||||
await tool_fn(action="docker", subaction="list")
|
||||
|
||||
async def test_list_http_401_unauthorized(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""HTTP 401 should propagate as ToolError."""
|
||||
_mock_graphql.side_effect = ToolError("HTTP error 401: Unauthorized")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="401"):
|
||||
await tool_fn(action="list")
|
||||
await tool_fn(action="docker", subaction="list")
|
||||
|
||||
async def test_json_decode_error_on_list(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Invalid JSON response should be wrapped in ToolError."""
|
||||
@@ -283,4 +216,4 @@ class TestDockerNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid JSON"):
|
||||
await tool_fn(action="list")
|
||||
await tool_fn(action="docker", subaction="list")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for unraid_health tool."""
|
||||
"""Tests for health subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import make_tool_fn
|
||||
@@ -12,26 +12,26 @@ from unraid_mcp.core.utils import safe_display_url
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.health.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.health", "register_health_tool", "unraid_health")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestHealthValidation:
|
||||
async def test_invalid_action(self, _mock_graphql: AsyncMock) -> None:
|
||||
async def test_invalid_subaction(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="invalid")
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="health", subaction="invalid")
|
||||
|
||||
|
||||
class TestHealthActions:
|
||||
async def test_test_connection(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"online": True}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="test_connection")
|
||||
result = await tool_fn(action="health", subaction="test_connection")
|
||||
assert result["status"] == "connected"
|
||||
assert result["online"] is True
|
||||
assert "latency_ms" in result
|
||||
@@ -46,13 +46,38 @@ class TestHealthActions:
|
||||
},
|
||||
"array": {"state": "STARTED"},
|
||||
"notifications": {"overview": {"unread": {"alert": 0, "warning": 0, "total": 3}}},
|
||||
"docker": {"containers": [{"id": "c1", "state": "running", "status": "Up 2 days"}]},
|
||||
"docker": {"containers": [{"id": "c1", "state": "RUNNING", "status": "Up 2 days"}]},
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="check")
|
||||
result = await tool_fn(action="health", subaction="check")
|
||||
assert result["status"] == "healthy"
|
||||
assert "api_latency_ms" in result
|
||||
|
||||
async def test_check_docker_counts_uppercase_states(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""ContainerState enum is UPPERCASE — running/stopped counts must use case-insensitive match."""
|
||||
_mock_graphql.return_value = {
|
||||
"info": {
|
||||
"machineId": "x",
|
||||
"versions": {"core": {"unraid": "7.0"}},
|
||||
"os": {"uptime": 1},
|
||||
},
|
||||
"array": {"state": "STARTED"},
|
||||
"notifications": {"overview": {"unread": {"alert": 0, "warning": 0, "total": 0}}},
|
||||
"docker": {
|
||||
"containers": [
|
||||
{"id": "c1", "state": "RUNNING"},
|
||||
{"id": "c2", "state": "RUNNING"},
|
||||
{"id": "c3", "state": "EXITED"},
|
||||
]
|
||||
},
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="health", subaction="check")
|
||||
svc = result["docker_services"]
|
||||
assert svc["total"] == 3
|
||||
assert svc["running"] == 2
|
||||
assert svc["stopped"] == 1
|
||||
|
||||
async def test_check_warning_on_alerts(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"info": {"machineId": "abc", "versions": {"unraid": "7.2"}, "os": {"uptime": 100}},
|
||||
@@ -61,20 +86,20 @@ class TestHealthActions:
|
||||
"docker": {"containers": []},
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="check")
|
||||
result = await tool_fn(action="health", subaction="check")
|
||||
assert result["status"] == "warning"
|
||||
assert any("alert" in i for i in result.get("issues", []))
|
||||
|
||||
async def test_check_no_data(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="check")
|
||||
result = await tool_fn(action="health", subaction="check")
|
||||
assert result["status"] == "unhealthy"
|
||||
|
||||
async def test_check_api_error(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.side_effect = Exception("Connection refused")
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="check")
|
||||
result = await tool_fn(action="health", subaction="check")
|
||||
assert result["status"] == "unhealthy"
|
||||
assert "Connection refused" in result["error"]
|
||||
|
||||
@@ -87,61 +112,51 @@ class TestHealthActions:
|
||||
"docker": {"containers": []},
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="check")
|
||||
result = await tool_fn(action="health", subaction="check")
|
||||
# Missing info escalates to "degraded"; alerts only escalate to "warning"
|
||||
# Severity should stay at "degraded" (not downgrade to "warning")
|
||||
assert result["status"] == "degraded"
|
||||
|
||||
async def test_diagnose_wraps_exception(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""When _diagnose_subscriptions raises, tool wraps in ToolError."""
|
||||
async def test_diagnose_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Diagnose returns subscription status."""
|
||||
tool_fn = _make_tool()
|
||||
mock_status = {"cpu": {"connection_state": "connected"}}
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_subscription_status = AsyncMock(return_value=mock_status)
|
||||
mock_manager.auto_start_enabled = True
|
||||
mock_manager.max_reconnect_attempts = 3
|
||||
mock_manager.subscription_configs = {}
|
||||
mock_manager.active_subscriptions = {}
|
||||
mock_manager.resource_data = {}
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.subscriptions.manager.subscription_manager", mock_manager),
|
||||
patch("unraid_mcp.subscriptions.resources.ensure_subscriptions_started", AsyncMock()),
|
||||
patch(
|
||||
"unraid_mcp.tools.health._diagnose_subscriptions",
|
||||
side_effect=RuntimeError("broken"),
|
||||
"unraid_mcp.subscriptions.utils._analyze_subscription_status",
|
||||
return_value=(0, []),
|
||||
),
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="diagnose")
|
||||
assert "subscriptions" in result
|
||||
assert "summary" in result
|
||||
|
||||
async def test_diagnose_wraps_exception(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""When subscription manager raises, tool wraps in ToolError."""
|
||||
tool_fn = _make_tool()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_subscription_status = AsyncMock(side_effect=RuntimeError("broken"))
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.subscriptions.manager.subscription_manager", mock_manager),
|
||||
patch("unraid_mcp.subscriptions.resources.ensure_subscriptions_started", AsyncMock()),
|
||||
patch(
|
||||
"unraid_mcp.subscriptions.utils._analyze_subscription_status",
|
||||
return_value=(0, []),
|
||||
),
|
||||
pytest.raises(ToolError, match="Failed to execute health/diagnose"),
|
||||
):
|
||||
await tool_fn(action="diagnose")
|
||||
|
||||
async def test_diagnose_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Diagnose returns subscription status when modules are available."""
|
||||
tool_fn = _make_tool()
|
||||
mock_status = {
|
||||
"cpu_sub": {"runtime": {"connection_state": "connected", "last_error": None}},
|
||||
}
|
||||
with patch("unraid_mcp.tools.health._diagnose_subscriptions", return_value=mock_status):
|
||||
result = await tool_fn(action="diagnose")
|
||||
assert "cpu_sub" in result
|
||||
|
||||
async def test_diagnose_import_error_internal(self) -> None:
|
||||
"""_diagnose_subscriptions raises ToolError when subscription modules are unavailable."""
|
||||
import sys
|
||||
|
||||
from unraid_mcp.tools.health import _diagnose_subscriptions
|
||||
|
||||
# Remove cached subscription modules so the import is re-triggered
|
||||
cached = {k: v for k, v in sys.modules.items() if "unraid_mcp.subscriptions" in k}
|
||||
for k in cached:
|
||||
del sys.modules[k]
|
||||
|
||||
try:
|
||||
# Replace the modules with objects that raise ImportError on access
|
||||
with (
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"unraid_mcp.subscriptions": None,
|
||||
"unraid_mcp.subscriptions.manager": None,
|
||||
"unraid_mcp.subscriptions.resources": None,
|
||||
},
|
||||
),
|
||||
pytest.raises(ToolError, match="Subscription modules not available"),
|
||||
):
|
||||
await _diagnose_subscriptions()
|
||||
finally:
|
||||
# Restore cached modules
|
||||
sys.modules.update(cached)
|
||||
await tool_fn(action="health", subaction="diagnose")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -166,17 +181,20 @@ class TestSafeDisplayUrl:
|
||||
|
||||
def test_strips_path(self) -> None:
|
||||
result = safe_display_url("http://unraid.local/some/deep/path?query=1")
|
||||
assert result is not None
|
||||
assert "path" not in result
|
||||
assert "query" not in result
|
||||
|
||||
def test_strips_credentials(self) -> None:
|
||||
result = safe_display_url("https://user:password@unraid.local/graphql")
|
||||
assert result is not None
|
||||
assert "user" not in result
|
||||
assert "password" not in result
|
||||
assert result == "https://unraid.local"
|
||||
|
||||
def test_strips_query_params(self) -> None:
|
||||
result = safe_display_url("http://host.local?token=abc&key=xyz")
|
||||
assert result is not None
|
||||
assert "token" not in result
|
||||
assert "abc" not in result
|
||||
|
||||
@@ -190,23 +208,25 @@ class TestSafeDisplayUrl:
|
||||
|
||||
def test_malformed_ipv6_url_returns_unparseable(self) -> None:
|
||||
"""Malformed IPv6 brackets in netloc cause urlparse.hostname to raise ValueError."""
|
||||
# urlparse("https://[invalid") parses without error, but accessing .hostname
|
||||
# raises ValueError: Invalid IPv6 URL — this triggers the except branch.
|
||||
result = safe_display_url("https://[invalid")
|
||||
assert result == "<unparseable>"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_setup_action_calls_elicitation() -> None:
|
||||
"""setup action triggers elicit_and_configure and returns success message."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
"""setup subaction triggers elicit_and_configure when no credentials exist."""
|
||||
tool_fn = _make_tool()
|
||||
|
||||
with patch(
|
||||
"unraid_mcp.tools.health.elicit_and_configure", new=AsyncMock(return_value=True)
|
||||
) as mock_elicit:
|
||||
result = await tool_fn(action="setup", ctx=MagicMock())
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = False
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||
patch(
|
||||
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
|
||||
) as mock_elicit,
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
|
||||
|
||||
assert mock_elicit.called
|
||||
assert "configured" in result.lower() or "success" in result.lower()
|
||||
@@ -214,13 +234,17 @@ async def test_health_setup_action_calls_elicitation() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_setup_action_returns_declined_message() -> None:
|
||||
"""setup action with declined elicitation returns appropriate message."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
"""setup subaction with declined elicitation returns appropriate message."""
|
||||
tool_fn = _make_tool()
|
||||
|
||||
with patch("unraid_mcp.tools.health.elicit_and_configure", new=AsyncMock(return_value=False)):
|
||||
result = await tool_fn(action="setup", ctx=MagicMock())
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = False
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||
patch("unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=False)),
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
|
||||
|
||||
assert (
|
||||
"not configured" in result.lower()
|
||||
@@ -229,18 +253,126 @@ async def test_health_setup_action_returns_declined_message() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_setup_already_configured_and_working_no_reset() -> None:
|
||||
"""setup returns early when credentials exist, connection works, and user declines reset."""
|
||||
tool_fn = _make_tool()
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||
patch(
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new=AsyncMock(return_value={"online": True}),
|
||||
),
|
||||
patch(
|
||||
"unraid_mcp.core.setup.elicit_reset_confirmation",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure,
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
|
||||
|
||||
mock_configure.assert_not_called()
|
||||
assert "already configured" in result.lower()
|
||||
assert "no changes" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_setup_already_configured_user_confirms_reset() -> None:
|
||||
"""setup proceeds with elicitation when credentials exist but user confirms reset."""
|
||||
tool_fn = _make_tool()
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||
patch(
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new=AsyncMock(return_value={"online": True}),
|
||||
),
|
||||
patch(
|
||||
"unraid_mcp.core.setup.elicit_reset_confirmation",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
|
||||
) as mock_configure,
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
|
||||
|
||||
mock_configure.assert_called_once()
|
||||
assert "configured" in result.lower() or "success" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_setup_credentials_exist_but_connection_fails() -> None:
|
||||
"""setup proceeds with elicitation when credentials exist but connection fails."""
|
||||
tool_fn = _make_tool()
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||
patch(
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new=AsyncMock(side_effect=Exception("connection refused")),
|
||||
),
|
||||
patch(
|
||||
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
|
||||
) as mock_configure,
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
|
||||
|
||||
mock_configure.assert_called_once()
|
||||
assert "configured" in result.lower() or "success" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_setup_ctx_none_already_configured_returns_no_changes() -> None:
|
||||
"""When ctx=None and credentials are working, setup returns 'already configured' gracefully."""
|
||||
tool_fn = _make_tool()
|
||||
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
with (
|
||||
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||
patch(
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new=AsyncMock(return_value={"online": True}),
|
||||
),
|
||||
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure,
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="setup", ctx=None)
|
||||
|
||||
mock_configure.assert_not_called()
|
||||
assert "already configured" in result.lower()
|
||||
assert "no changes" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_setup_declined_message_includes_manual_path() -> None:
|
||||
"""Declined setup message includes the exact credentials file path and variable names."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from unraid_mcp.config.settings import CREDENTIALS_ENV_PATH
|
||||
|
||||
tool_fn = _make_tool()
|
||||
|
||||
with patch("unraid_mcp.tools.health.elicit_and_configure", new=AsyncMock(return_value=False)):
|
||||
result = await tool_fn(action="setup", ctx=MagicMock())
|
||||
real_path_str = str(CREDENTIALS_ENV_PATH)
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = False
|
||||
type(mock_path).__str__ = lambda self: real_path_str # type: ignore[method-assign]
|
||||
|
||||
assert str(CREDENTIALS_ENV_PATH) in result
|
||||
assert "UNRAID_API_URL=" in result # inline variable shown
|
||||
with (
|
||||
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
|
||||
patch("unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=False)),
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
|
||||
|
||||
assert real_path_str in result
|
||||
assert "UNRAID_API_URL=" in result
|
||||
assert "UNRAID_API_KEY=" in result
|
||||
|
||||
@@ -1,65 +1,18 @@
|
||||
"""Tests for unraid_info tool."""
|
||||
"""Tests for system subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import get_args
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import make_tool_fn
|
||||
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
from unraid_mcp.tools.info import (
|
||||
INFO_ACTIONS,
|
||||
_analyze_disk_health,
|
||||
_process_array_status,
|
||||
_process_system_info,
|
||||
)
|
||||
from unraid_mcp.tools.unraid import _analyze_disk_health
|
||||
|
||||
|
||||
# --- Unit tests for helper functions ---
|
||||
|
||||
|
||||
class TestProcessSystemInfo:
|
||||
def test_processes_os_info(self) -> None:
|
||||
raw = {
|
||||
"os": {
|
||||
"distro": "Unraid",
|
||||
"release": "7.2",
|
||||
"platform": "linux",
|
||||
"arch": "x86_64",
|
||||
"hostname": "tower",
|
||||
"uptime": 3600,
|
||||
},
|
||||
"cpu": {"manufacturer": "AMD", "brand": "Ryzen", "cores": 8, "threads": 16},
|
||||
}
|
||||
result = _process_system_info(raw)
|
||||
assert "summary" in result
|
||||
assert "details" in result
|
||||
assert result["summary"]["hostname"] == "tower"
|
||||
assert "AMD" in result["summary"]["cpu"]
|
||||
|
||||
def test_handles_missing_fields(self) -> None:
|
||||
result = _process_system_info({})
|
||||
assert result["summary"] == {"memory_summary": "Memory information not available."}
|
||||
|
||||
def test_processes_memory_layout(self) -> None:
|
||||
raw = {
|
||||
"memory": {
|
||||
"layout": [
|
||||
{
|
||||
"bank": "0",
|
||||
"type": "DDR4",
|
||||
"clockSpeed": 3200,
|
||||
"manufacturer": "G.Skill",
|
||||
"partNum": "XYZ",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
result = _process_system_info(raw)
|
||||
assert len(result["summary"]["memory_layout_details"]) == 1
|
||||
|
||||
|
||||
class TestAnalyzeDiskHealth:
|
||||
def test_counts_healthy_disks(self) -> None:
|
||||
disks = [{"status": "DISK_OK"}, {"status": "DISK_OK"}]
|
||||
@@ -100,51 +53,17 @@ class TestAnalyzeDiskHealth:
|
||||
assert result["healthy"] == 0
|
||||
|
||||
|
||||
class TestProcessArrayStatus:
|
||||
def test_basic_array(self) -> None:
|
||||
raw = {
|
||||
"state": "STARTED",
|
||||
"capacity": {"kilobytes": {"free": "1048576", "used": "524288", "total": "1572864"}},
|
||||
"parities": [{"status": "DISK_OK"}],
|
||||
"disks": [{"status": "DISK_OK"}],
|
||||
"caches": [],
|
||||
}
|
||||
result = _process_array_status(raw)
|
||||
assert result["summary"]["state"] == "STARTED"
|
||||
assert result["summary"]["overall_health"] == "HEALTHY"
|
||||
|
||||
def test_critical_disk_threshold_array(self) -> None:
|
||||
raw = {
|
||||
"state": "STARTED",
|
||||
"parities": [],
|
||||
"disks": [{"status": "DISK_OK", "critical": 55}],
|
||||
"caches": [],
|
||||
}
|
||||
result = _process_array_status(raw)
|
||||
assert result["summary"]["overall_health"] == "CRITICAL"
|
||||
|
||||
def test_degraded_array(self) -> None:
|
||||
raw = {
|
||||
"state": "STARTED",
|
||||
"parities": [],
|
||||
"disks": [{"status": "DISK_NP"}],
|
||||
"caches": [],
|
||||
}
|
||||
result = _process_array_status(raw)
|
||||
assert result["summary"]["overall_health"] == "DEGRADED"
|
||||
|
||||
|
||||
# --- Integration tests for the tool function ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.info.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestUnraidInfoTool:
|
||||
@@ -162,14 +81,14 @@ class TestUnraidInfoTool:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="overview")
|
||||
result = await tool_fn(action="system", subaction="overview")
|
||||
assert "summary" in result
|
||||
_mock_graphql.assert_called_once()
|
||||
|
||||
async def test_ups_device_requires_device_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="device_id is required"):
|
||||
await tool_fn(action="ups_device")
|
||||
await tool_fn(action="system", subaction="ups_device")
|
||||
|
||||
async def test_network_action(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
@@ -193,7 +112,7 @@ class TestUnraidInfoTool:
|
||||
},
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="network")
|
||||
result = await tool_fn(action="system", subaction="network")
|
||||
assert "accessUrls" in result
|
||||
assert result["httpPort"] == 6969
|
||||
assert result["httpsPort"] == 31337
|
||||
@@ -202,26 +121,26 @@ class TestUnraidInfoTool:
|
||||
async def test_connect_action_raises_tool_error(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="connect.*not available"):
|
||||
await tool_fn(action="connect")
|
||||
await tool_fn(action="system", subaction="connect")
|
||||
|
||||
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.side_effect = RuntimeError("unexpected")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to execute info/online"):
|
||||
await tool_fn(action="online")
|
||||
with pytest.raises(ToolError, match="Failed to execute system/online"):
|
||||
await tool_fn(action="system", subaction="online")
|
||||
|
||||
async def test_metrics(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"metrics": {"cpu": {"used": 25.5}, "memory": {"used": 8192, "total": 32768}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="metrics")
|
||||
result = await tool_fn(action="system", subaction="metrics")
|
||||
assert result["cpu"]["used"] == 25.5
|
||||
|
||||
async def test_services(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"services": [{"name": "docker", "state": "running"}]}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="services")
|
||||
result = await tool_fn(action="system", subaction="services")
|
||||
assert "services" in result
|
||||
assert len(result["services"]) == 1
|
||||
assert result["services"][0]["name"] == "docker"
|
||||
@@ -231,14 +150,14 @@ class TestUnraidInfoTool:
|
||||
"settings": {"unified": {"values": {"timezone": "US/Eastern"}}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="settings")
|
||||
result = await tool_fn(action="system", subaction="settings")
|
||||
assert result["timezone"] == "US/Eastern"
|
||||
|
||||
async def test_settings_non_dict_values(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Settings values that are not a dict should be wrapped in {'raw': ...}."""
|
||||
_mock_graphql.return_value = {"settings": {"unified": {"values": "raw_string"}}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="settings")
|
||||
result = await tool_fn(action="system", subaction="settings")
|
||||
assert result == {"raw": "raw_string"}
|
||||
|
||||
async def test_servers(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -246,7 +165,7 @@ class TestUnraidInfoTool:
|
||||
"servers": [{"id": "s:1", "name": "tower", "status": "online"}]
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="servers")
|
||||
result = await tool_fn(action="system", subaction="servers")
|
||||
assert "servers" in result
|
||||
assert len(result["servers"]) == 1
|
||||
assert result["servers"][0]["name"] == "tower"
|
||||
@@ -262,7 +181,7 @@ class TestUnraidInfoTool:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="flash")
|
||||
result = await tool_fn(action="system", subaction="flash")
|
||||
assert result["product"] == "SanDisk"
|
||||
|
||||
async def test_ups_devices(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -270,7 +189,7 @@ class TestUnraidInfoTool:
|
||||
"upsDevices": [{"id": "ups:1", "model": "APC", "status": "online", "charge": 100}]
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="ups_devices")
|
||||
result = await tool_fn(action="system", subaction="ups_devices")
|
||||
assert "ups_devices" in result
|
||||
assert len(result["ups_devices"]) == 1
|
||||
assert result["ups_devices"][0]["model"] == "APC"
|
||||
@@ -284,7 +203,7 @@ class TestInfoNetworkErrors:
|
||||
_mock_graphql.side_effect = ToolError("HTTP error 401: Unauthorized")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="401"):
|
||||
await tool_fn(action="overview")
|
||||
await tool_fn(action="system", subaction="overview")
|
||||
|
||||
async def test_overview_connection_refused(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Connection refused should propagate as ToolError."""
|
||||
@@ -293,7 +212,7 @@ class TestInfoNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Connection refused"):
|
||||
await tool_fn(action="overview")
|
||||
await tool_fn(action="system", subaction="overview")
|
||||
|
||||
async def test_network_json_decode_error(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Invalid JSON from API should propagate as ToolError."""
|
||||
@@ -302,16 +221,17 @@ class TestInfoNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid JSON"):
|
||||
await tool_fn(action="network")
|
||||
await tool_fn(action="system", subaction="network")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: removed actions must not appear in INFO_ACTIONS
|
||||
# Regression: removed actions must not be valid subactions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["update_server", "update_ssh"])
|
||||
def test_removed_info_actions_are_gone(action: str) -> None:
|
||||
assert action not in get_args(INFO_ACTIONS), (
|
||||
f"{action} references a non-existent mutation and must not be in INFO_ACTIONS"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("subaction", ["update_server", "update_ssh"])
|
||||
async def test_removed_info_subactions_are_invalid(subaction: str) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="system", subaction=subaction)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for unraid_keys tool."""
|
||||
"""Tests for key subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -11,39 +11,39 @@ from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestKeysValidation:
|
||||
async def test_delete_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="confirm=True"):
|
||||
await tool_fn(action="delete", key_id="k:1")
|
||||
await tool_fn(action="key", subaction="delete", key_id="k:1")
|
||||
|
||||
async def test_get_requires_key_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="key_id"):
|
||||
await tool_fn(action="get")
|
||||
await tool_fn(action="key", subaction="get")
|
||||
|
||||
async def test_create_requires_name(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="name"):
|
||||
await tool_fn(action="create")
|
||||
await tool_fn(action="key", subaction="create")
|
||||
|
||||
async def test_update_requires_key_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="key_id"):
|
||||
await tool_fn(action="update")
|
||||
await tool_fn(action="key", subaction="update")
|
||||
|
||||
async def test_delete_requires_key_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="key_id"):
|
||||
await tool_fn(action="delete", confirm=True)
|
||||
await tool_fn(action="key", subaction="delete", confirm=True)
|
||||
|
||||
|
||||
class TestKeysActions:
|
||||
@@ -52,7 +52,7 @@ class TestKeysActions:
|
||||
"apiKeys": [{"id": "k:1", "name": "mcp-key", "roles": ["admin"]}]
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list")
|
||||
result = await tool_fn(action="key", subaction="list")
|
||||
assert len(result["keys"]) == 1
|
||||
|
||||
async def test_get(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -60,7 +60,7 @@ class TestKeysActions:
|
||||
"apiKey": {"id": "k:1", "name": "mcp-key", "roles": ["admin"]}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="get", key_id="k:1")
|
||||
result = await tool_fn(action="key", subaction="get", key_id="k:1")
|
||||
assert result["name"] == "mcp-key"
|
||||
|
||||
async def test_create(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -70,7 +70,7 @@ class TestKeysActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="create", name="new-key")
|
||||
result = await tool_fn(action="key", subaction="create", name="new-key")
|
||||
assert result["success"] is True
|
||||
assert result["key"]["name"] == "new-key"
|
||||
|
||||
@@ -86,7 +86,7 @@ class TestKeysActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="create", name="admin-key", roles=["admin"])
|
||||
result = await tool_fn(action="key", subaction="create", name="admin-key", roles=["admin"])
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_update(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -94,39 +94,43 @@ class TestKeysActions:
|
||||
"apiKey": {"update": {"id": "k:1", "name": "renamed", "roles": []}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="update", key_id="k:1", name="renamed")
|
||||
result = await tool_fn(action="key", subaction="update", key_id="k:1", name="renamed")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_delete(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"apiKey": {"delete": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="delete", key_id="k:1", confirm=True)
|
||||
result = await tool_fn(action="key", subaction="delete", key_id="k:1", confirm=True)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.side_effect = RuntimeError("connection lost")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to execute keys/list"):
|
||||
await tool_fn(action="list")
|
||||
with pytest.raises(ToolError, match="Failed to execute key/list"):
|
||||
await tool_fn(action="key", subaction="list")
|
||||
|
||||
async def test_add_role_requires_key_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="key_id"):
|
||||
await tool_fn(action="add_role", roles=["VIEWER"])
|
||||
await tool_fn(action="key", subaction="add_role", roles=["VIEWER"])
|
||||
|
||||
async def test_add_role_requires_role(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="role"):
|
||||
await tool_fn(action="add_role", key_id="abc:local")
|
||||
await tool_fn(action="key", subaction="add_role", key_id="abc:local")
|
||||
|
||||
async def test_add_role_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"apiKey": {"addRole": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="add_role", key_id="abc:local", roles=["VIEWER"])
|
||||
result = await tool_fn(
|
||||
action="key", subaction="add_role", key_id="abc:local", roles=["VIEWER"]
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_remove_role_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"apiKey": {"removeRole": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="remove_role", key_id="abc:local", roles=["VIEWER"])
|
||||
result = await tool_fn(
|
||||
action="key", subaction="remove_role", key_id="abc:local", roles=["VIEWER"]
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -1,125 +1,108 @@
|
||||
# tests/test_live.py
|
||||
"""Tests for unraid_live subscription snapshot tool."""
|
||||
"""Tests for live subactions of the consolidated unraid tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import FastMCP
|
||||
from conftest import make_tool_fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp():
|
||||
return FastMCP("test")
|
||||
|
||||
|
||||
def _make_live_tool(mcp):
|
||||
from unraid_mcp.tools.live import register_live_tool
|
||||
|
||||
register_live_tool(mcp)
|
||||
local_provider = mcp.providers[0]
|
||||
tool = local_provider._components["tool:unraid_live@"]
|
||||
return tool.fn
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_subscribe_once():
|
||||
with patch("unraid_mcp.tools.live.subscribe_once") as m:
|
||||
with patch("unraid_mcp.subscriptions.snapshot.subscribe_once") as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_subscribe_collect():
|
||||
with patch("unraid_mcp.tools.live.subscribe_collect") as m:
|
||||
with patch("unraid_mcp.subscriptions.snapshot.subscribe_collect") as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cpu_returns_snapshot(mcp, _mock_subscribe_once):
|
||||
async def test_cpu_returns_snapshot(_mock_subscribe_once):
|
||||
_mock_subscribe_once.return_value = {"systemMetricsCpu": {"percentTotal": 23.5, "cpus": []}}
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
result = await tool_fn(action="cpu")
|
||||
result = await _make_tool()(action="live", subaction="cpu")
|
||||
assert result["success"] is True
|
||||
assert result["data"]["systemMetricsCpu"]["percentTotal"] == 23.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_returns_snapshot(mcp, _mock_subscribe_once):
|
||||
async def test_memory_returns_snapshot(_mock_subscribe_once):
|
||||
_mock_subscribe_once.return_value = {
|
||||
"systemMetricsMemory": {"total": 32000000000, "used": 10000000000, "percentTotal": 31.2}
|
||||
}
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
result = await tool_fn(action="memory")
|
||||
result = await _make_tool()(action="live", subaction="memory")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tail_requires_path(mcp, _mock_subscribe_collect):
|
||||
async def test_log_tail_requires_path(_mock_subscribe_collect):
|
||||
_mock_subscribe_collect.return_value = []
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
with pytest.raises(ToolError, match="path"):
|
||||
await tool_fn(action="log_tail")
|
||||
await _make_tool()(action="live", subaction="log_tail")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tail_with_path(mcp, _mock_subscribe_collect):
|
||||
async def test_log_tail_with_path(_mock_subscribe_collect):
|
||||
_mock_subscribe_collect.return_value = [
|
||||
{"logFile": {"path": "/var/log/syslog", "content": "line1\nline2", "totalLines": 2}}
|
||||
]
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
result = await tool_fn(action="log_tail", path="/var/log/syslog", collect_for=1.0)
|
||||
result = await _make_tool()(
|
||||
action="live", subaction="log_tail", path="/var/log/syslog", collect_for=1.0
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["event_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_feed_collects_events(mcp, _mock_subscribe_collect):
|
||||
async def test_notification_feed_collects_events(_mock_subscribe_collect):
|
||||
_mock_subscribe_collect.return_value = [
|
||||
{"notificationAdded": {"id": "1", "title": "Alert"}},
|
||||
{"notificationAdded": {"id": "2", "title": "Info"}},
|
||||
]
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
result = await tool_fn(action="notification_feed", collect_for=2.0)
|
||||
result = await _make_tool()(action="live", subaction="notification_feed", collect_for=2.0)
|
||||
assert result["event_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_action_raises(mcp):
|
||||
async def test_invalid_subaction_raises():
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="nonexistent") # type: ignore[arg-type]
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await _make_tool()(action="live", subaction="nonexistent")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_propagates_tool_error(mcp, _mock_subscribe_once):
|
||||
async def test_snapshot_propagates_tool_error(_mock_subscribe_once):
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
_mock_subscribe_once.side_effect = ToolError("Subscription timed out after 10s")
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
with pytest.raises(ToolError, match="timed out"):
|
||||
await tool_fn(action="cpu")
|
||||
await _make_tool()(action="live", subaction="cpu")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tail_rejects_invalid_path(mcp, _mock_subscribe_collect):
|
||||
async def test_log_tail_rejects_invalid_path(_mock_subscribe_collect):
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
with pytest.raises(ToolError, match="must start with"):
|
||||
await tool_fn(action="log_tail", path="/etc/shadow")
|
||||
await _make_tool()(action="live", subaction="log_tail", path="/etc/shadow")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_wraps_bare_exception(mcp, _mock_subscribe_once):
|
||||
async def test_snapshot_wraps_bare_exception(_mock_subscribe_once):
|
||||
"""Bare exceptions from subscribe_once are wrapped in ToolError by tool_error_handler."""
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
_mock_subscribe_once.side_effect = RuntimeError("WebSocket connection refused")
|
||||
tool_fn = _make_live_tool(mcp)
|
||||
with pytest.raises(ToolError):
|
||||
await tool_fn(action="cpu")
|
||||
await _make_tool()(action="live", subaction="cpu")
|
||||
|
||||
@@ -1,67 +1,54 @@
|
||||
"""Tests for unraid_notifications tool."""
|
||||
"""Tests for notification subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import get_args
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import make_tool_fn
|
||||
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
from unraid_mcp.tools.notifications import NOTIFICATION_ACTIONS
|
||||
|
||||
|
||||
def test_warnings_action_removed() -> None:
|
||||
assert "warnings" not in get_args(NOTIFICATION_ACTIONS), (
|
||||
"warnings action references warningsAndAlerts which is not in live API"
|
||||
)
|
||||
|
||||
|
||||
def test_create_unique_action_removed() -> None:
|
||||
assert "create_unique" not in get_args(NOTIFICATION_ACTIONS), (
|
||||
"create_unique references notifyIfUnique which is not in live API"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch(
|
||||
"unraid_mcp.tools.notifications.make_graphql_request", new_callable=AsyncMock
|
||||
) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestNotificationsValidation:
|
||||
async def test_delete_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool_fn(action="delete", notification_id="n:1", notification_type="UNREAD")
|
||||
await tool_fn(
|
||||
action="notification",
|
||||
subaction="delete",
|
||||
notification_id="n:1",
|
||||
notification_type="UNREAD",
|
||||
)
|
||||
|
||||
async def test_delete_archived_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool_fn(action="delete_archived")
|
||||
await tool_fn(action="notification", subaction="delete_archived")
|
||||
|
||||
async def test_create_requires_fields(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="requires title"):
|
||||
await tool_fn(action="create")
|
||||
await tool_fn(action="notification", subaction="create")
|
||||
|
||||
async def test_archive_requires_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="notification_id"):
|
||||
await tool_fn(action="archive")
|
||||
await tool_fn(action="notification", subaction="archive")
|
||||
|
||||
async def test_delete_requires_id_and_type(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="requires notification_id"):
|
||||
await tool_fn(action="delete", confirm=True)
|
||||
await tool_fn(action="notification", subaction="delete", confirm=True)
|
||||
|
||||
|
||||
class TestNotificationsActions:
|
||||
@@ -75,7 +62,7 @@ class TestNotificationsActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="overview")
|
||||
result = await tool_fn(action="notification", subaction="overview")
|
||||
assert result["unread"]["total"] == 7
|
||||
|
||||
async def test_list(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -83,7 +70,7 @@ class TestNotificationsActions:
|
||||
"notifications": {"list": [{"id": "n:1", "title": "Test", "importance": "INFO"}]}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list")
|
||||
result = await tool_fn(action="notification", subaction="list")
|
||||
assert len(result["notifications"]) == 1
|
||||
|
||||
async def test_create(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -92,7 +79,8 @@ class TestNotificationsActions:
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="Test",
|
||||
subject="Test Subject",
|
||||
description="Test Desc",
|
||||
@@ -103,7 +91,7 @@ class TestNotificationsActions:
|
||||
async def test_archive_notification(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"archiveNotification": {"id": "n:1"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="archive", notification_id="n:1")
|
||||
result = await tool_fn(action="notification", subaction="archive", notification_id="n:1")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_delete_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -115,7 +103,8 @@ class TestNotificationsActions:
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="delete",
|
||||
action="notification",
|
||||
subaction="delete",
|
||||
notification_id="n:1",
|
||||
notification_type="unread",
|
||||
confirm=True,
|
||||
@@ -130,22 +119,24 @@ class TestNotificationsActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="archive_all")
|
||||
result = await tool_fn(action="notification", subaction="archive_all")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_unread_notification(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"unreadNotification": {"id": "n:1"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="unread", notification_id="n:1")
|
||||
result = await tool_fn(action="notification", subaction="unread", notification_id="n:1")
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "unread"
|
||||
assert result["subaction"] == "unread"
|
||||
|
||||
async def test_list_with_importance_filter(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"notifications": {"list": [{"id": "n:1", "title": "Alert", "importance": "WARNING"}]}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list", importance="warning", limit=10, offset=5)
|
||||
result = await tool_fn(
|
||||
action="notification", subaction="list", importance="warning", limit=10, offset=5
|
||||
)
|
||||
assert len(result["notifications"]) == 1
|
||||
call_args = _mock_graphql.call_args
|
||||
filter_var = call_args[0][1]["filter"]
|
||||
@@ -161,15 +152,15 @@ class TestNotificationsActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="delete_archived", confirm=True)
|
||||
result = await tool_fn(action="notification", subaction="delete_archived", confirm=True)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "delete_archived"
|
||||
assert result["subaction"] == "delete_archived"
|
||||
|
||||
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.side_effect = RuntimeError("boom")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to execute notifications/overview"):
|
||||
await tool_fn(action="overview")
|
||||
with pytest.raises(ToolError, match="Failed to execute notification/overview"):
|
||||
await tool_fn(action="notification", subaction="overview")
|
||||
|
||||
|
||||
class TestNotificationsCreateValidation:
|
||||
@@ -179,7 +170,8 @@ class TestNotificationsCreateValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid importance"):
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="T",
|
||||
subject="S",
|
||||
description="D",
|
||||
@@ -191,7 +183,8 @@ class TestNotificationsCreateValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid importance"):
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="T",
|
||||
subject="S",
|
||||
description="D",
|
||||
@@ -202,7 +195,12 @@ class TestNotificationsCreateValidation:
|
||||
_mock_graphql.return_value = {"createNotification": {"id": "n:1", "importance": "ALERT"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create", title="T", subject="S", description="D", importance="alert"
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="T",
|
||||
subject="S",
|
||||
description="D",
|
||||
importance="alert",
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -210,7 +208,8 @@ class TestNotificationsCreateValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="title must be at most 200"):
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="x" * 201,
|
||||
subject="S",
|
||||
description="D",
|
||||
@@ -221,7 +220,8 @@ class TestNotificationsCreateValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="subject must be at most 500"):
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="T",
|
||||
subject="x" * 501,
|
||||
description="D",
|
||||
@@ -232,7 +232,8 @@ class TestNotificationsCreateValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="description must be at most 2000"):
|
||||
await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="T",
|
||||
subject="S",
|
||||
description="x" * 2001,
|
||||
@@ -243,7 +244,8 @@ class TestNotificationsCreateValidation:
|
||||
_mock_graphql.return_value = {"createNotification": {"id": "n:1", "importance": "INFO"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create",
|
||||
action="notification",
|
||||
subaction="create",
|
||||
title="x" * 200,
|
||||
subject="S",
|
||||
description="D",
|
||||
@@ -261,7 +263,9 @@ class TestNewNotificationMutations:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="archive_many", notification_ids=["n:1", "n:2"])
|
||||
result = await tool_fn(
|
||||
action="notification", subaction="archive_many", notification_ids=["n:1", "n:2"]
|
||||
)
|
||||
assert result["success"] is True
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"ids": ["n:1", "n:2"]}
|
||||
@@ -269,7 +273,7 @@ class TestNewNotificationMutations:
|
||||
async def test_archive_many_requires_ids(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="notification_ids"):
|
||||
await tool_fn(action="archive_many")
|
||||
await tool_fn(action="notification", subaction="archive_many")
|
||||
|
||||
async def test_unarchive_many_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
@@ -279,13 +283,15 @@ class TestNewNotificationMutations:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="unarchive_many", notification_ids=["n:1", "n:2"])
|
||||
result = await tool_fn(
|
||||
action="notification", subaction="unarchive_many", notification_ids=["n:1", "n:2"]
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_unarchive_many_requires_ids(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="notification_ids"):
|
||||
await tool_fn(action="unarchive_many")
|
||||
await tool_fn(action="notification", subaction="unarchive_many")
|
||||
|
||||
async def test_unarchive_all_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
@@ -295,7 +301,7 @@ class TestNewNotificationMutations:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="unarchive_all")
|
||||
result = await tool_fn(action="notification", subaction="unarchive_all")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_unarchive_all_with_importance(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -304,7 +310,7 @@ class TestNewNotificationMutations:
|
||||
"unarchiveAll": {"unread": {"total": 1}, "archive": {"total": 0}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
await tool_fn(action="unarchive_all", importance="warning")
|
||||
await tool_fn(action="notification", subaction="unarchive_all", importance="warning")
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"importance": "WARNING"}
|
||||
|
||||
@@ -316,5 +322,5 @@ class TestNewNotificationMutations:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="recalculate")
|
||||
result = await tool_fn(action="notification", subaction="recalculate")
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# tests/test_oidc.py
|
||||
"""Tests for unraid_oidc tool."""
|
||||
"""Tests for oidc subactions of the consolidated unraid tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,16 +11,12 @@ from conftest import make_tool_fn
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql():
|
||||
with patch("unraid_mcp.tools.oidc.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as m:
|
||||
yield m
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.oidc",
|
||||
"register_oidc_tool",
|
||||
"unraid_oidc",
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -30,15 +26,16 @@ async def test_providers_returns_list(_mock_graphql):
|
||||
{"id": "1:local", "name": "Google", "clientId": "abc", "scopes": ["openid"]}
|
||||
]
|
||||
}
|
||||
result = await _make_tool()(action="providers")
|
||||
assert result["success"] is True
|
||||
result = await _make_tool()(action="oidc", subaction="providers")
|
||||
assert "providers" in result
|
||||
assert len(result["providers"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_providers(_mock_graphql):
|
||||
_mock_graphql.return_value = {"publicOidcProviders": []}
|
||||
result = await _make_tool()(action="public_providers")
|
||||
assert result["success"] is True
|
||||
result = await _make_tool()(action="oidc", subaction="public_providers")
|
||||
assert "providers" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -46,7 +43,7 @@ async def test_provider_requires_provider_id(_mock_graphql):
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
with pytest.raises(ToolError, match="provider_id"):
|
||||
await _make_tool()(action="provider")
|
||||
await _make_tool()(action="oidc", subaction="provider")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -54,7 +51,7 @@ async def test_validate_session_requires_token(_mock_graphql):
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
with pytest.raises(ToolError, match="token"):
|
||||
await _make_tool()(action="validate_session")
|
||||
await _make_tool()(action="oidc", subaction="validate_session")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -62,5 +59,5 @@ async def test_configuration(_mock_graphql):
|
||||
_mock_graphql.return_value = {
|
||||
"oidcConfiguration": {"providers": [], "defaultAllowedOrigins": []}
|
||||
}
|
||||
result = await _make_tool()(action="configuration")
|
||||
assert result["success"] is True
|
||||
result = await _make_tool()(action="oidc", subaction="configuration")
|
||||
assert "providers" in result
|
||||
|
||||
@@ -1,72 +1,63 @@
|
||||
# tests/test_plugins.py
|
||||
"""Tests for unraid_plugins tool."""
|
||||
"""Tests for plugin subactions of the consolidated unraid tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp():
|
||||
return FastMCP("test")
|
||||
from conftest import make_tool_fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql():
|
||||
with patch("unraid_mcp.tools.plugins.make_graphql_request") as m:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request") as m:
|
||||
yield m
|
||||
|
||||
|
||||
def _make_tool(mcp):
|
||||
from unraid_mcp.tools.plugins import register_plugins_tool
|
||||
|
||||
register_plugins_tool(mcp)
|
||||
# FastMCP 3.x: access tool fn via internal provider components (same as conftest.make_tool_fn)
|
||||
local_provider = mcp.providers[0]
|
||||
tool = local_provider._components["tool:unraid_plugins@"]
|
||||
return tool.fn
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_plugins(mcp, _mock_graphql):
|
||||
async def test_list_returns_plugins(_mock_graphql):
|
||||
_mock_graphql.return_value = {
|
||||
"plugins": [
|
||||
{"name": "my-plugin", "version": "1.0.0", "hasApiModule": True, "hasCliModule": False}
|
||||
]
|
||||
}
|
||||
result = await _make_tool(mcp)(action="list")
|
||||
result = await _make_tool()(action="plugin", subaction="list")
|
||||
assert result["success"] is True
|
||||
assert len(result["data"]["plugins"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_requires_names(mcp, _mock_graphql):
|
||||
async def test_add_requires_names(_mock_graphql):
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
with pytest.raises(ToolError, match="names"):
|
||||
await _make_tool(mcp)(action="add")
|
||||
await _make_tool()(action="plugin", subaction="add")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_success(mcp, _mock_graphql):
|
||||
async def test_add_success(_mock_graphql):
|
||||
_mock_graphql.return_value = {"addPlugin": False} # False = auto-restart triggered
|
||||
result = await _make_tool(mcp)(action="add", names=["my-plugin"])
|
||||
result = await _make_tool()(action="plugin", subaction="add", names=["my-plugin"])
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_requires_confirm(mcp, _mock_graphql):
|
||||
async def test_remove_requires_confirm(_mock_graphql):
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await _make_tool(mcp)(action="remove", names=["my-plugin"], confirm=False)
|
||||
await _make_tool()(action="plugin", subaction="remove", names=["my-plugin"], confirm=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_with_confirm(mcp, _mock_graphql):
|
||||
async def test_remove_with_confirm(_mock_graphql):
|
||||
_mock_graphql.return_value = {"removePlugin": True}
|
||||
result = await _make_tool(mcp)(action="remove", names=["my-plugin"], confirm=True)
|
||||
result = await _make_tool()(
|
||||
action="plugin", subaction="remove", names=["my-plugin"], confirm=True
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for unraid_rclone tool."""
|
||||
"""Tests for rclone subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -11,36 +11,36 @@ from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.rclone.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestRcloneValidation:
|
||||
async def test_delete_requires_confirm(self) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool_fn(action="delete_remote", name="gdrive")
|
||||
await tool_fn(action="rclone", subaction="delete_remote", name="gdrive")
|
||||
|
||||
async def test_create_requires_fields(self) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="requires name"):
|
||||
await tool_fn(action="create_remote")
|
||||
await tool_fn(action="rclone", subaction="create_remote")
|
||||
|
||||
async def test_delete_requires_name(self) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="name is required"):
|
||||
await tool_fn(action="delete_remote", confirm=True)
|
||||
await tool_fn(action="rclone", subaction="delete_remote", confirm=True)
|
||||
|
||||
|
||||
class TestRcloneActions:
|
||||
async def test_list_remotes(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"rclone": {"remotes": [{"name": "gdrive", "type": "drive"}]}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list_remotes")
|
||||
result = await tool_fn(action="rclone", subaction="list_remotes")
|
||||
assert len(result["remotes"]) == 1
|
||||
|
||||
async def test_config_form(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -48,7 +48,7 @@ class TestRcloneActions:
|
||||
"rclone": {"configForm": {"id": "form:1", "dataSchema": {}, "uiSchema": {}}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="config_form")
|
||||
result = await tool_fn(action="rclone", subaction="config_form")
|
||||
assert result["id"] == "form:1"
|
||||
|
||||
async def test_config_form_with_provider(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -56,7 +56,7 @@ class TestRcloneActions:
|
||||
"rclone": {"configForm": {"id": "form:s3", "dataSchema": {}, "uiSchema": {}}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="config_form", provider_type="s3")
|
||||
result = await tool_fn(action="rclone", subaction="config_form", provider_type="s3")
|
||||
assert result["id"] == "form:s3"
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"formOptions": {"providerType": "s3"}}
|
||||
@@ -67,7 +67,8 @@ class TestRcloneActions:
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="newremote",
|
||||
provider_type="s3",
|
||||
config_data={"bucket": "mybucket"},
|
||||
@@ -81,7 +82,8 @@ class TestRcloneActions:
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="ftp-remote",
|
||||
provider_type="ftp",
|
||||
config_data={},
|
||||
@@ -91,14 +93,16 @@ class TestRcloneActions:
|
||||
async def test_delete_remote(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"rclone": {"deleteRCloneRemote": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="delete_remote", name="gdrive", confirm=True)
|
||||
result = await tool_fn(
|
||||
action="rclone", subaction="delete_remote", name="gdrive", confirm=True
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_delete_remote_failure(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"rclone": {"deleteRCloneRemote": False}}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to delete"):
|
||||
await tool_fn(action="delete_remote", name="gdrive", confirm=True)
|
||||
await tool_fn(action="rclone", subaction="delete_remote", name="gdrive", confirm=True)
|
||||
|
||||
|
||||
class TestRcloneConfigDataValidation:
|
||||
@@ -108,7 +112,8 @@ class TestRcloneConfigDataValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="disallowed characters"):
|
||||
await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="r",
|
||||
provider_type="s3",
|
||||
config_data={"../evil": "value"},
|
||||
@@ -118,7 +123,8 @@ class TestRcloneConfigDataValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="disallowed characters"):
|
||||
await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="r",
|
||||
provider_type="s3",
|
||||
config_data={"key;rm": "value"},
|
||||
@@ -128,7 +134,8 @@ class TestRcloneConfigDataValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="max 50"):
|
||||
await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="r",
|
||||
provider_type="s3",
|
||||
config_data={f"key{i}": "v" for i in range(51)},
|
||||
@@ -138,7 +145,8 @@ class TestRcloneConfigDataValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="string, number, or boolean"):
|
||||
await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="r",
|
||||
provider_type="s3",
|
||||
config_data={"nested": {"key": "val"}},
|
||||
@@ -148,19 +156,19 @@ class TestRcloneConfigDataValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="exceeds max length"):
|
||||
await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="r",
|
||||
provider_type="s3",
|
||||
config_data={"key": "x" * 4097},
|
||||
)
|
||||
|
||||
async def test_boolean_value_accepted(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"rclone": {"createRCloneRemote": {"name": "r", "type": "s3"}}
|
||||
}
|
||||
_mock_graphql.return_value = {"rclone": {"createRCloneRemote": {"name": "r", "type": "s3"}}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="r",
|
||||
provider_type="s3",
|
||||
config_data={"use_path_style": True},
|
||||
@@ -173,7 +181,8 @@ class TestRcloneConfigDataValidation:
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create_remote",
|
||||
action="rclone",
|
||||
subaction="create_remote",
|
||||
name="r",
|
||||
provider_type="sftp",
|
||||
config_data={"port": 22},
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from unraid_mcp.subscriptions.queries import SNAPSHOT_ACTIONS
|
||||
from unraid_mcp.subscriptions.resources import register_subscription_resources
|
||||
|
||||
|
||||
@@ -16,15 +17,6 @@ def _make_resources():
|
||||
return test_mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_subscribe_once():
|
||||
with patch(
|
||||
"unraid_mcp.subscriptions.resources.subscribe_once",
|
||||
new_callable=AsyncMock,
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_ensure_started():
|
||||
with patch(
|
||||
@@ -34,55 +26,59 @@ def _mock_ensure_started():
|
||||
yield mock
|
||||
|
||||
|
||||
class TestLiveResources:
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
[
|
||||
"cpu",
|
||||
"memory",
|
||||
"cpu_telemetry",
|
||||
"array_state",
|
||||
"parity_progress",
|
||||
"ups_status",
|
||||
"notifications_overview",
|
||||
"owner",
|
||||
"server_status",
|
||||
],
|
||||
)
|
||||
async def test_resource_returns_json(
|
||||
self,
|
||||
action: str,
|
||||
_mock_subscribe_once: AsyncMock,
|
||||
_mock_ensure_started: AsyncMock,
|
||||
class TestLiveResourcesUseManagerCache:
|
||||
"""All live resources must read from the persistent SubscriptionManager cache."""
|
||||
|
||||
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
|
||||
async def test_resource_returns_cached_data(
|
||||
self, action: str, _mock_ensure_started: AsyncMock
|
||||
) -> None:
|
||||
_mock_subscribe_once.return_value = {"data": "ok"}
|
||||
mcp = _make_resources()
|
||||
cached = {"systemMetricsCpu": {"percentTotal": 12.5}}
|
||||
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
|
||||
mock_mgr.get_resource_data = AsyncMock(return_value=cached)
|
||||
mcp = _make_resources()
|
||||
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||
result = await resource.fn()
|
||||
assert json.loads(result) == cached
|
||||
|
||||
local_provider = mcp.providers[0]
|
||||
resource_key = f"resource:unraid://live/{action}@"
|
||||
resource = local_provider._components[resource_key]
|
||||
result = await resource.fn()
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed == {"data": "ok"}
|
||||
|
||||
async def test_resource_returns_error_dict_on_failure(
|
||||
self,
|
||||
_mock_subscribe_once: AsyncMock,
|
||||
_mock_ensure_started: AsyncMock,
|
||||
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
|
||||
async def test_resource_returns_status_when_no_cache(
|
||||
self, action: str, _mock_ensure_started: AsyncMock
|
||||
) -> None:
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
_mock_subscribe_once.side_effect = ToolError("WebSocket timeout")
|
||||
mcp = _make_resources()
|
||||
|
||||
local_provider = mcp.providers[0]
|
||||
resource = local_provider._components["resource:unraid://live/cpu@"]
|
||||
result = await resource.fn()
|
||||
|
||||
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
|
||||
mock_mgr.get_resource_data = AsyncMock(return_value=None)
|
||||
mcp = _make_resources()
|
||||
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||
result = await resource.fn()
|
||||
parsed = json.loads(result)
|
||||
assert "error" in parsed
|
||||
assert "WebSocket timeout" in parsed["error"]
|
||||
assert "status" in parsed
|
||||
|
||||
def test_subscribe_once_not_imported(self) -> None:
|
||||
"""subscribe_once must not be imported — resources use manager cache exclusively."""
|
||||
import unraid_mcp.subscriptions.resources as res_module
|
||||
|
||||
assert not hasattr(res_module, "subscribe_once")
|
||||
|
||||
|
||||
class TestSnapshotSubscriptionsRegistered:
|
||||
"""All SNAPSHOT_ACTIONS must be registered in the SubscriptionManager with auto_start=True."""
|
||||
|
||||
def test_all_snapshot_actions_in_configs(self) -> None:
|
||||
from unraid_mcp.subscriptions.manager import subscription_manager
|
||||
|
||||
for action in SNAPSHOT_ACTIONS:
|
||||
assert action in subscription_manager.subscription_configs, (
|
||||
f"'{action}' not registered in subscription_configs"
|
||||
)
|
||||
|
||||
def test_all_snapshot_actions_autostart(self) -> None:
|
||||
from unraid_mcp.subscriptions.manager import subscription_manager
|
||||
|
||||
for action in SNAPSHOT_ACTIONS:
|
||||
config = subscription_manager.subscription_configs[action]
|
||||
assert config.get("auto_start") is True, (
|
||||
f"'{action}' missing auto_start=True in subscription_configs"
|
||||
)
|
||||
|
||||
|
||||
class TestLogsStreamResource:
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
"""Tests for the unraid_settings tool."""
|
||||
"""Tests for the setting subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import get_args
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import FastMCP
|
||||
from conftest import make_tool_fn
|
||||
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
from unraid_mcp.tools.settings import SETTINGS_ACTIONS, register_settings_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.settings.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool() -> AsyncMock:
|
||||
test_mcp = FastMCP("test")
|
||||
register_settings_tool(test_mcp)
|
||||
# FastMCP 3.x stores tools in providers[0]._components keyed as "tool:{name}@"
|
||||
local_provider = test_mcp.providers[0]
|
||||
tool = local_provider._components["tool:unraid_settings@"] # ty: ignore[unresolved-attribute]
|
||||
return tool.fn
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: removed actions must not appear in SETTINGS_ACTIONS
|
||||
# Regression: removed subactions must raise Invalid subaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
"subaction",
|
||||
[
|
||||
"update_temperature",
|
||||
"update_time",
|
||||
@@ -44,10 +38,10 @@ def _make_tool() -> AsyncMock:
|
||||
"update_ssh",
|
||||
],
|
||||
)
|
||||
def test_removed_settings_actions_are_gone(action: str) -> None:
|
||||
assert action not in get_args(SETTINGS_ACTIONS), (
|
||||
f"{action} references a non-existent mutation and must not be in SETTINGS_ACTIONS"
|
||||
)
|
||||
async def test_removed_settings_subactions_are_invalid(subaction: str) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="setting", subaction=subaction)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -56,19 +50,19 @@ def test_removed_settings_actions_are_gone(action: str) -> None:
|
||||
|
||||
|
||||
class TestSettingsValidation:
|
||||
"""Tests for action validation and destructive guard."""
|
||||
"""Tests for subaction validation and destructive guard."""
|
||||
|
||||
async def test_invalid_action(self, _mock_graphql: AsyncMock) -> None:
|
||||
async def test_invalid_subaction(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="nonexistent_action")
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="setting", subaction="nonexistent_action")
|
||||
|
||||
async def test_destructive_configure_ups_requires_confirm(
|
||||
self, _mock_graphql: AsyncMock
|
||||
) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="confirm=True"):
|
||||
await tool_fn(action="configure_ups", ups_config={"mode": "slave"})
|
||||
await tool_fn(action="setting", subaction="configure_ups", ups_config={"mode": "slave"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -77,21 +71,23 @@ class TestSettingsValidation:
|
||||
|
||||
|
||||
class TestSettingsUpdate:
|
||||
"""Tests for update action."""
|
||||
"""Tests for update subaction."""
|
||||
|
||||
async def test_update_requires_settings_input(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="settings_input is required"):
|
||||
await tool_fn(action="update")
|
||||
await tool_fn(action="setting", subaction="update")
|
||||
|
||||
async def test_update_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"updateSettings": {"restartRequired": False, "values": {}, "warnings": []}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="update", settings_input={"shareCount": 5})
|
||||
result = await tool_fn(
|
||||
action="setting", subaction="update", settings_input={"shareCount": 5}
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "update"
|
||||
assert result["subaction"] == "update"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,18 +96,21 @@ class TestSettingsUpdate:
|
||||
|
||||
|
||||
class TestUpsConfig:
|
||||
"""Tests for configure_ups action."""
|
||||
"""Tests for configure_ups subaction."""
|
||||
|
||||
async def test_configure_ups_requires_ups_config(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="ups_config is required"):
|
||||
await tool_fn(action="configure_ups", confirm=True)
|
||||
await tool_fn(action="setting", subaction="configure_ups", confirm=True)
|
||||
|
||||
async def test_configure_ups_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"configureUps": True}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="configure_ups", confirm=True, ups_config={"mode": "master", "cable": "usb"}
|
||||
action="setting",
|
||||
subaction="configure_ups",
|
||||
confirm=True,
|
||||
ups_config={"mode": "master", "cable": "usb"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "configure_ups"
|
||||
assert result["subaction"] == "configure_ups"
|
||||
|
||||
@@ -387,6 +387,119 @@ def test_tool_error_handler_credentials_error_message_includes_path():
|
||||
assert "setup" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# elicit_reset_confirmation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elicit_reset_confirmation_returns_false_when_ctx_none():
|
||||
"""Returns False immediately when no MCP context is available."""
|
||||
from unraid_mcp.core.setup import elicit_reset_confirmation
|
||||
|
||||
result = await elicit_reset_confirmation(None, "https://example.com")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elicit_reset_confirmation_returns_true_when_user_confirms():
|
||||
"""Returns True when the user accepts and answers True."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from unraid_mcp.core.setup import elicit_reset_confirmation
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.action = "accept"
|
||||
mock_result.data = True
|
||||
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await elicit_reset_confirmation(mock_ctx, "https://example.com")
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elicit_reset_confirmation_returns_false_when_user_answers_false():
|
||||
"""Returns False when the user accepts but answers False (does not want to reset)."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from unraid_mcp.core.setup import elicit_reset_confirmation
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.action = "accept"
|
||||
mock_result.data = False
|
||||
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await elicit_reset_confirmation(mock_ctx, "https://example.com")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elicit_reset_confirmation_returns_false_when_declined():
|
||||
"""Returns False when the user declines via action (dismisses the prompt)."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from unraid_mcp.core.setup import elicit_reset_confirmation
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.action = "decline"
|
||||
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await elicit_reset_confirmation(mock_ctx, "https://example.com")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elicit_reset_confirmation_returns_false_when_cancelled():
|
||||
"""Returns False when the user cancels the prompt."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from unraid_mcp.core.setup import elicit_reset_confirmation
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.action = "cancel"
|
||||
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await elicit_reset_confirmation(mock_ctx, "https://example.com")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elicit_reset_confirmation_returns_false_when_not_implemented():
|
||||
"""Returns False when the MCP client does not support elicitation."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from unraid_mcp.core.setup import elicit_reset_confirmation
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.elicit = AsyncMock(side_effect=NotImplementedError("elicitation not supported"))
|
||||
|
||||
result = await elicit_reset_confirmation(mock_ctx, "https://example.com")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elicit_reset_confirmation_includes_current_url_in_prompt():
|
||||
"""The elicitation message includes the current URL so the user knows what they're replacing."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from unraid_mcp.core.setup import elicit_reset_confirmation
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.action = "decline"
|
||||
mock_ctx.elicit = AsyncMock(return_value=mock_result)
|
||||
|
||||
await elicit_reset_confirmation(mock_ctx, "https://my-unraid.example.com:31337")
|
||||
|
||||
call_kwargs = mock_ctx.elicit.call_args
|
||||
message = call_kwargs.kwargs.get("message") or call_kwargs.args[0]
|
||||
assert "https://my-unraid.example.com:31337" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credentials_not_configured_surfaces_as_tool_error_with_path():
|
||||
"""CredentialsNotConfiguredError from a tool becomes ToolError with the credentials path."""
|
||||
@@ -396,15 +509,15 @@ async def test_credentials_not_configured_surfaces_as_tool_error_with_path():
|
||||
from unraid_mcp.config.settings import CREDENTIALS_ENV_PATH
|
||||
from unraid_mcp.core.exceptions import CredentialsNotConfiguredError, ToolError
|
||||
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.users", "register_users_tool", "unraid_users")
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"unraid_mcp.tools.users.make_graphql_request",
|
||||
"unraid_mcp.tools.unraid.make_graphql_request",
|
||||
new=AsyncMock(side_effect=CredentialsNotConfiguredError()),
|
||||
),
|
||||
pytest.raises(ToolError) as exc_info,
|
||||
):
|
||||
await tool_fn(action="me")
|
||||
await tool_fn(action="user", subaction="me")
|
||||
|
||||
assert str(CREDENTIALS_ENV_PATH) in str(exc_info.value)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for unraid_storage tool."""
|
||||
"""Tests for disk subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import get_args
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -9,13 +8,6 @@ from conftest import make_tool_fn
|
||||
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
from unraid_mcp.core.utils import format_bytes, format_kb, safe_get
|
||||
from unraid_mcp.tools.storage import STORAGE_ACTIONS
|
||||
|
||||
|
||||
def test_unassigned_action_removed() -> None:
|
||||
assert "unassigned" not in get_args(STORAGE_ACTIONS), (
|
||||
"unassigned action references unassignedDevices which is not in live API"
|
||||
)
|
||||
|
||||
|
||||
# --- Unit tests for helpers ---
|
||||
@@ -46,59 +38,63 @@ class TestFormatBytes:
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.storage.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestStorageValidation:
|
||||
async def test_disk_details_requires_disk_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="disk_id"):
|
||||
await tool_fn(action="disk_details")
|
||||
await tool_fn(action="disk", subaction="disk_details")
|
||||
|
||||
async def test_logs_requires_log_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="log_path"):
|
||||
await tool_fn(action="logs")
|
||||
await tool_fn(action="disk", subaction="logs")
|
||||
|
||||
async def test_logs_rejects_invalid_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="log_path must start with"):
|
||||
await tool_fn(action="logs", log_path="/etc/shadow")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/etc/shadow")
|
||||
|
||||
async def test_logs_rejects_path_traversal(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
# Traversal that escapes /var/log/ to reach /etc/shadow
|
||||
with pytest.raises(ToolError, match="log_path must start with"):
|
||||
await tool_fn(action="logs", log_path="/var/log/../../etc/shadow")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/var/log/../../etc/shadow")
|
||||
# Traversal that escapes /mnt/ to reach /etc/passwd
|
||||
with pytest.raises(ToolError, match="log_path must start with"):
|
||||
await tool_fn(action="logs", log_path="/mnt/../etc/passwd")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/mnt/../etc/passwd")
|
||||
|
||||
async def test_logs_allows_valid_paths(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
result = await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
assert result["content"] == "ok"
|
||||
|
||||
async def test_logs_tail_lines_too_large(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="tail_lines must be between"):
|
||||
await tool_fn(action="logs", log_path="/var/log/syslog", tail_lines=10_001)
|
||||
await tool_fn(
|
||||
action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=10_001
|
||||
)
|
||||
|
||||
async def test_logs_tail_lines_zero_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="tail_lines must be between"):
|
||||
await tool_fn(action="logs", log_path="/var/log/syslog", tail_lines=0)
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=0)
|
||||
|
||||
async def test_logs_tail_lines_at_max_accepted(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog", tail_lines=10_000)
|
||||
result = await tool_fn(
|
||||
action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=10_000
|
||||
)
|
||||
assert result["content"] == "ok"
|
||||
|
||||
async def test_non_logs_action_ignores_tail_lines_validation(
|
||||
@@ -106,7 +102,7 @@ class TestStorageValidation:
|
||||
) -> None:
|
||||
_mock_graphql.return_value = {"shares": []}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="shares", tail_lines=0)
|
||||
result = await tool_fn(action="disk", subaction="shares", tail_lines=0)
|
||||
assert result["shares"] == []
|
||||
|
||||
|
||||
@@ -173,13 +169,13 @@ class TestStorageActions:
|
||||
"shares": [{"id": "s:1", "name": "media"}, {"id": "s:2", "name": "backups"}]
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="shares")
|
||||
result = await tool_fn(action="disk", subaction="shares")
|
||||
assert len(result["shares"]) == 2
|
||||
|
||||
async def test_disks(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"disks": [{"id": "d:1", "device": "sda"}]}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disks")
|
||||
result = await tool_fn(action="disk", subaction="disks")
|
||||
assert len(result["disks"]) == 1
|
||||
|
||||
async def test_disk_details(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -194,7 +190,7 @@ class TestStorageActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disk_details", disk_id="d:1")
|
||||
result = await tool_fn(action="disk", subaction="disk_details", disk_id="d:1")
|
||||
assert result["summary"]["temperature"] == "35\u00b0C"
|
||||
assert "1.00 GB" in result["summary"]["size_formatted"]
|
||||
|
||||
@@ -211,7 +207,7 @@ class TestStorageActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disk_details", disk_id="d:1")
|
||||
result = await tool_fn(action="disk", subaction="disk_details", disk_id="d:1")
|
||||
assert result["summary"]["temperature"] == "0\u00b0C"
|
||||
|
||||
async def test_disk_details_temperature_null(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -227,26 +223,26 @@ class TestStorageActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disk_details", disk_id="d:1")
|
||||
result = await tool_fn(action="disk", subaction="disk_details", disk_id="d:1")
|
||||
assert result["summary"]["temperature"] == "N/A"
|
||||
|
||||
async def test_logs_null_log_file(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""logFile being null should return an empty dict."""
|
||||
_mock_graphql.return_value = {"logFile": None}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
result = await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
assert result == {}
|
||||
|
||||
async def test_disk_details_not_found(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"disk": None}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not found"):
|
||||
await tool_fn(action="disk_details", disk_id="d:missing")
|
||||
await tool_fn(action="disk", subaction="disk_details", disk_id="d:missing")
|
||||
|
||||
async def test_log_files(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"logFiles": [{"name": "syslog", "path": "/var/log/syslog"}]}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="log_files")
|
||||
result = await tool_fn(action="disk", subaction="log_files")
|
||||
assert len(result["log_files"]) == 1
|
||||
|
||||
async def test_logs(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -254,7 +250,7 @@ class TestStorageActions:
|
||||
"logFile": {"path": "/var/log/syslog", "content": "log line", "totalLines": 1}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
result = await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
assert result["content"] == "log line"
|
||||
|
||||
|
||||
@@ -268,7 +264,7 @@ class TestStorageNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid JSON"):
|
||||
await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
|
||||
async def test_shares_connection_refused(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Connection refused when listing shares should propagate as ToolError."""
|
||||
@@ -277,14 +273,14 @@ class TestStorageNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Connection refused"):
|
||||
await tool_fn(action="shares")
|
||||
await tool_fn(action="disk", subaction="shares")
|
||||
|
||||
async def test_disks_http_500(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""HTTP 500 when listing disks should propagate as ToolError."""
|
||||
_mock_graphql.side_effect = ToolError("HTTP error 500: Internal Server Error")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="HTTP error 500"):
|
||||
await tool_fn(action="disks")
|
||||
await tool_fn(action="disk", subaction="disks")
|
||||
|
||||
|
||||
class TestStorageFlashBackup:
|
||||
@@ -292,29 +288,40 @@ class TestStorageFlashBackup:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool_fn(
|
||||
action="flash_backup", remote_name="r", source_path="/boot", destination_path="r:b"
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
destination_path="r:b",
|
||||
)
|
||||
|
||||
async def test_flash_backup_requires_remote_name(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="remote_name"):
|
||||
await tool_fn(action="flash_backup", confirm=True)
|
||||
await tool_fn(action="disk", subaction="flash_backup", confirm=True)
|
||||
|
||||
async def test_flash_backup_requires_source_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="source_path"):
|
||||
await tool_fn(action="flash_backup", confirm=True, remote_name="r")
|
||||
await tool_fn(action="disk", subaction="flash_backup", confirm=True, remote_name="r")
|
||||
|
||||
async def test_flash_backup_requires_destination_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="destination_path"):
|
||||
await tool_fn(action="flash_backup", confirm=True, remote_name="r", source_path="/boot")
|
||||
await tool_fn(
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
)
|
||||
|
||||
async def test_flash_backup_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"initiateFlashBackup": {"status": "started", "jobId": "j:1"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="flash_backup",
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
@@ -327,7 +334,8 @@ class TestStorageFlashBackup:
|
||||
_mock_graphql.return_value = {"initiateFlashBackup": {"status": "started", "jobId": "j:2"}}
|
||||
tool_fn = _make_tool()
|
||||
await tool_fn(
|
||||
action="flash_backup",
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for unraid_users tool.
|
||||
"""Tests for user subactions of the consolidated unraid tool.
|
||||
|
||||
NOTE: Unraid GraphQL API only supports the me() query.
|
||||
User management operations (list, add, delete, cloud, remote_access, origins) are NOT available in the API.
|
||||
@@ -15,35 +15,35 @@ from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.users.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.users", "register_users_tool", "unraid_users")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestUsersValidation:
|
||||
"""Test validation for invalid actions."""
|
||||
"""Test validation for invalid subactions."""
|
||||
|
||||
async def test_invalid_action_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Test that non-existent actions are rejected with clear error."""
|
||||
async def test_invalid_subaction_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Test that non-existent subactions are rejected with clear error."""
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="list")
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="user", subaction="list")
|
||||
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="add")
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="user", subaction="add")
|
||||
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="delete")
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="user", subaction="delete")
|
||||
|
||||
with pytest.raises(ToolError, match="Invalid action"):
|
||||
await tool_fn(action="cloud")
|
||||
with pytest.raises(ToolError, match="Invalid subaction"):
|
||||
await tool_fn(action="user", subaction="cloud")
|
||||
|
||||
|
||||
class TestUsersActions:
|
||||
"""Test the single supported action: me."""
|
||||
"""Test the single supported subaction: me."""
|
||||
|
||||
async def test_me(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Test querying current authenticated user."""
|
||||
@@ -51,27 +51,18 @@ class TestUsersActions:
|
||||
"me": {"id": "u:1", "name": "root", "description": "", "roles": ["ADMIN"]}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="me")
|
||||
result = await tool_fn(action="user", subaction="me")
|
||||
assert result["name"] == "root"
|
||||
assert result["roles"] == ["ADMIN"]
|
||||
_mock_graphql.assert_called_once()
|
||||
|
||||
async def test_me_default_action(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Test that 'me' is the default action."""
|
||||
_mock_graphql.return_value = {
|
||||
"me": {"id": "u:1", "name": "root", "description": "", "roles": ["ADMIN"]}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn()
|
||||
assert result["name"] == "root"
|
||||
|
||||
|
||||
class TestUsersNoneHandling:
|
||||
"""Verify actions return empty dict (not TypeError) when API returns None."""
|
||||
"""Verify subactions return empty dict (not TypeError) when API returns None."""
|
||||
|
||||
async def test_me_returns_none(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Test that me returns empty dict when API returns None."""
|
||||
_mock_graphql.return_value = {"me": None}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="me")
|
||||
result = await tool_fn(action="user", subaction="me")
|
||||
assert result == {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for unraid_vm tool."""
|
||||
"""Tests for vm subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -11,34 +11,32 @@ from unraid_mcp.core.exceptions import ToolError
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch(
|
||||
"unraid_mcp.tools.virtualization.make_graphql_request", new_callable=AsyncMock
|
||||
) as mock:
|
||||
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _make_tool():
|
||||
return make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestVmValidation:
|
||||
async def test_actions_except_list_require_vm_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
for action in ("details", "start", "stop", "pause", "resume", "reboot"):
|
||||
for subaction in ("details", "start", "stop", "pause", "resume", "reboot"):
|
||||
with pytest.raises(ToolError, match="vm_id"):
|
||||
await tool_fn(action=action)
|
||||
await tool_fn(action="vm", subaction=subaction)
|
||||
|
||||
async def test_destructive_actions_require_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
for action in ("force_stop", "reset"):
|
||||
for subaction in ("force_stop", "reset"):
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool_fn(action=action, vm_id="uuid-1")
|
||||
await tool_fn(action="vm", subaction=subaction, vm_id="uuid-1")
|
||||
|
||||
async def test_destructive_vm_id_check_before_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Destructive actions without vm_id should fail on vm_id first (validated before confirm)."""
|
||||
"""Destructive subactions without vm_id should fail on vm_id first (validated before confirm)."""
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="vm_id"):
|
||||
await tool_fn(action="force_stop")
|
||||
await tool_fn(action="vm", subaction="force_stop")
|
||||
|
||||
|
||||
class TestVmActions:
|
||||
@@ -51,20 +49,20 @@ class TestVmActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list")
|
||||
result = await tool_fn(action="vm", subaction="list")
|
||||
assert len(result["vms"]) == 1
|
||||
assert result["vms"][0]["name"] == "Windows 11"
|
||||
|
||||
async def test_list_empty(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vms": {"domains": []}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list")
|
||||
result = await tool_fn(action="vm", subaction="list")
|
||||
assert result["vms"] == []
|
||||
|
||||
async def test_list_no_vms_key(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="list")
|
||||
result = await tool_fn(action="vm", subaction="list")
|
||||
assert result["vms"] == []
|
||||
|
||||
async def test_details_by_uuid(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -74,7 +72,7 @@ class TestVmActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="details", vm_id="uuid-1")
|
||||
result = await tool_fn(action="vm", subaction="details", vm_id="uuid-1")
|
||||
assert result["name"] == "Win11"
|
||||
|
||||
async def test_details_by_name(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -84,7 +82,7 @@ class TestVmActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="details", vm_id="Win11")
|
||||
result = await tool_fn(action="vm", subaction="details", vm_id="Win11")
|
||||
assert result["uuid"] == "uuid-1"
|
||||
|
||||
async def test_details_not_found(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -95,48 +93,48 @@ class TestVmActions:
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not found"):
|
||||
await tool_fn(action="details", vm_id="nonexistent")
|
||||
await tool_fn(action="vm", subaction="details", vm_id="nonexistent")
|
||||
|
||||
async def test_start_vm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {"start": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="start", vm_id="uuid-1")
|
||||
result = await tool_fn(action="vm", subaction="start", vm_id="uuid-1")
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "start"
|
||||
assert result["subaction"] == "start"
|
||||
|
||||
async def test_force_stop(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {"forceStop": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="force_stop", vm_id="uuid-1", confirm=True)
|
||||
result = await tool_fn(action="vm", subaction="force_stop", vm_id="uuid-1", confirm=True)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "force_stop"
|
||||
assert result["subaction"] == "force_stop"
|
||||
|
||||
async def test_stop_vm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {"stop": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="stop", vm_id="uuid-1")
|
||||
result = await tool_fn(action="vm", subaction="stop", vm_id="uuid-1")
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "stop"
|
||||
assert result["subaction"] == "stop"
|
||||
|
||||
async def test_pause_vm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {"pause": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="pause", vm_id="uuid-1")
|
||||
result = await tool_fn(action="vm", subaction="pause", vm_id="uuid-1")
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "pause"
|
||||
assert result["subaction"] == "pause"
|
||||
|
||||
async def test_resume_vm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {"resume": True}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="resume", vm_id="uuid-1")
|
||||
result = await tool_fn(action="vm", subaction="resume", vm_id="uuid-1")
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "resume"
|
||||
assert result["subaction"] == "resume"
|
||||
|
||||
async def test_mutation_unexpected_response(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"vm": {}}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to start"):
|
||||
await tool_fn(action="start", vm_id="uuid-1")
|
||||
await tool_fn(action="vm", subaction="start", vm_id="uuid-1")
|
||||
|
||||
|
||||
class TestVmMutationFailures:
|
||||
@@ -147,38 +145,38 @@ class TestVmMutationFailures:
|
||||
_mock_graphql.return_value = {}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to start"):
|
||||
await tool_fn(action="start", vm_id="uuid-1")
|
||||
await tool_fn(action="vm", subaction="start", vm_id="uuid-1")
|
||||
|
||||
async def test_start_mutation_returns_false(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""VM start returning False should still succeed (the tool reports the raw value)."""
|
||||
_mock_graphql.return_value = {"vm": {"start": False}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="start", vm_id="uuid-1")
|
||||
result = await tool_fn(action="vm", subaction="start", vm_id="uuid-1")
|
||||
assert result["success"] is False
|
||||
assert result["action"] == "start"
|
||||
assert result["subaction"] == "start"
|
||||
|
||||
async def test_stop_mutation_returns_null(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""VM stop returning None in the field should succeed (key exists, value is None)."""
|
||||
_mock_graphql.return_value = {"vm": {"stop": None}}
|
||||
tool_fn = _make_tool()
|
||||
# The check is `field in data["vm"]` — `in` checks key existence, not truthiness
|
||||
result = await tool_fn(action="stop", vm_id="uuid-1")
|
||||
result = await tool_fn(action="vm", subaction="stop", vm_id="uuid-1")
|
||||
assert result["success"] is None
|
||||
assert result["action"] == "stop"
|
||||
assert result["subaction"] == "stop"
|
||||
|
||||
async def test_force_stop_mutation_empty_vm_object(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Empty vm object with no matching field should raise ToolError."""
|
||||
_mock_graphql.return_value = {"vm": {}}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to force_stop"):
|
||||
await tool_fn(action="force_stop", vm_id="uuid-1", confirm=True)
|
||||
await tool_fn(action="vm", subaction="force_stop", vm_id="uuid-1", confirm=True)
|
||||
|
||||
async def test_reboot_mutation_vm_key_none(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""vm key being None should raise ToolError."""
|
||||
_mock_graphql.return_value = {"vm": None}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Failed to reboot"):
|
||||
await tool_fn(action="reboot", vm_id="uuid-1")
|
||||
await tool_fn(action="vm", subaction="reboot", vm_id="uuid-1")
|
||||
|
||||
async def test_mutation_timeout(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Mid-operation timeout should be wrapped in ToolError."""
|
||||
@@ -186,4 +184,4 @@ class TestVmMutationFailures:
|
||||
_mock_graphql.side_effect = TimeoutError("VM operation timed out")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="timed out"):
|
||||
await tool_fn(action="start", vm_id="uuid-1")
|
||||
await tool_fn(action="vm", subaction="start", vm_id="uuid-1")
|
||||
|
||||
Reference in New Issue
Block a user