Files
Unraid-AppOps-Gateway-Public/backend/tests/test_npm_secure_transport.py
T

504 lines
16 KiB
Python

from __future__ import annotations
import pytest
@pytest.fixture
def npm_settings(monkeypatch):
from backend.app.core.config import settings
previous = dict(settings.apis)
settings.apis.update(
{
"NPM_URL": "https://npm.example.test",
"NPM_IDENTITY": "operator@example.test",
"NPM_SECRET": "fixture password value",
"NPM_VERIFY_TLS": True,
"NPM_TIMEOUT_SECONDS": "3",
}
)
yield settings.apis
settings.apis.clear()
settings.apis.update(previous)
@pytest.mark.parametrize(
("url", "verify_tls", "reason"),
[
("http://npm.example.test", True, "npm_https_required"),
("https://user:fixture-password@npm.example.test", True, "npm_url_invalid"),
("https://npm.example.test/?token=fixture-password", True, "npm_url_invalid"),
("https://npm.example.test/#fixture-password", True, "npm_url_invalid"),
("https://npm.example.test:", True, "npm_url_invalid"),
("https://npm.example.test/base/../api", True, "npm_url_invalid"),
("https://[broken", True, "npm_url_invalid"),
("https://npm.example.test", False, "npm_tls_verification_required"),
],
)
def test_unsafe_npm_transport_is_rejected_before_client_construction(
npm_settings, monkeypatch, url, verify_tls, reason
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
npm_settings["NPM_URL"] = url
npm_settings["NPM_VERIFY_TLS"] = verify_tls
client_calls = []
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
lambda **kwargs: client_calls.append(kwargs),
)
connector = NginxProxyManagerConnector()
with pytest.raises(RuntimeError, match=f"^{reason}$") as error:
connector._login()
assert str(error.value) == reason
assert client_calls == []
assert "fixture password value" not in str(error.value)
assert "npm.example.test" not in str(error.value)
def test_missing_verify_setting_defaults_to_verified_https(npm_settings, monkeypatch):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
npm_settings.pop("NPM_VERIFY_TLS")
clients = []
class Response:
status_code = 200
def json(self):
return {"token": "fixture bearer"}
class Client:
def __init__(self, **kwargs):
clients.append(kwargs)
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def post(self, *_args, **_kwargs):
return Response()
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
)
connector = NginxProxyManagerConnector()
assert connector._login() == "fixture bearer"
assert clients == [{"timeout": 3.0, "verify": True, "follow_redirects": False}]
def test_invalid_url_is_not_echoed_in_health_metadata(npm_settings, monkeypatch):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
npm_settings["NPM_URL"] = (
"https://operator:fixture-password@npm.example.test/?token=fixture-password"
)
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
lambda **_kwargs: pytest.fail("invalid URL must be rejected before HTTP"),
)
result = NginxProxyManagerConnector().health()
assert result["status"] == "unreachable"
assert result["message"] == "npm_url_invalid"
assert result["url"] == ""
assert "fixture-password" not in str(result)
assert "operator@npm.example.test" not in str(result)
def test_login_and_api_request_preserve_base_path_without_following_redirects(
npm_settings, monkeypatch
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
npm_settings["NPM_URL"] = "https://npm.example.test/manager/"
calls = []
class Response:
def __init__(self, status_code, payload, headers=None):
self.status_code = status_code
self._payload = payload
self.headers = headers or {}
def json(self):
return self._payload
class Client:
def __init__(self, **kwargs):
assert kwargs == {
"timeout": 3.0,
"verify": True,
"follow_redirects": False,
}
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def post(self, url, **kwargs):
calls.append(("POST", url, kwargs))
return Response(200, {"token": "fixture bearer"})
def request(self, method, url, **kwargs):
calls.append((method, url, kwargs))
return Response(200, {"ok": True})
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
)
connector = NginxProxyManagerConnector()
assert connector._request("GET", "/nginx/proxy-hosts") == {"ok": True}
assert [call[:2] for call in calls] == [
("POST", "https://npm.example.test/manager/api/tokens"),
("GET", "https://npm.example.test/manager/api/nginx/proxy-hosts"),
]
assert "Authorization" not in calls[0][2].get("headers", {})
def test_redirect_responses_are_not_followed_or_reported_as_success(
npm_settings, monkeypatch
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
calls = []
class Response:
def __init__(self):
self.status_code = 302
self.headers = {"location": "https://attacker.example.test/collect"}
class Client:
def __init__(self, **kwargs):
assert kwargs["follow_redirects"] is False
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def post(self, url, **kwargs):
calls.append((url, kwargs))
return Response()
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
)
connector = NginxProxyManagerConnector()
with pytest.raises(RuntimeError, match="^npm_redirect_denied$"):
connector._login()
assert len(calls) == 1
assert calls[0][0] == "https://npm.example.test/api/tokens"
assert calls[0][1]["json"]["secret"] == "fixture password value"
def test_api_redirect_does_not_forward_bearer_token(npm_settings, monkeypatch):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
calls = []
class Response:
def __init__(self, status_code, payload=None):
self.status_code = status_code
self._payload = payload
self.headers = {"location": "https://attacker.example.test/collect"}
def json(self):
return self._payload
class Client:
def __init__(self, **kwargs):
assert kwargs["follow_redirects"] is False
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def post(self, url, **kwargs):
calls.append(("POST", url, kwargs))
return Response(200, {"token": "fixture bearer"})
def request(self, method, url, **kwargs):
calls.append((method, url, kwargs))
return Response(302)
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
)
connector = NginxProxyManagerConnector()
with pytest.raises(RuntimeError, match="^npm_redirect_denied$"):
connector._request("GET", "/nginx/proxy-hosts", follow_redirects=True)
assert [call[:2] for call in calls] == [
("POST", "https://npm.example.test/api/tokens"),
("GET", "https://npm.example.test/api/nginx/proxy-hosts"),
]
assert calls[1][2]["headers"]["Authorization"] == "Bearer fixture bearer"
assert calls[1][2]["follow_redirects"] is False
def test_login_error_body_cannot_echo_configured_secret(npm_settings, monkeypatch):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
class Response:
status_code = 500
text = "login failed for fixture password value"
class Client:
def __init__(self, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def post(self, *_args, **_kwargs):
return Response()
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
)
connector = NginxProxyManagerConnector()
with pytest.raises(RuntimeError, match="^npm_api_error$") as error:
connector._login()
assert "fixture password value" not in str(error.value)
assert connector.last_error == "npm_api_error"
def test_cached_token_is_cleared_before_unsafe_transport_reuse(npm_settings, monkeypatch):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
connector = NginxProxyManagerConnector()
connector._config()
connector._runtime_token = "stale fixture bearer"
connector._read_cache["proxy_hosts"] = {"success": True, "items": ["stale"]}
npm_settings["NPM_URL"] = "http://npm.example.test"
client_calls = []
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
lambda **kwargs: client_calls.append(kwargs),
)
with pytest.raises(RuntimeError, match="^npm_https_required$"):
connector._login()
assert connector._runtime_token is None
assert connector._read_cache == {}
assert client_calls == []
@pytest.mark.parametrize(
"changed_key,changed_value",
[
("NPM_URL", "https://other.example.test"),
("NPM_IDENTITY", "different@example.test"),
("NPM_SECRET", "different fixture password"),
],
)
def test_cached_token_and_read_cache_are_bound_to_transport_config(
npm_settings, monkeypatch, changed_key, changed_value
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
connector = NginxProxyManagerConnector()
connector._config()
connector._runtime_token = "cached fixture bearer"
connector._read_cache["proxy_hosts"] = {"success": True, "items": ["old"]}
npm_settings[changed_key] = changed_value
requested = []
monkeypatch.setattr(
connector,
"_request",
lambda method, path, **_kwargs: requested.append((method, path)) or [],
)
result = connector.proxy_hosts()
assert result["items"] == []
assert requested == [("GET", "/nginx/proxy-hosts")]
assert connector._runtime_token is None
assert connector._read_cache["proxy_hosts"] is result
def test_disabled_tls_clears_cached_state_before_read_cache_return(
npm_settings, monkeypatch
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
connector = NginxProxyManagerConnector()
connector._config()
connector._runtime_token = "cached fixture bearer"
connector._read_cache["proxy_hosts"] = {"success": True, "items": ["old"]}
npm_settings["NPM_VERIFY_TLS"] = False
monkeypatch.setattr(
connector,
"_request",
lambda *_args, **_kwargs: pytest.fail("unsafe transport must not request"),
)
result = connector.proxy_hosts()
assert result["success"] is False
assert result["message"] == "npm_tls_verification_required"
assert connector._runtime_token is None
assert connector._read_cache == {}
def test_malformed_tls_setting_is_rejected_and_url_metadata_is_hidden(
npm_settings, monkeypatch
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
npm_settings["NPM_VERIFY_TLS"] = "maybe"
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
lambda **_kwargs: pytest.fail("invalid TLS setting must be rejected before HTTP"),
)
result = NginxProxyManagerConnector().health()
assert result["status"] == "unreachable"
assert result["message"] == "npm_config_invalid"
assert result["url"] == ""
@pytest.mark.parametrize(
"changed_key,changed_value",
[
("NPM_URL", "https://other.example.test"),
("NPM_IDENTITY", "different@example.test"),
("NPM_SECRET", "different fixture password"),
],
)
def test_changed_credentials_require_a_new_login(
npm_settings, monkeypatch, changed_key, changed_value
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
token_calls = []
token_urls = []
class Response:
status_code = 200
def __init__(self, token):
self._token = token
def json(self):
return {"token": self._token}
class Client:
def __init__(self, **kwargs):
assert kwargs["verify"] is True
assert kwargs["follow_redirects"] is False
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def post(self, url, **kwargs):
token_urls.append(url)
token_calls.append(kwargs["json"])
return Response(f"fixture-token-{len(token_calls)}")
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
)
connector = NginxProxyManagerConnector()
assert connector._login() == "fixture-token-1"
npm_settings[changed_key] = changed_value
assert connector._login() == "fixture-token-2"
assert len(token_calls) == 2
assert token_urls[0] == "https://npm.example.test/api/tokens"
expected_url = npm_settings["NPM_URL"].rstrip("/") + "/api/tokens"
assert token_urls[1] == expected_url
assert token_calls[1]["identity"] == npm_settings["NPM_IDENTITY"]
assert token_calls[1]["secret"] == npm_settings["NPM_SECRET"]
def test_missing_config_rejects_before_client_even_with_cached_token(
npm_settings, monkeypatch
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
connector = NginxProxyManagerConnector()
connector._config()
connector._runtime_token = "stale fixture bearer"
npm_settings["NPM_URL"] = None
client_calls = []
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
lambda **kwargs: client_calls.append(kwargs),
)
with pytest.raises(RuntimeError, match="^missing_config$"):
connector._login()
assert connector._runtime_token is None
assert client_calls == []
def test_missing_config_check_clears_cached_context_before_configuration_returns(
npm_settings,
):
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
connector = NginxProxyManagerConnector()
connector._config()
connector._runtime_token = "stale fixture bearer"
connector._read_cache["proxy_hosts"] = {"success": True, "items": []}
npm_settings["NPM_SECRET"] = None
assert connector.is_configured() is False
assert connector._runtime_token is None
assert connector._read_cache == {}
def test_missing_npm_tls_environment_default_does_not_inherit_global_disable(
monkeypatch,
):
import builtins
from backend.app.core import config
monkeypatch.setenv("APPOPS_VERIFY_TLS", "false")
monkeypatch.delenv("NPM_VERIFY_TLS", raising=False)
for name in (
"APPOPS_MCP_TOKEN_FILE",
"APPOPS_N8N_TOKEN_FILE",
"APPOPS_SECRET_KEY_FILE",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(config, "_secret_value", lambda *_args: None)
monkeypatch.setattr(
builtins,
"open",
lambda *_args, **_kwargs: pytest.fail("config test must not read secret files"),
)
configured = config.Settings()
assert configured.verify_tls is False
assert configured.apis["NPM_VERIFY_TLS"] is True