Initial implementation
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""Tests for auth.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from mcp_synology_container.auth import AuthManager, AuthenticationError
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
|
||||
def make_config(host: str = "nas.local") -> AppConfig:
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host=host, port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_credentials_from_env(monkeypatch):
|
||||
monkeypatch.setenv("SYNOLOGY_USERNAME", "admin")
|
||||
monkeypatch.setenv("SYNOLOGY_PASSWORD", "secret")
|
||||
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
username, password, device_token = auth.resolve_credentials()
|
||||
|
||||
assert username == "admin"
|
||||
assert password == "secret"
|
||||
assert device_token is None
|
||||
|
||||
|
||||
def test_resolve_credentials_no_credentials(monkeypatch):
|
||||
monkeypatch.delenv("SYNOLOGY_USERNAME", raising=False)
|
||||
monkeypatch.delenv("SYNOLOGY_PASSWORD", raising=False)
|
||||
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
with patch("keyring.get_password", return_value=None):
|
||||
with pytest.raises(AuthenticationError, match="No credentials found"):
|
||||
auth.resolve_credentials()
|
||||
|
||||
|
||||
def test_resolve_credentials_from_keyring(monkeypatch):
|
||||
monkeypatch.delenv("SYNOLOGY_USERNAME", raising=False)
|
||||
monkeypatch.delenv("SYNOLOGY_PASSWORD", raising=False)
|
||||
|
||||
def mock_get_password(service, key):
|
||||
data = {"username": "keyring_user", "password": "keyring_pass", "device_token": "tok123"}
|
||||
return data.get(key)
|
||||
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
with patch("keyring.get_password", side_effect=mock_get_password):
|
||||
username, password, device_token = auth.resolve_credentials()
|
||||
|
||||
assert username == "keyring_user"
|
||||
assert password == "keyring_pass"
|
||||
assert device_token == "tok123"
|
||||
|
||||
|
||||
def test_store_credentials_success():
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
with patch("keyring.set_password") as mock_set:
|
||||
result = auth.store_credentials("user", "pass")
|
||||
|
||||
assert result is True
|
||||
assert mock_set.call_count == 2
|
||||
|
||||
|
||||
def test_store_credentials_keyring_unavailable():
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
with patch("keyring.set_password", side_effect=Exception("no keyring")):
|
||||
result = auth.store_credentials("user", "pass")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success():
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = {"sid": "test_session_id"}
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
|
||||
sid = await auth.login(mock_client)
|
||||
|
||||
assert sid == "test_session_id"
|
||||
mock_client.request.assert_called_once()
|
||||
call_kwargs = mock_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_with_device_token():
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = {"sid": "test_sid"}
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", "dev_token")):
|
||||
sid = await auth.login(mock_client)
|
||||
|
||||
assert sid == "test_sid"
|
||||
params = mock_client.request.call_args[1]["params"]
|
||||
assert params["device_id"] == "dev_token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_2fa_required():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.side_effect = SynologyError("2FA required", code=403)
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
|
||||
with pytest.raises(AuthenticationError, match="2FA is required"):
|
||||
await auth.login(mock_client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_no_sid_returned():
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = {} # No 'sid' key
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
|
||||
with pytest.raises(AuthenticationError, match="no session ID"):
|
||||
await auth.login(mock_client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout():
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.sid = "active_sid"
|
||||
|
||||
await auth.logout(mock_client)
|
||||
|
||||
mock_client.request.assert_called_once()
|
||||
assert mock_client.sid is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_no_session():
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.sid = None
|
||||
|
||||
await auth.logout(mock_client)
|
||||
mock_client.request.assert_not_called()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Tests for config.py."""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_synology_container.config import (
|
||||
AppConfig,
|
||||
ConnectionConfig,
|
||||
_validate_config,
|
||||
_merge_env_overrides,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_minimal():
|
||||
raw = {
|
||||
"schema_version": 1,
|
||||
"connection": {"host": "nas.local"},
|
||||
}
|
||||
config = _validate_config(raw)
|
||||
assert config.connection.host == "nas.local"
|
||||
assert config.connection.port == 443 # default when https=True
|
||||
assert config.connection.https is True
|
||||
assert config.connection.verify_ssl is True
|
||||
assert config.compose_base_path == "/volume1/docker"
|
||||
assert config.alias is None
|
||||
|
||||
|
||||
def test_validate_config_full():
|
||||
raw = {
|
||||
"schema_version": 1,
|
||||
"alias": "HomeNAS",
|
||||
"compose_base_path": "/volume2/containers",
|
||||
"connection": {
|
||||
"host": "192.168.1.100",
|
||||
"port": 5001,
|
||||
"https": True,
|
||||
"verify_ssl": False,
|
||||
},
|
||||
}
|
||||
config = _validate_config(raw)
|
||||
assert config.alias == "HomeNAS"
|
||||
assert config.compose_base_path == "/volume2/containers"
|
||||
assert config.connection.host == "192.168.1.100"
|
||||
assert config.connection.port == 5001
|
||||
assert config.connection.verify_ssl is False
|
||||
|
||||
|
||||
def test_validate_config_wrong_schema_version():
|
||||
raw = {
|
||||
"schema_version": 99,
|
||||
"connection": {"host": "nas.local"},
|
||||
}
|
||||
with pytest.raises(ValueError, match="schema_version"):
|
||||
_validate_config(raw)
|
||||
|
||||
|
||||
def test_validate_config_missing_host():
|
||||
raw = {
|
||||
"schema_version": 1,
|
||||
"connection": {},
|
||||
}
|
||||
with pytest.raises(ValueError, match="connection.host"):
|
||||
_validate_config(raw)
|
||||
|
||||
|
||||
def test_validate_config_missing_connection():
|
||||
raw = {"schema_version": 1}
|
||||
with pytest.raises(ValueError, match="connection.host"):
|
||||
_validate_config(raw)
|
||||
|
||||
|
||||
def test_merge_env_overrides_host(monkeypatch):
|
||||
monkeypatch.setenv("SYNOLOGY_HOST", "192.168.1.50")
|
||||
raw: dict = {"schema_version": 1, "connection": {}}
|
||||
result = _merge_env_overrides(raw)
|
||||
assert result["connection"]["host"] == "192.168.1.50"
|
||||
|
||||
|
||||
def test_merge_env_overrides_port(monkeypatch):
|
||||
monkeypatch.setenv("SYNOLOGY_PORT", "8080")
|
||||
raw: dict = {"schema_version": 1, "connection": {}}
|
||||
result = _merge_env_overrides(raw)
|
||||
assert result["connection"]["port"] == 8080 # coerced to int
|
||||
|
||||
|
||||
def test_merge_env_overrides_https_true(monkeypatch):
|
||||
monkeypatch.setenv("SYNOLOGY_HTTPS", "true")
|
||||
raw: dict = {"schema_version": 1, "connection": {}}
|
||||
result = _merge_env_overrides(raw)
|
||||
assert result["connection"]["https"] is True
|
||||
|
||||
|
||||
def test_merge_env_overrides_https_false(monkeypatch):
|
||||
monkeypatch.setenv("SYNOLOGY_HTTPS", "false")
|
||||
raw: dict = {"schema_version": 1, "connection": {}}
|
||||
result = _merge_env_overrides(raw)
|
||||
assert result["connection"]["https"] is False
|
||||
|
||||
|
||||
def test_base_url_https():
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
assert config.base_url == "https://nas.local:443"
|
||||
|
||||
|
||||
def test_base_url_http():
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="192.168.1.1", port=5000, https=False, verify_ssl=True),
|
||||
)
|
||||
assert config.base_url == "http://192.168.1.1:5000"
|
||||
|
||||
|
||||
def test_keyring_service():
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="mynas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
assert config.keyring_service == "mcp-synology-container/mynas.local"
|
||||
|
||||
|
||||
def test_load_config_file_not_found():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config("/nonexistent/path/config.yaml")
|
||||
|
||||
|
||||
def test_save_and_load_config(tmp_path):
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(
|
||||
host="test.nas.local",
|
||||
port=5001,
|
||||
https=True,
|
||||
verify_ssl=False,
|
||||
),
|
||||
compose_base_path="/data/docker",
|
||||
alias="TestNAS",
|
||||
)
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
save_config(config, config_file)
|
||||
|
||||
assert config_file.exists()
|
||||
loaded = load_config(config_file)
|
||||
|
||||
assert loaded.connection.host == "test.nas.local"
|
||||
assert loaded.connection.port == 5001
|
||||
assert loaded.connection.https is True
|
||||
assert loaded.connection.verify_ssl is False
|
||||
assert loaded.compose_base_path == "/data/docker"
|
||||
assert loaded.alias == "TestNAS"
|
||||
|
||||
|
||||
def test_save_config_no_alias(tmp_path):
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
save_config(config, config_file)
|
||||
|
||||
raw = yaml.safe_load(config_file.read_text())
|
||||
assert "alias" not in raw
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Tests for modules/compose.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
compose_base_path="/volume1/docker",
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_COMPOSE = """
|
||||
services:
|
||||
web:
|
||||
image: nginx:1.24
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: mydb
|
||||
POSTGRES_USER: admin
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_compose():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
# Simulate FileStation.Info success for the first filename
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["read_compose"]("myapp")
|
||||
assert "nginx:1.24" in result
|
||||
assert "postgres:15" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_compose_not_found():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
# Simulate all FileStation.Info calls failing
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
client.request.side_effect = SynologyError("not found", code=408)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["read_compose"]("nonexistent")
|
||||
assert "No compose file found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_requires_confirmation():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
assert "nginx:1.24" in result
|
||||
assert "nginx:1.25" in result
|
||||
client.upload_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_confirmed():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=True)
|
||||
assert "nginx:1.24 → nginx:1.25" in result
|
||||
assert "redeploy_project" in result
|
||||
client.upload_text.assert_called_once()
|
||||
|
||||
# Verify the uploaded content has the new tag
|
||||
uploaded_content = client.upload_text.call_args[0][2]
|
||||
parsed = yaml.safe_load(uploaded_content)
|
||||
assert parsed["services"]["web"]["image"] == "nginx:1.25"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_service_not_found():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("myapp", "nonexistent", "1.25", confirmed=True)
|
||||
assert "not found" in result
|
||||
assert "web" in result # should list available services
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_env_var_new_var_list_format():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_env_var"]("myapp", "web", "NEW_VAR", "value123", confirmed=True)
|
||||
assert "NEW_VAR=value123" in result
|
||||
|
||||
uploaded_content = client.upload_text.call_args[0][2]
|
||||
parsed = yaml.safe_load(uploaded_content)
|
||||
env = parsed["services"]["web"]["environment"]
|
||||
assert any("NEW_VAR=value123" in str(e) for e in env)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_env_var_update_existing_list():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_env_var"]("myapp", "web", "LOG_LEVEL", "debug", confirmed=True)
|
||||
assert "LOG_LEVEL=debug" in result
|
||||
|
||||
uploaded_content = client.upload_text.call_args[0][2]
|
||||
parsed = yaml.safe_load(uploaded_content)
|
||||
env = parsed["services"]["web"]["environment"]
|
||||
assert "LOG_LEVEL=debug" in env
|
||||
assert "LOG_LEVEL=info" not in env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_env_var_dict_format():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
# db service has dict-format environment
|
||||
result = await tools["update_env_var"]("myapp", "db", "POSTGRES_DB", "newdb", confirmed=True)
|
||||
assert "POSTGRES_DB=newdb" in result
|
||||
|
||||
uploaded_content = client.upload_text.call_args[0][2]
|
||||
parsed = yaml.safe_load(uploaded_content)
|
||||
assert parsed["services"]["db"]["environment"]["POSTGRES_DB"] == "newdb"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_compose_invalid_yaml():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_compose"]("myapp", "not: valid: yaml: {{{{", confirmed=True)
|
||||
assert "Invalid YAML" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_compose_missing_services_key():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_compose"]("myapp", "version: '3'\n", confirmed=True)
|
||||
assert "services" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_compose_requires_confirmation():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
client.upload_text.assert_not_called()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for modules/containers.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_CONTAINERS_DATA = {
|
||||
"containers": [
|
||||
{
|
||||
"name": "myapp_web",
|
||||
"status": "running",
|
||||
"image": "nginx:alpine",
|
||||
"project_name": "myapp",
|
||||
},
|
||||
{
|
||||
"name": "myapp_db",
|
||||
"status": "running",
|
||||
"image": "postgres:15",
|
||||
"project_name": "myapp",
|
||||
},
|
||||
{
|
||||
"name": "other_svc",
|
||||
"status": "stopped",
|
||||
"image": "redis:7",
|
||||
"project_name": "other",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_LOGS_DATA = {
|
||||
"logs": [
|
||||
{
|
||||
"created": "2025-01-01T10:00:00Z",
|
||||
"stream": "stdout",
|
||||
"text": "Server started",
|
||||
"docid": "1",
|
||||
},
|
||||
{
|
||||
"created": "2025-01-01T10:00:01Z",
|
||||
"stream": "stderr",
|
||||
"text": "Warning: deprecated option",
|
||||
"docid": "2",
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_containers_all():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_CONTAINERS_DATA
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_containers"]()
|
||||
assert "myapp_web" in result
|
||||
assert "myapp_db" in result
|
||||
assert "other_svc" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_containers_filtered_by_project():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_CONTAINERS_DATA
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_containers"](project_name="myapp")
|
||||
assert "myapp_web" in result
|
||||
assert "myapp_db" in result
|
||||
assert "other_svc" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_containers_empty():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {"containers": []}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_containers"]()
|
||||
assert "No containers found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_logs():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_LOGS_DATA
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["get_container_logs"]("myapp_web", tail=50)
|
||||
assert "myapp_web" in result
|
||||
assert "Server started" in result
|
||||
assert "Warning: deprecated option" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_logs_with_keyword():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_LOGS_DATA
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
await tools["get_container_logs"]("myapp_web", tail=100, keyword="error")
|
||||
call_params = client.request.call_args[1]["params"]
|
||||
assert call_params["keyword"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_container_requires_confirmation():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_container_confirmed():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {"output": "file1.py\nfile2.py", "exit_code": 0}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True)
|
||||
assert "file1.py" in result
|
||||
assert "Exit code: 0" in result
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for modules/images.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_IMAGES = {
|
||||
"images": [
|
||||
{
|
||||
"id": "sha256:aaaa",
|
||||
"repository": "nginx",
|
||||
"tags": ["1.24"],
|
||||
"size": 50 * 1024 * 1024,
|
||||
"upgradable": True,
|
||||
},
|
||||
{
|
||||
"id": "sha256:bbbb",
|
||||
"repository": "postgres",
|
||||
"tags": ["15"],
|
||||
"size": 80 * 1024 * 1024,
|
||||
"upgradable": False,
|
||||
},
|
||||
{
|
||||
"id": "sha256:cccc",
|
||||
"repository": "redis",
|
||||
"tags": ["7"],
|
||||
"size": 30 * 1024 * 1024,
|
||||
"upgradable": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_all():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_IMAGES
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["check_image_updates"]()
|
||||
assert "nginx:1.24" in result
|
||||
assert "UPDATE AVAILABLE" in result
|
||||
assert "postgres:15" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_all_up_to_date():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"], "size": 50 * 1024 * 1024, "upgradable": False},
|
||||
]
|
||||
}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["check_image_updates"]()
|
||||
assert "All images are up to date" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_no_images():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {"images": []}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["check_image_updates"]()
|
||||
assert "No images found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_api_error():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API unavailable", code=102)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["check_image_updates"]()
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_for_project():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
project_list = {
|
||||
"uuid-1": {
|
||||
"id": "uuid-1",
|
||||
"name": "myapp",
|
||||
"status": "RUNNING",
|
||||
"containerIds": ["abc123"],
|
||||
}
|
||||
}
|
||||
project_detail = {
|
||||
"containers": [
|
||||
{"Image": "sha256:aaaa", "Config": {"Image": "nginx:1.24"}},
|
||||
]
|
||||
}
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Project" and method == "list":
|
||||
return project_list
|
||||
if api == "SYNO.Docker.Project" and method == "get":
|
||||
return project_detail
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["check_image_updates"](project_name="myapp")
|
||||
assert "myapp" in result
|
||||
assert "nginx:1.24" in result
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for modules/projects.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from mcp_synology_container.modules.projects import _find_project, _format_project_detail
|
||||
|
||||
|
||||
SAMPLE_PROJECTS = {
|
||||
"uuid-1": {
|
||||
"id": "uuid-1",
|
||||
"name": "myapp",
|
||||
"status": "RUNNING",
|
||||
"path": "/volume1/docker/myapp",
|
||||
"share_path": "/docker/myapp",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-02T00:00:00Z",
|
||||
"containerIds": ["abc123def456"],
|
||||
"services": [{"display_name": "myapp (project)"}],
|
||||
},
|
||||
"uuid-2": {
|
||||
"id": "uuid-2",
|
||||
"name": "database",
|
||||
"status": "STOPPED",
|
||||
"path": "/volume1/docker/database",
|
||||
"share_path": "/docker/database",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"containerIds": [],
|
||||
"services": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_project_found():
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_PROJECTS
|
||||
|
||||
result = await _find_project(client, "myapp")
|
||||
assert result is not None
|
||||
assert result["name"] == "myapp"
|
||||
assert result["status"] == "RUNNING"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_project_not_found():
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_PROJECTS
|
||||
|
||||
result = await _find_project(client, "nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_project_api_error():
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = Exception("API error")
|
||||
|
||||
result = await _find_project(client, "myapp")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_format_project_detail():
|
||||
project = SAMPLE_PROJECTS["uuid-1"]
|
||||
output = _format_project_detail(project)
|
||||
|
||||
assert "myapp" in output
|
||||
assert "RUNNING" in output
|
||||
assert "/volume1/docker/myapp" in output
|
||||
assert "uuid-1" in output
|
||||
|
||||
|
||||
def test_format_project_detail_no_containers():
|
||||
project = SAMPLE_PROJECTS["uuid-2"]
|
||||
output = _format_project_detail(project)
|
||||
|
||||
assert "database" in output
|
||||
assert "STOPPED" in output
|
||||
assert "Containers: 0" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_tool():
|
||||
"""Test list_projects tool via function registration."""
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_PROJECTS
|
||||
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
register_projects(MockMCP(), config, client)
|
||||
assert "list_projects" in tools
|
||||
|
||||
result = await tools["list_projects"]()
|
||||
assert "myapp" in result
|
||||
assert "database" in result
|
||||
assert "RUNNING" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_project_requires_confirmation():
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
client = AsyncMock()
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
register_projects(MockMCP(), config, client)
|
||||
|
||||
result = await tools["stop_project"]("myapp", confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_project_requires_confirmation():
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
client = AsyncMock()
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
register_projects(MockMCP(), config, client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
Reference in New Issue
Block a user