Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11814
-288
No files matched your search
@@ -0,0 +1,503 @@
|
||||
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
|
||||
Reference in new issue
Block a user