59301ae760
- auth.py: AuthManager with OS keyring, env var fallback, 2FA device token flow - config.py: AppConfig/ConnectionConfig dataclasses, YAML load/save, env overrides - client.py: FileStationClient with lazy init, session re-auth, upload/download - cli.py: setup / check / serve subcommands (anyio.run throughout) - server.py: create_server factory wiring FastMCP to FileStation tools - tools/filestation.py: list_shares and list_dir with ASCII table output, pagination hints, input validation, DSM error mapping - tests: 30 unit tests, all passing (auth, config, tools) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""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
|