feat(settings): add update_ssh action with confirm=True guard

Enables/disables SSH and sets port via updateSshSettings mutation
(UpdateSshInput: enabled: Boolean!, port: Int!). Changing SSH config
can lock users out of the server — requires confirm=True.

- Add update_ssh to MUTATIONS, DESTRUCTIVE_ACTIONS, SETTINGS_ACTIONS
- Add ssh_enabled/ssh_port parameters to unraid_settings
- Add TestSshSettings class (4 tests: require ssh_enabled, require ssh_port, success, disable+verify vars)
- Update safety test KNOWN_DESTRUCTIVE + _DESTRUCTIVE_TEST_CASES + positive confirm test
- Update schema completeness test

757 tests passing
This commit is contained in:
Jacob Magar
2026-03-15 20:13:51 -04:00
parent 94850333e8
commit 389b88f560
4 changed files with 87 additions and 7 deletions

View File

@@ -89,7 +89,12 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = {
"module": "unraid_mcp.tools.settings",
"register_fn": "register_settings_tool",
"tool_name": "unraid_settings",
"actions": {"configure_ups", "setup_remote_access", "enable_dynamic_remote_access"},
"actions": {
"configure_ups",
"setup_remote_access",
"enable_dynamic_remote_access",
"update_ssh",
},
"runtime_set": SETTINGS_DESTRUCTIVE,
},
"plugins": {
@@ -217,6 +222,7 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
),
# Settings
("settings", "configure_ups", {"ups_config": {"mode": "slave"}}),
("settings", "update_ssh", {"ssh_enabled": True, "ssh_port": 22}),
# Plugins
("plugins", "remove", {"names": ["my-plugin"]}),
]
@@ -455,6 +461,16 @@ class TestConfirmAllowsExecution:
)
assert result["success"] is True
async def test_settings_update_ssh_with_confirm(
self, _mock_settings_graphql: AsyncMock
) -> None:
_mock_settings_graphql.return_value = {"updateSshSettings": {"useSsh": True, "portssh": 22}}
tool_fn = make_tool_fn(
"unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"
)
result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True, ssh_port=22)
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")

View File

@@ -765,6 +765,7 @@ class TestSettingsMutations:
"connect_sign_out",
"setup_remote_access",
"enable_dynamic_remote_access",
"update_ssh",
}
assert set(MUTATIONS.keys()) == expected

View File

@@ -58,6 +58,11 @@ class TestSettingsValidation:
action="enable_dynamic_remote_access", access_url_type="WAN", dynamic_enabled=True
)
async def test_destructive_update_ssh_requires_confirm(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
with pytest.raises(ToolError, match="confirm=True"):
await tool_fn(action="update_ssh", ssh_enabled=True, ssh_port=22)
class TestSettingsUpdate:
"""Tests for update action."""
@@ -267,3 +272,32 @@ class TestRemoteAccess:
access_url_ipv6="::1",
)
assert result["success"] is True
class TestSshSettings:
"""Tests for update_ssh action."""
async def test_update_ssh_requires_ssh_enabled(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
with pytest.raises(ToolError, match="ssh_enabled is required"):
await tool_fn(action="update_ssh", confirm=True, ssh_port=22)
async def test_update_ssh_requires_ssh_port(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
with pytest.raises(ToolError, match="ssh_port is required"):
await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True)
async def test_update_ssh_success(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"updateSshSettings": {"useSsh": True, "portssh": 22}}
tool_fn = _make_tool()
result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True, ssh_port=22)
assert result["success"] is True
assert result["action"] == "update_ssh"
async def test_update_ssh_disable(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"updateSshSettings": {"useSsh": False, "portssh": 22}}
tool_fn = _make_tool()
result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=False, ssh_port=22)
assert result["success"] is True
call_vars = _mock_graphql.call_args[0][1]
assert call_vars["input"] == {"enabled": False, "port": 22}