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:
2026-06-09 19:43:47 +00:00
parent 4a95ccd1b5
commit ddaf4190f9
8 changed files with 1148 additions and 71 deletions
+87 -2
View File
@@ -1,10 +1,9 @@
"""Tests for NpmClient."""
import pytest
from httpx import Response
from npm_mcp.client import NpmClient
from npm_mcp.exceptions import NpmAuthenticationError, NpmConnectionError
from npm_mcp.exceptions import NpmAuthenticationError
@pytest.fixture
@@ -227,3 +226,89 @@ class TestNpmClientEndpoints:
assert host.forward_port == 3000
assert host.ssl_forced is True
assert host.certificate_id == 24
@pytest.mark.asyncio
async def test_create_access_list(self, httpx_mock, mock_token_response):
"""Test creating an access list."""
httpx_mock.add_response(
method="POST",
url="http://localhost:81/api/tokens",
json=mock_token_response,
)
httpx_mock.add_response(
method="POST",
url="http://localhost:81/api/nginx/access-lists",
json={
"id": 5,
"created_on": "2024-01-01T00:00:00Z",
"modified_on": "2024-01-01T00:00:00Z",
"owner_user_id": 1,
"name": "Custom List",
"satisfy_any": True,
"pass_auth": False,
},
status_code=201,
)
async with NpmClient(
base_url="http://localhost:81/api",
identity="test@test.com",
secret="password",
) as client:
al = await client.create_access_list(
name="Custom List",
satisfy_any=True,
pass_auth=False,
items=[{"username": "u", "password": "p"}],
clients=[{"address": "1.1.1.1", "directive": "allow"}],
)
assert al.id == 5
assert al.name == "Custom List"
assert al.satisfy_any is True
assert al.pass_auth is False
@pytest.mark.asyncio
async def test_get_proxy_host_logs(self, httpx_mock, mock_token_response):
"""Test fetching proxy host logs via API."""
httpx_mock.add_response(
method="POST",
url="http://localhost:81/api/tokens",
json=mock_token_response,
)
httpx_mock.add_response(
method="GET",
url="http://localhost:81/api/nginx/proxy-hosts/42/logs?type=access&limit=50",
json={"lines": ["line 1", "line 2"]},
)
async with NpmClient(
base_url="http://localhost:81/api",
identity="test@test.com",
secret="password",
) as client:
logs = await client.get_proxy_host_logs(host_id=42, log_type="access", lines=50)
assert logs == {"lines": ["line 1", "line 2"]}
@pytest.mark.asyncio
async def test_get_proxy_host_logs_summary(self, httpx_mock, mock_token_response):
"""Test fetching proxy host logs summary via API."""
httpx_mock.add_response(
method="POST",
url="http://localhost:81/api/tokens",
json=mock_token_response,
)
httpx_mock.add_response(
method="GET",
url="http://localhost:81/api/nginx/proxy-hosts/42/logs/summary",
json={"access": 100, "error": 5},
)
async with NpmClient(
base_url="http://localhost:81/api",
identity="test@test.com",
secret="password",
) as client:
summary = await client.get_proxy_host_logs_summary(host_id=42)
assert summary == {"access": 100, "error": 5}
+38
View File
@@ -89,3 +89,41 @@ class TestProxyDefaults:
# Other defaults preserved
assert defaults["ssl_forced"] is True
assert defaults["block_exploits"] is True
class TestMultiServerConfig:
"""Test multi-server configuration parsing."""
def test_servers_empty_by_default(self):
"""Test that servers is empty by default."""
s = Settings(identity="test", secret="test")
assert s.servers == []
assert s.default_server is None
def test_servers_json_parsing(self, monkeypatch):
"""Test parsing servers JSON list from environment variable."""
monkeypatch.setenv("NPM_IDENTITY", "test")
monkeypatch.setenv("NPM_SECRET", "test")
monkeypatch.setenv(
"NPM_SERVERS",
'[{"name": "prod", "url": "http://prod:81/api", "identity": "p", "secret": "ps"}, '
'{"name": "dev", "url": "http://dev:81/api", "identity": "d", "secret": "ds"}]'
)
monkeypatch.setenv("NPM_DEFAULT_SERVER", "prod")
s = Settings()
assert len(s.servers) == 2
assert s.servers[0]["name"] == "prod"
assert s.servers[0]["url"] == "http://prod:81/api"
assert s.servers[1]["name"] == "dev"
assert s.default_server == "prod"
def test_servers_invalid_json_raises(self, monkeypatch):
"""Test that invalid servers JSON raises SettingsError."""
monkeypatch.setenv("NPM_IDENTITY", "test")
monkeypatch.setenv("NPM_SECRET", "test")
monkeypatch.setenv("NPM_SERVERS", "{not valid}")
with pytest.raises(SettingsError):
Settings()
+97
View File
@@ -0,0 +1,97 @@
"""Tests for ServerRegistry."""
import pytest
from npm_mcp.config import settings
from npm_mcp.server import ServerRegistry
def test_registry_fallback_to_single_server(monkeypatch):
"""Test that registry falls back to single-server settings when empty."""
monkeypatch.setattr(settings, "api_url", "http://test-url:81/api")
monkeypatch.setattr(settings, "identity", "test-user")
monkeypatch.setattr(settings, "secret", "test-pass")
registry = ServerRegistry(configs=[], default=None)
assert registry.list_names() == ["default"]
assert registry.get_default() == "default"
client = registry.get()
assert client.base_url == "http://test-url:81/api"
assert client._identity == "test-user"
def test_registry_multiple_servers():
"""Test that multiple servers are correctly registered."""
configs = [
{"name": "prod", "url": "http://prod:81/api", "identity": "p", "secret": "ps"},
{"name": "dev", "url": "http://dev:81/api", "identity": "d", "secret": "ds"},
]
registry = ServerRegistry(configs=configs, default="prod")
assert set(registry.list_names()) == {"prod", "dev"}
assert registry.get_default() == "prod"
prod_client = registry.get("prod")
assert prod_client.base_url == "http://prod:81/api"
dev_client = registry.get("dev")
assert dev_client.base_url == "http://dev:81/api"
def test_registry_get_default_fallback():
"""Test that get() falls back to default server when name is None/empty."""
configs = [
{"name": "prod", "url": "http://prod:81/api", "identity": "p", "secret": "ps"},
{"name": "dev", "url": "http://dev:81/api", "identity": "d", "secret": "ds"},
]
registry = ServerRegistry(configs=configs, default="dev")
# Name is None
client = registry.get(None)
assert client.base_url == "http://dev:81/api"
# Name is empty string
client_empty = registry.get("")
assert client_empty.base_url == "http://dev:81/api"
def test_registry_single_client_no_default_specified():
"""Test that get() succeeds if there is only 1 server, even if no default is specified."""
configs = [
{"name": "only-one", "url": "http://only:81/api", "identity": "o", "secret": "os"}
]
registry = ServerRegistry(configs=configs, default=None)
assert registry.get_default() is None
client = registry.get()
assert client.base_url == "http://only:81/api"
def test_registry_multiple_clients_no_default_raises():
"""Test that get() raises KeyError if multiple servers are defined but no default is set."""
configs = [
{"name": "prod", "url": "http://prod:81/api", "identity": "p", "secret": "ps"},
{"name": "dev", "url": "http://dev:81/api", "identity": "d", "secret": "ds"},
]
registry = ServerRegistry(configs=configs, default=None)
with pytest.raises(KeyError, match="Multiple servers configured but no default server"):
registry.get()
def test_registry_invalid_name_raises():
"""Test that get() raises KeyError for non-existent server names."""
configs = [
{"name": "prod", "url": "http://prod:81/api", "identity": "p", "secret": "ps"},
]
registry = ServerRegistry(configs=configs, default="prod")
with pytest.raises(KeyError, match="Server 'non-existent' not found"):
registry.get("non-existent")
+315
View File
@@ -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