from pathlib import Path from unittest.mock import patch import pytest from unraid_mcp.core.exceptions import CredentialsNotConfiguredError, ToolError def test_credentials_not_configured_error_exists(): err = CredentialsNotConfiguredError() assert str(err) == "Unraid credentials are not configured." def test_credentials_not_configured_error_is_exception(): """CredentialsNotConfiguredError must be catchable as a plain Exception.""" with pytest.raises(Exception): raise CredentialsNotConfiguredError() def test_credentials_not_configured_error_is_not_tool_error(): """CredentialsNotConfiguredError must NOT be a ToolError — it bypasses MCP protocol error handling.""" assert not issubclass(CredentialsNotConfiguredError, ToolError) def test_settings_is_configured_true(): from unraid_mcp.config import settings with ( patch.object(settings, "UNRAID_API_URL", "https://example.com"), patch.object(settings, "UNRAID_API_KEY", "key123"), ): assert settings.is_configured() is True def test_settings_is_configured_false_when_missing(): from unraid_mcp.config import settings with ( patch.object(settings, "UNRAID_API_URL", None), patch.object(settings, "UNRAID_API_KEY", None), ): assert settings.is_configured() is False def test_settings_apply_runtime_config_updates_module_globals(): import os from unraid_mcp.config import settings original_url = settings.UNRAID_API_URL original_key = settings.UNRAID_API_KEY original_env_url = os.environ.get("UNRAID_API_URL") original_env_key = os.environ.get("UNRAID_API_KEY") try: settings.apply_runtime_config("https://newurl.com/graphql", "newkey") assert settings.UNRAID_API_URL == "https://newurl.com/graphql" assert settings.UNRAID_API_KEY == "newkey" assert os.environ["UNRAID_API_URL"] == "https://newurl.com/graphql" assert os.environ["UNRAID_API_KEY"] == "newkey" finally: # Reset module globals settings.UNRAID_API_URL = original_url settings.UNRAID_API_KEY = original_key # Reset os.environ if original_env_url is None: os.environ.pop("UNRAID_API_URL", None) else: os.environ["UNRAID_API_URL"] = original_env_url if original_env_key is None: os.environ.pop("UNRAID_API_KEY", None) else: os.environ["UNRAID_API_KEY"] = original_env_key def test_run_server_does_not_exit_when_creds_missing(monkeypatch): """Server should not sys.exit(1) when credentials are absent.""" import unraid_mcp.config.settings as settings_mod monkeypatch.setattr(settings_mod, "UNRAID_API_URL", None) monkeypatch.setattr(settings_mod, "UNRAID_API_KEY", None) from unraid_mcp import server as server_mod with ( patch.object(server_mod, "mcp") as mock_mcp, patch("unraid_mcp.server.logger") as mock_logger, ): mock_mcp.run.side_effect = SystemExit(0) try: server_mod.run_server() except SystemExit as e: assert e.code == 0, f"Unexpected sys.exit({e.code}) — server crashed on missing creds" mock_logger.warning.assert_called() warning_msgs = [call[0][0] for call in mock_logger.warning.call_args_list] assert any("elicitation" in msg for msg in warning_msgs), ( f"Expected a warning containing 'elicitation', got: {warning_msgs}" ) @pytest.mark.asyncio async def test_elicit_and_configure_writes_env_file(tmp_path): """elicit_and_configure writes a .env file and calls apply_runtime_config.""" from unittest.mock import AsyncMock, MagicMock, patch from unraid_mcp.core.setup import elicit_and_configure mock_ctx = MagicMock() mock_result = MagicMock() mock_result.action = "accept" mock_result.data = MagicMock() mock_result.data.api_url = "https://myunraid.example.com/graphql" mock_result.data.api_key = "abc123secret" mock_ctx.elicit = AsyncMock(return_value=mock_result) creds_dir = tmp_path / "creds" creds_file = creds_dir / ".env" with ( patch("unraid_mcp.core.setup.CREDENTIALS_DIR", creds_dir), patch("unraid_mcp.core.setup.CREDENTIALS_ENV_PATH", creds_file), patch("unraid_mcp.core.setup.PROJECT_ROOT", tmp_path), patch("unraid_mcp.core.setup.apply_runtime_config") as mock_apply, ): result = await elicit_and_configure(mock_ctx) assert result is True assert creds_file.exists() content = creds_file.read_text() assert "UNRAID_API_URL=https://myunraid.example.com/graphql" in content assert "UNRAID_API_KEY=abc123secret" in content mock_apply.assert_called_once_with("https://myunraid.example.com/graphql", "abc123secret") @pytest.mark.asyncio async def test_elicit_and_configure_returns_false_on_decline(): from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_and_configure mock_ctx = MagicMock() mock_result = MagicMock() mock_result.action = "decline" mock_ctx.elicit = AsyncMock(return_value=mock_result) result = await elicit_and_configure(mock_ctx) assert result is False @pytest.mark.asyncio async def test_elicit_and_configure_returns_false_on_cancel(): from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_and_configure mock_ctx = MagicMock() mock_result = MagicMock() mock_result.action = "cancel" mock_ctx.elicit = AsyncMock(return_value=mock_result) result = await elicit_and_configure(mock_ctx) assert result is False @pytest.mark.asyncio async def test_make_graphql_request_raises_sentinel_when_unconfigured(): """make_graphql_request raises CredentialsNotConfiguredError (not ToolError) when credentials are absent, so callers can trigger elicitation.""" from unraid_mcp.config import settings as settings_mod from unraid_mcp.core.client import make_graphql_request from unraid_mcp.core.exceptions import CredentialsNotConfiguredError original_url = settings_mod.UNRAID_API_URL original_key = settings_mod.UNRAID_API_KEY try: settings_mod.UNRAID_API_URL = None settings_mod.UNRAID_API_KEY = None with pytest.raises(CredentialsNotConfiguredError): await make_graphql_request("{ __typename }") finally: settings_mod.UNRAID_API_URL = original_url settings_mod.UNRAID_API_KEY = original_key import os # noqa: E402 — needed for reload-based tests below def test_credentials_dir_defaults_to_home_unraid_mcp(): """CREDENTIALS_DIR defaults to ~/.unraid-mcp when env var is not set.""" import importlib import unraid_mcp.config.settings as s os.environ.pop("UNRAID_CREDENTIALS_DIR", None) try: with patch.dict(os.environ, {}, clear=False): os.environ.pop("UNRAID_CREDENTIALS_DIR", None) importlib.reload(s) assert Path.home() / ".unraid-mcp" == s.CREDENTIALS_DIR finally: importlib.reload(s) # Restore module state def test_credentials_dir_env_var_override(): """UNRAID_CREDENTIALS_DIR env var overrides the default.""" import importlib import unraid_mcp.config.settings as s custom = "/tmp/custom-creds" try: with patch.dict(os.environ, {"UNRAID_CREDENTIALS_DIR": custom}): importlib.reload(s) assert Path(custom) == s.CREDENTIALS_DIR finally: # Reload without the custom env var to restore original state os.environ.pop("UNRAID_CREDENTIALS_DIR", None) importlib.reload(s) def test_credentials_env_path_is_dot_env_inside_credentials_dir(): import unraid_mcp.config.settings as s assert s.CREDENTIALS_ENV_PATH == s.CREDENTIALS_DIR / ".env" import stat # noqa: E402 def test_write_env_creates_credentials_dir_with_700_permissions(tmp_path): """_write_env creates CREDENTIALS_DIR with mode 700 (owner-only).""" from unraid_mcp.core.setup import _write_env creds_dir = tmp_path / "creds" creds_file = creds_dir / ".env" with ( patch("unraid_mcp.core.setup.CREDENTIALS_DIR", creds_dir), patch("unraid_mcp.core.setup.CREDENTIALS_ENV_PATH", creds_file), ): _write_env("https://example.com", "mykey") assert creds_dir.exists() dir_mode = stat.S_IMODE(creds_dir.stat().st_mode) assert dir_mode == 0o700, f"Expected 0o700, got {oct(dir_mode)}" def test_write_env_sets_file_permissions_600(tmp_path): """_write_env sets .env file permissions to 600 (owner read/write only).""" from unraid_mcp.core.setup import _write_env creds_dir = tmp_path / "creds" creds_file = creds_dir / ".env" with ( patch("unraid_mcp.core.setup.CREDENTIALS_DIR", creds_dir), patch("unraid_mcp.core.setup.CREDENTIALS_ENV_PATH", creds_file), ): _write_env("https://example.com", "mykey") file_mode = stat.S_IMODE(creds_file.stat().st_mode) assert file_mode == 0o600, f"Expected 0o600, got {oct(file_mode)}" def test_write_env_seeds_from_env_example_on_first_run(tmp_path): """_write_env copies .env.example structure and replaces credentials in-place.""" from unraid_mcp.core.setup import _write_env creds_dir = tmp_path / "creds" creds_file = creds_dir / ".env" # Create a fake .env.example example = tmp_path / ".env.example" example.write_text( "# Example config\nFOO=bar\nUNRAID_API_URL=placeholder\nUNRAID_API_KEY=placeholder\n" ) with ( patch("unraid_mcp.core.setup.CREDENTIALS_DIR", creds_dir), patch("unraid_mcp.core.setup.CREDENTIALS_ENV_PATH", creds_file), patch("unraid_mcp.core.setup.PROJECT_ROOT", tmp_path), ): _write_env("https://real.url", "realkey") content = creds_file.read_text() assert "UNRAID_API_URL=https://real.url" in content assert "UNRAID_API_KEY=realkey" in content assert "# Example config" in content # comment preserved assert "FOO=bar" in content # other vars preserved assert "placeholder" not in content # old credential values replaced # Credentials should be at their original position (after comments), not prepended before them lines = content.splitlines() url_idx = next(i for i, line in enumerate(lines) if line.startswith("UNRAID_API_URL=")) comment_idx = next(i for i, line in enumerate(lines) if line.startswith("# Example config")) assert comment_idx < url_idx # Comment comes before credentials def test_write_env_first_run_no_example_file(tmp_path): """_write_env works on first run when .env.example does not exist.""" from unraid_mcp.core.setup import _write_env creds_dir = tmp_path / "creds" creds_file = creds_dir / ".env" # tmp_path has no .env.example with ( patch("unraid_mcp.core.setup.CREDENTIALS_DIR", creds_dir), patch("unraid_mcp.core.setup.CREDENTIALS_ENV_PATH", creds_file), patch("unraid_mcp.core.setup.PROJECT_ROOT", tmp_path), ): _write_env("https://myserver.com", "mykey123") assert creds_file.exists() content = creds_file.read_text() assert "UNRAID_API_URL=https://myserver.com" in content assert "UNRAID_API_KEY=mykey123" in content def test_write_env_updates_existing_credentials_in_place(tmp_path): """_write_env updates credentials without destroying other vars.""" from unraid_mcp.core.setup import _write_env creds_dir = tmp_path / "creds" creds_dir.mkdir(mode=0o700) creds_file = creds_dir / ".env" creds_file.write_text( "UNRAID_API_URL=https://old.url\nUNRAID_API_KEY=oldkey\nUNRAID_VERIFY_SSL=false\n" ) with ( patch("unraid_mcp.core.setup.CREDENTIALS_DIR", creds_dir), patch("unraid_mcp.core.setup.CREDENTIALS_ENV_PATH", creds_file), patch("unraid_mcp.core.setup.PROJECT_ROOT", tmp_path), ): _write_env("https://new.url", "newkey") content = creds_file.read_text() assert "UNRAID_API_URL=https://new.url" in content assert "UNRAID_API_KEY=newkey" in content assert "UNRAID_VERIFY_SSL=false" in content # preserved assert "old" not in content @pytest.mark.asyncio async def test_elicit_and_configure_returns_false_when_client_not_supported(): """elicit_and_configure returns False when client raises NotImplementedError.""" from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_and_configure mock_ctx = MagicMock() mock_ctx.elicit = AsyncMock(side_effect=NotImplementedError("elicitation not supported")) result = await elicit_and_configure(mock_ctx) assert result is False def test_tool_error_handler_converts_credentials_not_configured_to_tool_error(): """tool_error_handler wraps CredentialsNotConfiguredError in a ToolError.""" import logging from unraid_mcp.core.exceptions import ( CredentialsNotConfiguredError, ToolError, tool_error_handler, ) _log = logging.getLogger("test") with pytest.raises(ToolError), tool_error_handler("docker", "list", _log): raise CredentialsNotConfiguredError() def test_tool_error_handler_credentials_error_message_includes_path(): """ToolError from CredentialsNotConfiguredError includes the credentials path.""" import logging from unraid_mcp.config.settings import CREDENTIALS_ENV_PATH from unraid_mcp.core.exceptions import ( CredentialsNotConfiguredError, ToolError, tool_error_handler, ) _log = logging.getLogger("test") with pytest.raises(ToolError) as exc_info, tool_error_handler("docker", "list", _log): raise CredentialsNotConfiguredError() assert str(CREDENTIALS_ENV_PATH) in str(exc_info.value) 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.""" from unittest.mock import AsyncMock, patch from tests.conftest import make_tool_fn 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.unraid", "register_unraid_tool", "unraid") with ( patch( "unraid_mcp.tools.unraid.make_graphql_request", new=AsyncMock(side_effect=CredentialsNotConfiguredError()), ), pytest.raises(ToolError) as exc_info, ): await tool_fn(action="user", subaction="me") assert str(CREDENTIALS_ENV_PATH) in str(exc_info.value)