feat: implement auth infrastructure and first two FileStation tools

- 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>
This commit is contained in:
2026-04-14 08:23:34 +02:00
parent 9fc5a3d68c
commit 59301ae760
10 changed files with 2945 additions and 8 deletions
+188
View File
@@ -0,0 +1,188 @@
"""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
+186
View File
@@ -0,0 +1,186 @@
"""Tests for config.py: loading, validation, env overrides, save."""
from __future__ import annotations
import textwrap
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from mcp_synology_filestation.config import (
AppConfig,
ConnectionConfig,
load_config,
save_config,
)
def _write_config(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(content), encoding="utf-8")
def test_load_config_minimal(tmp_path: Path) -> None:
"""Minimal valid config loads successfully."""
cfg_file = tmp_path / "config.yaml"
_write_config(
cfg_file,
"""\
schema_version: 1
connection:
host: nas.example.com
""",
)
config = load_config(cfg_file)
assert config.connection.host == "nas.example.com"
assert config.connection.port == 443
assert config.connection.https is True
assert config.connection.verify_ssl is True
assert config.connection.timeout == 30
assert config.alias is None
def test_load_config_full(tmp_path: Path) -> None:
"""All fields are parsed correctly."""
cfg_file = tmp_path / "config.yaml"
_write_config(
cfg_file,
"""\
schema_version: 1
alias: home-nas
connection:
host: 192.168.1.2
port: 5001
https: true
verify_ssl: false
timeout: 60
""",
)
config = load_config(cfg_file)
assert config.connection.host == "192.168.1.2"
assert config.connection.port == 5001
assert config.connection.verify_ssl is False
assert config.connection.timeout == 60
assert config.alias == "home-nas"
def test_load_config_missing_file(tmp_path: Path) -> None:
"""Missing config file raises FileNotFoundError."""
with pytest.raises(FileNotFoundError):
load_config(tmp_path / "nonexistent.yaml")
def test_load_config_wrong_schema_version(tmp_path: Path) -> None:
"""Wrong schema_version raises ValueError."""
cfg_file = tmp_path / "config.yaml"
_write_config(
cfg_file,
"""\
schema_version: 99
connection:
host: nas.example.com
""",
)
with pytest.raises(ValueError, match="schema_version"):
load_config(cfg_file)
def test_load_config_missing_host(tmp_path: Path) -> None:
"""Missing host raises ValueError."""
cfg_file = tmp_path / "config.yaml"
_write_config(
cfg_file,
"""\
schema_version: 1
connection: {}
""",
)
with pytest.raises(ValueError, match="connection.host"):
load_config(cfg_file)
def test_load_config_env_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Environment variables override YAML values."""
cfg_file = tmp_path / "config.yaml"
_write_config(
cfg_file,
"""\
schema_version: 1
connection:
host: original-host.example.com
""",
)
monkeypatch.setenv("SYNOLOGY_HOST", "env-host.example.com")
monkeypatch.setenv("SYNOLOGY_PORT", "9999")
config = load_config(cfg_file)
assert config.connection.host == "env-host.example.com"
assert config.connection.port == 9999
def test_base_url_https(tmp_path: Path) -> None:
"""base_url uses https scheme when https=True."""
cfg_file = tmp_path / "config.yaml"
_write_config(
cfg_file,
"""\
schema_version: 1
connection:
host: nas.example.com
port: 443
https: true
""",
)
config = load_config(cfg_file)
assert config.base_url == "https://nas.example.com:443"
def test_base_url_http(tmp_path: Path) -> None:
"""base_url uses http scheme when https=False."""
cfg_file = tmp_path / "config.yaml"
_write_config(
cfg_file,
"""\
schema_version: 1
connection:
host: nas.example.com
port: 5000
https: false
""",
)
config = load_config(cfg_file)
assert config.base_url == "http://nas.example.com:5000"
def test_keyring_service() -> None:
"""keyring_service is always the fixed service name."""
config = AppConfig(
schema_version=1,
connection=ConnectionConfig(host="anything"),
)
assert config.keyring_service == "mcp-synology-filestation"
def test_save_and_reload(tmp_path: Path) -> None:
"""save_config + load_config round-trips successfully."""
config = AppConfig(
schema_version=1,
connection=ConnectionConfig(
host="nas.example.com",
port=443,
https=True,
verify_ssl=False,
timeout=45,
),
alias="test",
)
cfg_file = tmp_path / "config.yaml"
save_config(config, cfg_file)
loaded = load_config(cfg_file)
assert loaded.connection.host == "nas.example.com"
assert loaded.connection.verify_ssl is False
assert loaded.connection.timeout == 45
assert loaded.alias == "test"
+247
View File
@@ -0,0 +1,247 @@
"""Tests for tools/filestation.py: list_shares and list_dir."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from mcp_synology_filestation.client import SynologyError
from mcp_synology_filestation.config import AppConfig, ConnectionConfig
@pytest.fixture()
def config() -> AppConfig:
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.example.com"),
)
def _make_mcp_and_tools(config: AppConfig, client: MagicMock) -> dict:
"""Register FileStation tools on a mock FastMCP and collect them by name."""
from mcp_synology_filestation.tools.filestation import register_filestation
registered: dict[str, object] = {}
mcp = MagicMock()
def tool_decorator():
"""Simulate @mcp.tool() — capture the decorated function."""
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
mcp.tool = tool_decorator
register_filestation(mcp, config, client)
return registered
# ──────────────────────────────────────────────────────────────────────────
# list_shares
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_shares_success(config: AppConfig) -> None:
"""list_shares returns a formatted table on success."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"shares": [
{
"name": "data",
"path": "/data",
"additional": {
"real_path": "/volume1/data",
"volume_status": {
"totalspace": 2_000_000_000,
"usedspace": 500_000_000,
},
},
},
{
"name": "photos",
"path": "/photos",
"additional": {
"real_path": "/volume1/photos",
"volume_status": {},
},
},
]
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_shares"]()
assert "data" in result
assert "/volume1/data" in result
assert "photos" in result
assert "2 share(s) found" in result
# Check usage percentage appears
assert "%" in result
@pytest.mark.asyncio
async def test_list_shares_empty(config: AppConfig) -> None:
"""list_shares returns a friendly message when no shares exist."""
client = MagicMock()
client.request = AsyncMock(return_value={"shares": []})
tools = _make_mcp_and_tools(config, client)
result = await tools["list_shares"]()
assert "No shared folders" in result
@pytest.mark.asyncio
async def test_list_shares_dsm_error(config: AppConfig) -> None:
"""list_shares returns an Error: message on SynologyError."""
client = MagicMock()
client.request = AsyncMock(
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_shares"]()
assert result.startswith("Error:")
assert "Permission denied" in result
# ──────────────────────────────────────────────────────────────────────────
# list_dir
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_dir_success(config: AppConfig) -> None:
"""list_dir returns a formatted table with files and directories."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"total": 3,
"files": [
{
"name": "documents",
"isdir": True,
"additional": {"size": None, "time": {"mtime": 1700000000}},
},
{
"name": "photo.jpg",
"isdir": False,
"additional": {"size": 2_500_000, "time": {"mtime": 1700100000}},
},
{
"name": "readme.txt",
"isdir": False,
"additional": {"size": 1024, "time": {"mtime": 1700200000}},
},
],
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_dir"](path="/volume1/data")
assert "documents" in result
assert "photo.jpg" in result
assert "readme.txt" in result
assert "dir" in result
assert "file" in result
assert "Showing 13 of 3 item(s)" in result
@pytest.mark.asyncio
async def test_list_dir_pagination(config: AppConfig) -> None:
"""list_dir shows pagination hint when more items are available."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"total": 200,
"files": [
{
"name": f"file{i}.txt",
"isdir": False,
"additional": {"size": 100, "time": {"mtime": 1700000000 + i}},
}
for i in range(100)
],
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["list_dir"](path="/volume1/data", limit=100)
assert "Showing 1100 of 200" in result
assert "offset=100" in result
@pytest.mark.asyncio
async def test_list_dir_empty(config: AppConfig) -> None:
"""list_dir returns a friendly message for empty directories."""
client = MagicMock()
client.request = AsyncMock(return_value={"total": 0, "files": []})
tools = _make_mcp_and_tools(config, client)
result = await tools["list_dir"](path="/volume1/empty")
assert "empty" in result.lower() or "does not exist" in result.lower()
@pytest.mark.asyncio
async def test_list_dir_invalid_sort_by(config: AppConfig) -> None:
"""list_dir returns an Error: message for invalid sort_by."""
client = MagicMock()
client.request = AsyncMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["list_dir"](path="/volume1/data", sort_by="invalid_field")
assert result.startswith("Error:")
assert "sort_by" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_list_dir_invalid_sort_direction(config: AppConfig) -> None:
"""list_dir returns an Error: message for invalid sort_direction."""
client = MagicMock()
client.request = AsyncMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["list_dir"](path="/volume1/data", sort_direction="random")
assert result.startswith("Error:")
assert "sort_direction" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_list_dir_limit_clamped(config: AppConfig) -> None:
"""list_dir clamps limit to _MAX_LIMIT (500)."""
client = MagicMock()
client.request = AsyncMock(return_value={"total": 1, "files": []})
tools = _make_mcp_and_tools(config, client)
await tools["list_dir"](path="/volume1/data", limit=9999)
call_params = client.request.call_args[1]["params"]
assert call_params["limit"] == 500
@pytest.mark.asyncio
async def test_list_dir_dsm_error(config: AppConfig) -> None:
"""list_dir returns Error: on SynologyError (e.g. path not found)."""
client = MagicMock()
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
tools = _make_mcp_and_tools(config, client)
result = await tools["list_dir"](path="/volume1/nonexistent")
assert result.startswith("Error:")
assert "not found" in result.lower()