"""Tests for auth.py: credential resolution, keyring, login, logout.""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from mcp_synology_filestation.auth import AuthenticationError, AuthManager from mcp_synology_filestation.config import AppConfig, ConnectionConfig @pytest.fixture() def config() -> AppConfig: """Minimal AppConfig for testing.""" return AppConfig( schema_version=1, connection=ConnectionConfig(host="nas.example.com"), ) @pytest.fixture() def auth(config: AppConfig) -> AuthManager: return AuthManager(config) # ────────────────────────────────────────────────────────────────────────── # resolve_credentials # ────────────────────────────────────────────────────────────────────────── def test_resolve_from_env(auth: AuthManager, monkeypatch: pytest.MonkeyPatch) -> None: """Credentials are resolved from environment variables.""" monkeypatch.setenv("SYNOLOGY_USERNAME", "envuser") monkeypatch.setenv("SYNOLOGY_PASSWORD", "envpass") user, pw, tok = auth.resolve_credentials() assert user == "envuser" assert pw == "envpass" assert tok is None def test_resolve_from_keyring(auth: AuthManager, monkeypatch: pytest.MonkeyPatch) -> None: """Credentials are resolved from OS keyring when env vars absent.""" monkeypatch.delenv("SYNOLOGY_USERNAME", raising=False) monkeypatch.delenv("SYNOLOGY_PASSWORD", raising=False) mock_keyring = MagicMock() mock_keyring.get_password.side_effect = lambda service, key: { "username": "kruser", "password": "krpass", "device_token": "token123", }.get(key) with patch.dict("sys.modules", {"keyring": mock_keyring}): user, pw, tok = auth.resolve_credentials() assert user == "kruser" assert pw == "krpass" assert tok == "token123" def test_resolve_no_credentials(auth: AuthManager, monkeypatch: pytest.MonkeyPatch) -> None: """AuthenticationError is raised when no credentials are found.""" monkeypatch.delenv("SYNOLOGY_USERNAME", raising=False) monkeypatch.delenv("SYNOLOGY_PASSWORD", raising=False) mock_keyring = MagicMock() mock_keyring.get_password.return_value = None with ( patch.dict("sys.modules", {"keyring": mock_keyring}), pytest.raises(AuthenticationError, match="No credentials found"), ): auth.resolve_credentials() # ────────────────────────────────────────────────────────────────────────── # login # ────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_login_success(auth: AuthManager, monkeypatch: pytest.MonkeyPatch) -> None: """Successful login returns the session ID.""" monkeypatch.setenv("SYNOLOGY_USERNAME", "user") monkeypatch.setenv("SYNOLOGY_PASSWORD", "pass") client = MagicMock() client.request = AsyncMock(return_value={"sid": "abc123"}) sid = await auth.login(client) assert sid == "abc123" client.request.assert_called_once() call_kwargs = client.request.call_args assert call_kwargs[0][0] == "SYNO.API.Auth" assert call_kwargs[0][1] == "login" @pytest.mark.asyncio async def test_login_2fa_required(auth: AuthManager, monkeypatch: pytest.MonkeyPatch) -> None: """Login raises AuthenticationError with code 403 when 2FA is needed.""" monkeypatch.setenv("SYNOLOGY_USERNAME", "user") monkeypatch.setenv("SYNOLOGY_PASSWORD", "pass") monkeypatch.delenv("SYNOLOGY_USERNAME", raising=False) monkeypatch.setenv("SYNOLOGY_USERNAME", "user") from mcp_synology_filestation.client import SynologyError client = MagicMock() client.request = AsyncMock(side_effect=SynologyError("2FA required", code=403)) with pytest.raises(AuthenticationError) as exc_info: await auth.login(client) assert exc_info.value.code == 403 assert "setup" in str(exc_info.value).lower() @pytest.mark.asyncio async def test_login_wrong_password(auth: AuthManager, monkeypatch: pytest.MonkeyPatch) -> None: """Login raises AuthenticationError on wrong credentials.""" monkeypatch.setenv("SYNOLOGY_USERNAME", "user") monkeypatch.setenv("SYNOLOGY_PASSWORD", "wrong") from mcp_synology_filestation.client import SynologyError client = MagicMock() client.request = AsyncMock( side_effect=SynologyError("Incorrect username or password", code=400) ) with pytest.raises(AuthenticationError) as exc_info: await auth.login(client) assert exc_info.value.code == 400 @pytest.mark.asyncio async def test_login_no_sid(auth: AuthManager, monkeypatch: pytest.MonkeyPatch) -> None: """Login raises AuthenticationError when response has no sid.""" monkeypatch.setenv("SYNOLOGY_USERNAME", "user") monkeypatch.setenv("SYNOLOGY_PASSWORD", "pass") client = MagicMock() client.request = AsyncMock(return_value={}) # no sid with pytest.raises(AuthenticationError, match="no session ID"): await auth.login(client) # ────────────────────────────────────────────────────────────────────────── # logout # ────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_logout_no_session(auth: AuthManager) -> None: """Logout is a no-op when sid is None.""" client = MagicMock() client.sid = None client.request = AsyncMock() await auth.logout(client) client.request.assert_not_called() @pytest.mark.asyncio async def test_logout_clears_sid(auth: AuthManager) -> None: """Logout calls the DSM logout endpoint and clears client.sid.""" client = MagicMock() client.sid = "active-session" client.request = AsyncMock(return_value={}) await auth.logout(client) client.request.assert_called_once() assert client.sid is None @pytest.mark.asyncio async def test_logout_tolerates_error(auth: AuthManager) -> None: """Logout silently ignores SynologyError (session may have expired).""" from mcp_synology_filestation.client import SynologyError client = MagicMock() client.sid = "stale-session" client.request = AsyncMock(side_effect=SynologyError("Session invalid", code=119)) # Should not raise await auth.logout(client) assert client.sid is None