mirror of
https://github.com/b3nw/nginx-proxy-manager-mcp.git
synced 2026-06-09 23:09:40 -05:00
feat: implement multi-server support and sync tools
- Introduced ServerRegistry to manage multiple NPM instances - Added support for NPM_SERVERS JSON environment variable - Updated all tools to support optional 'server' targeting - Implemented clone_proxy_host, sync_access_lists, and sync_certificates tools - Transitioned get_proxy_host_logs to API-based retrieval with local fallback - Added comprehensive test suite for multi-server management and sync operations Co-authored-by: claw-io <agent@ben.io>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
"""Tests for multi-server management and sync tools."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from npm_mcp.models import AccessList, Certificate, HealthStatus, ProxyHost
|
||||
from npm_mcp.server import (
|
||||
clone_proxy_host,
|
||||
get_proxy_host_logs,
|
||||
list_servers,
|
||||
sync_access_lists,
|
||||
sync_certificates,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry():
|
||||
"""Mock registry with prod and dev servers."""
|
||||
reg = MagicMock()
|
||||
reg.list_names.return_value = ["prod", "dev"]
|
||||
reg.get_default.return_value = "prod"
|
||||
return reg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_servers(mock_registry):
|
||||
"""Test list_servers tool output and health query."""
|
||||
client_prod = MagicMock()
|
||||
client_prod.get_status = AsyncMock(
|
||||
return_value=HealthStatus(status="online", version={"major": "2"})
|
||||
)
|
||||
client_dev = MagicMock()
|
||||
client_dev.get_status = AsyncMock(side_effect=Exception("Connection failed"))
|
||||
|
||||
mock_registry.get.side_effect = lambda name: client_prod if name == "prod" else client_dev
|
||||
|
||||
with patch("npm_mcp.server.get_registry", return_value=mock_registry):
|
||||
result_json = await list_servers()
|
||||
result = json.loads(result_json)
|
||||
|
||||
assert result["servers"] == ["prod", "dev"]
|
||||
assert result["default_server"] == "prod"
|
||||
assert result["health"]["prod"]["status"] == "online"
|
||||
assert result["health"]["dev"]["status"] == "error"
|
||||
assert "Connection failed" in result["health"]["dev"]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_proxy_host(mock_registry):
|
||||
"""Test clone_proxy_host tool with cert and access list resolution."""
|
||||
source_client = MagicMock()
|
||||
target_client = MagicMock()
|
||||
|
||||
mock_registry.get.side_effect = lambda name: source_client if name == "prod" else target_client
|
||||
|
||||
# Source host setup
|
||||
source_host = ProxyHost(
|
||||
id=12,
|
||||
created_on="2024-01-01T00:00:00Z",
|
||||
modified_on="2024-01-01T00:00:00Z",
|
||||
owner_user_id=1,
|
||||
domain_names=["test.example.com"],
|
||||
forward_host="192.168.1.50",
|
||||
forward_port=8080,
|
||||
forward_scheme="http",
|
||||
certificate_id=10,
|
||||
access_list_id=5,
|
||||
ssl_forced=True,
|
||||
hsts_enabled=True,
|
||||
hsts_subdomains=False,
|
||||
http2_support=True,
|
||||
block_exploits=True,
|
||||
caching_enabled=False,
|
||||
allow_websocket_upgrade=True,
|
||||
advanced_config="my advanced config",
|
||||
meta={"key": "val"},
|
||||
)
|
||||
source_client.get_proxy_host = AsyncMock(return_value=source_host)
|
||||
|
||||
# Source dependencies setup
|
||||
source_cert = Certificate(
|
||||
id=10,
|
||||
nice_name="wildcard-example",
|
||||
domain_names=["*.example.com"],
|
||||
provider="letsencrypt",
|
||||
)
|
||||
source_client.get_certificate = AsyncMock(return_value=source_cert)
|
||||
|
||||
source_alists = [
|
||||
AccessList(
|
||||
id=5,
|
||||
name="Staging Auth",
|
||||
created_on="2024-01-01T00:00:00Z",
|
||||
modified_on="2024-01-01T00:00:00Z",
|
||||
)
|
||||
]
|
||||
source_client.get_access_lists = AsyncMock(return_value=source_alists)
|
||||
|
||||
# Target dependency search results
|
||||
target_certs = [
|
||||
Certificate(
|
||||
id=100,
|
||||
nice_name="wildcard-example",
|
||||
domain_names=["*.example.com"],
|
||||
provider="letsencrypt",
|
||||
)
|
||||
]
|
||||
target_client.get_certificates = AsyncMock(return_value=target_certs)
|
||||
|
||||
target_alists = [
|
||||
AccessList(
|
||||
id=500,
|
||||
name="Staging Auth",
|
||||
created_on="2024-01-02T00:00:00Z",
|
||||
modified_on="2024-01-02T00:00:00Z",
|
||||
)
|
||||
]
|
||||
target_client.get_access_lists = AsyncMock(return_value=target_alists)
|
||||
|
||||
# Mock creation on target
|
||||
cloned_host = ProxyHost(
|
||||
id=999,
|
||||
created_on="2024-01-03T00:00:00Z",
|
||||
modified_on="2024-01-03T00:00:00Z",
|
||||
owner_user_id=1,
|
||||
domain_names=["test.example.com"],
|
||||
forward_host="192.168.1.50",
|
||||
forward_port=8080,
|
||||
)
|
||||
target_client.create_proxy_host = AsyncMock(return_value=cloned_host)
|
||||
|
||||
with patch("npm_mcp.server.get_registry", return_value=mock_registry):
|
||||
result = await clone_proxy_host(
|
||||
source_server="prod",
|
||||
target_server="dev",
|
||||
host_id=12,
|
||||
override_settings={"forward_host": "10.0.0.10"},
|
||||
)
|
||||
|
||||
assert "Successfully cloned" in result
|
||||
assert "Source Host ID: 12" in result
|
||||
assert "Target Host ID: 999" in result
|
||||
assert "Resolved to ID 100" in result
|
||||
assert "Resolved to ID 500" in result
|
||||
|
||||
target_client.create_proxy_host.assert_called_once_with(
|
||||
domain_names=["test.example.com"],
|
||||
forward_host="10.0.0.10", # Overridden!
|
||||
forward_port=8080,
|
||||
forward_scheme="http",
|
||||
certificate_id=100, # Resolved!
|
||||
ssl_forced=True,
|
||||
hsts_enabled=True,
|
||||
hsts_subdomains=False,
|
||||
http2_support=True,
|
||||
block_exploits=True,
|
||||
caching_enabled=False,
|
||||
allow_websocket_upgrade=True,
|
||||
access_list_id=500, # Resolved!
|
||||
advanced_config="my advanced config",
|
||||
meta={"key": "val"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_access_lists(mock_registry):
|
||||
"""Test sync_access_lists replicates missing access lists with credentials/IPs."""
|
||||
source_client = MagicMock()
|
||||
target_client = MagicMock()
|
||||
|
||||
mock_registry.get.side_effect = lambda name: source_client if name == "prod" else target_client
|
||||
|
||||
# Source returns raw JSON including items & clients
|
||||
source_mock_response = MagicMock()
|
||||
source_mock_response.json.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Staging Auth",
|
||||
"satisfy_any": False,
|
||||
"pass_auth": True,
|
||||
"items": [
|
||||
{"id": 10, "access_list_id": 1, "username": "u", "password": "p"}
|
||||
],
|
||||
"clients": [
|
||||
{"id": 20, "access_list_id": 1, "address": "1.1.1.1", "directive": "allow"}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Already Synced",
|
||||
"satisfy_any": True,
|
||||
"pass_auth": False,
|
||||
}
|
||||
]
|
||||
source_client._request = AsyncMock(return_value=source_mock_response)
|
||||
|
||||
# Target returns raw JSON showing "Already Synced" exists
|
||||
target_mock_response = MagicMock()
|
||||
target_mock_response.json.return_value = [{"id": 99, "name": "Already Synced"}]
|
||||
target_client._request = AsyncMock(return_value=target_mock_response)
|
||||
|
||||
target_client.create_access_list = AsyncMock()
|
||||
|
||||
with patch("npm_mcp.server.get_registry", return_value=mock_registry):
|
||||
result = await sync_access_lists(source_server="prod", target_server="dev")
|
||||
|
||||
assert "Created: Staging Auth" in result
|
||||
assert "Matched (exists): 'Already Synced' (already exists)" in result
|
||||
|
||||
# Verify items and clients were stripped of database IDs
|
||||
target_client.create_access_list.assert_called_once_with(
|
||||
name="Staging Auth",
|
||||
satisfy_any=False,
|
||||
pass_auth=True,
|
||||
items=[{"username": "u", "password": "p"}],
|
||||
clients=[{"address": "1.1.1.1", "directive": "allow"}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_certificates(mock_registry):
|
||||
"""Test sync_certificates provisions Let's Encrypt and skips custom certs."""
|
||||
source_client = MagicMock()
|
||||
target_client = MagicMock()
|
||||
|
||||
mock_registry.get.side_effect = lambda name: source_client if name == "prod" else target_client
|
||||
|
||||
# Source certificates
|
||||
source_certs = [
|
||||
Certificate(
|
||||
id=1,
|
||||
nice_name="le-cert",
|
||||
domain_names=["le.example.com"],
|
||||
provider="letsencrypt",
|
||||
meta={"letsencrypt_email": "le@test.com", "dns_challenge": True},
|
||||
),
|
||||
Certificate(
|
||||
id=2,
|
||||
nice_name="custom-cert",
|
||||
domain_names=["custom.example.com"],
|
||||
provider="other-provider",
|
||||
),
|
||||
Certificate(
|
||||
id=3,
|
||||
nice_name="already-on-target",
|
||||
domain_names=["existing.example.com"],
|
||||
provider="letsencrypt",
|
||||
)
|
||||
]
|
||||
source_client.get_certificates = AsyncMock(return_value=source_certs)
|
||||
|
||||
# Target certificates
|
||||
target_certs = [
|
||||
Certificate(
|
||||
id=10,
|
||||
nice_name="already-on-target",
|
||||
domain_names=["existing.example.com"],
|
||||
provider="letsencrypt",
|
||||
)
|
||||
]
|
||||
target_client.get_certificates = AsyncMock(return_value=target_certs)
|
||||
|
||||
target_client.create_certificate = AsyncMock()
|
||||
|
||||
with patch("npm_mcp.server.get_registry", return_value=mock_registry):
|
||||
result = await sync_certificates(source_server="prod", target_server="dev")
|
||||
|
||||
assert "Provisioned: 'le.example.com'" in result
|
||||
assert "Matched (exists): 'already-on-target'" in result
|
||||
assert "Skipped (manual upload required): 'custom-cert'" in result
|
||||
|
||||
target_client.create_certificate.assert_called_once_with(
|
||||
domain_names=["le.example.com"],
|
||||
email="le@test.com",
|
||||
dns_challenge=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_proxy_host_logs_api(mock_registry):
|
||||
"""Test get_proxy_host_logs tool queries the API for logs first."""
|
||||
client = MagicMock()
|
||||
mock_registry.get.return_value = client
|
||||
|
||||
# Mock host details
|
||||
host = ProxyHost(
|
||||
id=5,
|
||||
created_on="2024-01-01T00:00:00Z",
|
||||
modified_on="2024-01-01T00:00:00Z",
|
||||
owner_user_id=1,
|
||||
domain_names=["test.example.com"],
|
||||
forward_host="192.168.1.50",
|
||||
forward_port=8080,
|
||||
)
|
||||
client.get_proxy_host = AsyncMock(return_value=host)
|
||||
|
||||
# Mock API logs endpoint
|
||||
client.get_proxy_host_logs = AsyncMock(return_value={
|
||||
"lines": ["Log line A", "Log line B", "Filter me out"]
|
||||
})
|
||||
|
||||
with patch("npm_mcp.server.get_registry", return_value=mock_registry):
|
||||
# Retrieve logs with search filter
|
||||
result = await get_proxy_host_logs(
|
||||
host_id=5, log_type="access", lines=10, search="Log line"
|
||||
)
|
||||
|
||||
assert "test.example.com" in result
|
||||
assert "(retrieved via API)" in result
|
||||
assert "Log line A" in result
|
||||
assert "Log line B" in result
|
||||
assert "Filter me out" not in result
|
||||
assert "Showing last 2 lines:" in result
|
||||
Reference in New Issue
Block a user