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