mirror of
https://github.com/b3nw/nginx-proxy-manager-mcp.git
synced 2026-06-09 23:09:40 -05:00
ddaf4190f9
- 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>
316 lines
10 KiB
Python
316 lines
10 KiB
Python
"""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
|