504 lines
16 KiB
Python
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
|