22-tool MCP server for Node-RED flow management with Pydantic models, incremental flow patching, layout linting, and flexible auth (token, basic, OAuth2). 137 tests, full ruff compliance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
423 lines
16 KiB
Python
423 lines
16 KiB
Python
"""Tests for NodeRedClient."""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from nodered_mcp.client import NodeRedClient, get_client, reset_client
|
|
|
|
|
|
class TestNodeRedClient:
|
|
def test_init_without_token(self) -> None:
|
|
client = NodeRedClient(base_url="http://localhost:1880", api_version="v1")
|
|
assert client._http.base_url == httpx.URL("http://localhost:1880")
|
|
assert client._http.headers["Node-RED-API-Version"] == "v1"
|
|
assert "Authorization" not in client._http.headers
|
|
|
|
def test_init_with_token(self) -> None:
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
token="test-token",
|
|
api_version="v1",
|
|
)
|
|
assert client._http.headers["Authorization"] == "Bearer test-token"
|
|
assert client._http.headers["Node-RED-API-Version"] == "v1"
|
|
|
|
def test_init_with_custom_api_version(self) -> None:
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
api_version="v2",
|
|
)
|
|
assert client._http.headers["Node-RED-API-Version"] == "v2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_success_with_json(self) -> None:
|
|
from unittest.mock import MagicMock
|
|
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"id": "test"}
|
|
|
|
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response):
|
|
result = await client._request("GET", "/flows")
|
|
assert result == {"id": "test"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_204_no_content(self) -> None:
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 204
|
|
|
|
with patch.object(client._http, "request", return_value=mock_response):
|
|
result = await client._request("DELETE", "/flow/test")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_400_error(self) -> None:
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 400
|
|
mock_response.text = "Bad Request"
|
|
|
|
with patch.object(client._http, "request", return_value=mock_response):
|
|
with pytest.raises(RuntimeError, match="Node-RED API error 400: Bad Request"):
|
|
await client._request("GET", "/flows")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_404_error(self) -> None:
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 404
|
|
mock_response.text = "Not Found"
|
|
|
|
with patch.object(client._http, "request", return_value=mock_response):
|
|
with pytest.raises(RuntimeError, match="Node-RED API error 404: Not Found"):
|
|
await client._request("GET", "/flow/nonexistent")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_500_error(self) -> None:
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 500
|
|
mock_response.text = "Internal Server Error"
|
|
|
|
with patch.object(client._http, "request", return_value=mock_response):
|
|
with pytest.raises(RuntimeError, match="Node-RED API error 500"):
|
|
await client._request("POST", "/flows")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_with_data(self) -> None:
|
|
from unittest.mock import MagicMock
|
|
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"success": True}
|
|
|
|
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response) as mock_req:
|
|
await client._request("POST", "/flow", data={"label": "Test"})
|
|
mock_req.assert_called_once_with(
|
|
"POST", "/flow", json={"label": "Test"}, headers={}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_with_extra_headers(self) -> None:
|
|
from unittest.mock import MagicMock
|
|
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {}
|
|
|
|
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response) as mock_req:
|
|
await client._request(
|
|
"POST", "/flows", data=[], headers={"Node-RED-Deployment-Type": "full"}
|
|
)
|
|
mock_req.assert_called_once_with(
|
|
"POST",
|
|
"/flows",
|
|
json=[],
|
|
headers={"Node-RED-Deployment-Type": "full"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_flows_passes_deployment_type_header(self) -> None:
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 204
|
|
|
|
with patch.object(client._http, "request", return_value=mock_response) as mock_req:
|
|
await client.update_flows(flows=[{"id": "test"}], deployment_type="nodes")
|
|
mock_req.assert_called_once()
|
|
call_args = mock_req.call_args
|
|
assert call_args[1]["headers"]["Node-RED-Deployment-Type"] == "nodes"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close(self) -> None:
|
|
client = NodeRedClient(base_url="http://localhost:1880")
|
|
with patch.object(client._http, "aclose", new_callable=AsyncMock) as mock_close:
|
|
await client.close()
|
|
mock_close.assert_called_once()
|
|
|
|
|
|
class TestGetClient:
|
|
@pytest.mark.asyncio
|
|
async def test_get_client_returns_singleton(self) -> None:
|
|
# Reset to ensure clean state
|
|
await reset_client()
|
|
c1 = get_client()
|
|
c2 = get_client()
|
|
assert c1 is c2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_client_returns_noderedclient(self) -> None:
|
|
await reset_client()
|
|
client = get_client()
|
|
assert isinstance(client, NodeRedClient)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_client_uses_config(self) -> None:
|
|
with patch("nodered_mcp.client.get_config") as mock_get_config:
|
|
mock_config = AsyncMock()
|
|
mock_config.url = "http://test:1880"
|
|
mock_config.token = "test-token"
|
|
mock_config.api_version = "v1"
|
|
mock_config.auth_method = "token"
|
|
mock_config.auth_username = ""
|
|
mock_config.auth_password = ""
|
|
mock_config.oauth_token_url = ""
|
|
mock_config.oauth_client_id = ""
|
|
mock_get_config.return_value = mock_config
|
|
|
|
await reset_client()
|
|
client = get_client()
|
|
assert client._http.base_url == httpx.URL("http://test:1880")
|
|
assert client._http.headers["Authorization"] == "Bearer test-token"
|
|
|
|
|
|
class TestResetClient:
|
|
@pytest.mark.asyncio
|
|
async def test_reset_client_returns_fresh_instance(self) -> None:
|
|
c1 = get_client()
|
|
c2 = await reset_client()
|
|
assert c1 is not c2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_client_closes_old_client(self) -> None:
|
|
client = get_client()
|
|
with patch.object(client, "close", new_callable=AsyncMock) as mock_close:
|
|
await reset_client()
|
|
mock_close.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_client_returns_noderedclient(self) -> None:
|
|
client = await reset_client()
|
|
assert isinstance(client, NodeRedClient)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_client_updates_singleton(self) -> None:
|
|
await reset_client()
|
|
c1 = get_client()
|
|
c2 = await reset_client()
|
|
c3 = get_client()
|
|
# After reset, get_client should return the new instance
|
|
assert c1 is not c3
|
|
assert c2 is c3
|
|
|
|
|
|
class TestBasicAuth:
|
|
def test_init_basic_auth(self) -> None:
|
|
"""Basic auth sets httpx.BasicAuth on the client."""
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="basic",
|
|
auth_username="user",
|
|
auth_password="secret",
|
|
)
|
|
assert client._auth_method == "basic"
|
|
assert client._http._auth is not None
|
|
# No static Authorization header — BasicAuth sends it per-request
|
|
assert "Authorization" not in client._http.headers
|
|
|
|
def test_init_basic_auth_no_token_header(self) -> None:
|
|
"""Basic auth mode ignores static token."""
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="basic",
|
|
auth_username="user",
|
|
auth_password="pass",
|
|
token="should-be-ignored",
|
|
)
|
|
assert "Authorization" not in client._http.headers
|
|
|
|
|
|
class TestOAuthAuth:
|
|
def test_init_oauth_no_initial_auth(self) -> None:
|
|
"""OAuth mode has no Authorization header at init."""
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="oauth",
|
|
auth_username="user",
|
|
auth_password="pass",
|
|
oauth_token_url="https://auth.example.com/application/o/token/",
|
|
oauth_client_id="my-client-id",
|
|
)
|
|
assert client._auth_method == "oauth"
|
|
assert client._oauth_token is None
|
|
assert "Authorization" not in client._http.headers
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_token_fetch(self) -> None:
|
|
"""OAuth fetches token on first request."""
|
|
from unittest.mock import MagicMock
|
|
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="oauth",
|
|
auth_username="user",
|
|
auth_password="pass",
|
|
oauth_token_url="https://auth.example.com/token",
|
|
oauth_client_id="client-id",
|
|
)
|
|
|
|
# Mock the token endpoint response
|
|
token_response = MagicMock()
|
|
token_response.status_code = 200
|
|
token_response.json.return_value = {
|
|
"access_token": "jwt-token-123",
|
|
"expires_in": 600,
|
|
}
|
|
|
|
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
|
mock_auth_client = AsyncMock()
|
|
mock_auth_client.post.return_value = token_response
|
|
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
|
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_auth_cls.return_value = mock_auth_client
|
|
|
|
await client._ensure_oauth_token()
|
|
|
|
assert client._oauth_token == "jwt-token-123"
|
|
assert client._oauth_expiry > 0
|
|
assert client._http.headers["Authorization"] == "Bearer jwt-token-123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_token_cached(self) -> None:
|
|
"""OAuth doesn't re-fetch valid token."""
|
|
import time
|
|
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="oauth",
|
|
auth_username="user",
|
|
auth_password="pass",
|
|
oauth_token_url="https://auth.example.com/token",
|
|
oauth_client_id="client-id",
|
|
)
|
|
# Pre-set a valid token
|
|
client._oauth_token = "cached-token"
|
|
client._oauth_expiry = time.time() + 300 # 5 min from now
|
|
|
|
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_cls:
|
|
await client._ensure_oauth_token()
|
|
# Should NOT have created a new client (no fetch needed)
|
|
mock_cls.assert_not_called()
|
|
|
|
assert client._oauth_token == "cached-token"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_token_refresh_when_expired(self) -> None:
|
|
"""OAuth re-fetches when token is expired."""
|
|
from unittest.mock import MagicMock
|
|
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="oauth",
|
|
auth_username="user",
|
|
auth_password="pass",
|
|
oauth_token_url="https://auth.example.com/token",
|
|
oauth_client_id="client-id",
|
|
)
|
|
# Set an expired token
|
|
client._oauth_token = "old-token"
|
|
client._oauth_expiry = 0.0
|
|
|
|
token_response = MagicMock()
|
|
token_response.status_code = 200
|
|
token_response.json.return_value = {
|
|
"access_token": "new-token",
|
|
"expires_in": 600,
|
|
}
|
|
|
|
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
|
mock_auth_client = AsyncMock()
|
|
mock_auth_client.post.return_value = token_response
|
|
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
|
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_auth_cls.return_value = mock_auth_client
|
|
|
|
await client._ensure_oauth_token()
|
|
|
|
assert client._oauth_token == "new-token"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_token_error(self) -> None:
|
|
"""OAuth raises RuntimeError on token endpoint failure."""
|
|
from unittest.mock import MagicMock
|
|
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="oauth",
|
|
auth_username="user",
|
|
auth_password="pass",
|
|
oauth_token_url="https://auth.example.com/token",
|
|
oauth_client_id="client-id",
|
|
)
|
|
|
|
error_response = MagicMock()
|
|
error_response.status_code = 401
|
|
error_response.text = "invalid_client"
|
|
|
|
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
|
mock_auth_client = AsyncMock()
|
|
mock_auth_client.post.return_value = error_response
|
|
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
|
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_auth_cls.return_value = mock_auth_client
|
|
|
|
with pytest.raises(RuntimeError, match="OAuth token error 401"):
|
|
await client._ensure_oauth_token()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_401_retry(self) -> None:
|
|
"""OAuth retries once on 401 from Node-RED."""
|
|
from unittest.mock import MagicMock
|
|
import time
|
|
|
|
client = NodeRedClient(
|
|
base_url="http://localhost:1880",
|
|
auth_method="oauth",
|
|
auth_username="user",
|
|
auth_password="pass",
|
|
oauth_token_url="https://auth.example.com/token",
|
|
oauth_client_id="client-id",
|
|
)
|
|
# Pre-set a "valid" but actually revoked token
|
|
client._oauth_token = "revoked-token"
|
|
client._oauth_expiry = time.time() + 300
|
|
|
|
# First response: 401, second response: 200
|
|
response_401 = MagicMock()
|
|
response_401.status_code = 401
|
|
response_401.text = "Unauthorized"
|
|
|
|
response_200 = MagicMock()
|
|
response_200.status_code = 200
|
|
response_200.json.return_value = {"id": "test"}
|
|
|
|
with patch.object(
|
|
client._http, "request", new_callable=AsyncMock,
|
|
side_effect=[response_401, response_200],
|
|
):
|
|
# Mock the token refresh
|
|
token_response = MagicMock()
|
|
token_response.status_code = 200
|
|
token_response.json.return_value = {
|
|
"access_token": "new-token",
|
|
"expires_in": 600,
|
|
}
|
|
|
|
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
|
mock_auth_client = AsyncMock()
|
|
mock_auth_client.post.return_value = token_response
|
|
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
|
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_auth_cls.return_value = mock_auth_client
|
|
|
|
result = await client._request("GET", "/flows")
|
|
|
|
assert result == {"id": "test"}
|
|
assert client._oauth_token == "new-token"
|