from pathlib import Path from fastapi.testclient import TestClient from sqlmodel import SQLModel, create_engine def _reset_db(tmp_path, monkeypatch): db_path = tmp_path / "npm_test.db" monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") from backend.app.db import session from backend.app.db.init_db import init_db engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) session.engine = engine SQLModel.metadata.drop_all(engine) SQLModel.metadata.create_all(engine) init_db() def _set_npm_config( url: str | None = "https://npm.example.test", identity: str | None = "admin@example.test", secret: str | None = "NPM_SECRET_VALUE", ): from backend.app.core.config import settings previous = dict(settings.apis) settings.apis["NPM_URL"] = url settings.apis["NPM_IDENTITY"] = identity settings.apis["NPM_SECRET"] = secret settings.apis["NPM_VERIFY_TLS"] = True settings.apis["NPM_TIMEOUT_SECONDS"] = "10" return previous def _restore_config(previous): from backend.app.core.config import settings settings.apis.clear() settings.apis.update(previous) def _proxy_hosts(): return [ { "id": 1, "domain_names": ["media.example.com"], "forward_host": "plex", "forward_port": 32400, "forward_scheme": "http", "enabled": 1, "ssl_forced": 1, "http2_support": 1, "hsts_enabled": 0, "certificate_id": 10, "access_list_id": 2, "block_exploits": 1, "caching_enabled": 0, "allow_websocket_upgrade": 1, "locations": [{"path": "/"}], "meta": {"letsencrypt_agree": True}, }, { "id": 2, "domain_names": ["open.example.com"], "forward_host": "unknown-app", "forward_port": 8080, "forward_scheme": "http", "enabled": True, "ssl_forced": False, "http2_support": False, "hsts_enabled": False, "certificate_id": None, "access_list_id": 0, "block_exploits": False, "caching_enabled": False, "allow_websocket_upgrade": False, }, ] def _certificates(): return [ { "id": 10, "nice_name": "media wildcard", "domain_names": ["media.example.com"], "provider": "letsencrypt", "expires_on": "2099-01-01T00:00:00Z", }, { "id": 11, "nice_name": "soon", "domain_names": ["soon.example.com"], "provider": "letsencrypt", "expires_on": "2000-01-01T00:00:00Z", }, ] def _access_lists(): return [ { "id": 2, "name": "Authentik forward auth", "auth_type": "authentik", "satisfy_any": False, "items": [{"username": "redacted"}], "clients": [{"address": "198.18.10.0/24"}], } ] def test_npm_connector_missing_config_reports_required_env(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) previous = _set_npm_config(url=None, identity=None, secret=None) from backend.app.main import app with TestClient(app) as client: payload = client.post("/api/connectors/nginx_proxy_manager/test").json() assert payload["success"] is False assert payload["status"] == "missing_config" assert payload["missing_config"] is True assert payload["required_env_vars"] == ["NPM_URL", "NPM_IDENTITY", "NPM_SECRET"] assert payload["secretRef"] == "NPM_SECRET" _restore_config(previous) def test_npm_health_success_and_auth_failure_redact_secret(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() monkeypatch.setattr(NginxProxyManagerConnector, "_request", lambda self, method, path, **kwargs: _proxy_hosts() if path == "/nginx/proxy-hosts" else [], raising=True) monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) healthy = NginxProxyManagerConnector().health() assert healthy["success"] is True assert healthy["status"] == "connected" assert healthy["proxyHostCount"] == 2 assert healthy["url"] == "https://npm.example.test" assert "NPM_SECRET_VALUE" not in str(healthy) def fail_login(self): raise RuntimeError("auth_failed password=NPM_SECRET_VALUE") monkeypatch.setattr(NginxProxyManagerConnector, "_login", fail_login, raising=True) failed = NginxProxyManagerConnector().health() assert failed["success"] is False assert failed["status"] == "auth_failed" assert "NPM_SECRET_VALUE" not in str(failed) assert "[REDACTED]" in str(failed) _restore_config(previous) def test_typed_proxy_host_refuses_takeover_and_creates_only_hardened_plan_target(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.core.config import settings previous = _set_npm_config() settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' calls = [] def request(self, method, path, **kwargs): calls.append((method, path, kwargs)) if method == 'GET' and path == '/nginx/proxy-hosts': return [] if method == 'POST' and path == '/nginx/proxy-hosts': return {'id': 42} raise AssertionError((method, path)) monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) result = NginxProxyManagerConnector().ensure_managed_proxy_host( hostname='support.itworx.test', forward_host='wsbc-portal', forward_port=8080, certificate_mode='managed', force_ssl=True, http2=True, upload_limit_mb=256, read_timeout_seconds=180, ) assert result == { 'success': True, 'status': 'created', 'message': 'proxy_host_created_from_typed_plan', 'hostId': 42, 'hostname': 'support.itworx.test', } payload = calls[-1][2]['json'] assert payload['forward_host'] == 'wsbc-portal' assert payload['forward_port'] == 8080 assert payload['ssl_forced'] is True assert payload['http2_support'] is True assert payload['caching_enabled'] is False assert payload['allow_websocket_upgrade'] is False assert payload['block_exploits'] is True assert payload['advanced_config'] == 'client_max_body_size 256m;\nproxy_read_timeout 180s;\nproxy_send_timeout 180s;' _restore_config(previous) def test_managed_proxy_requires_and_verifies_requested_access_list(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.core.config import settings previous = _set_npm_config() settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' calls = [] def request(self, method, path, **kwargs): calls.append((method, path, kwargs)) if path == '/nginx/access-lists': return [{'id': 1, 'name': 'Jens', 'satisfy_any': False, 'items': [{'id': 7}]}] if method == 'GET' and path == '/nginx/proxy-hosts': if len([call for call in calls if call[:2] == ('GET', path)]) == 1: return [] return [{ 'id': 42, 'domain_names': ['repopulse.itworx.test'], 'forward_host': 'repopulse', 'forward_port': 8000, 'access_list_id': 1, }] if method == 'POST' and path == '/nginx/proxy-hosts': return {'id': 42} raise AssertionError((method, path)) monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) result = NginxProxyManagerConnector().ensure_managed_proxy_host( hostname='repopulse.itworx.test', forward_host='repopulse', forward_port=8000, certificate_mode='managed', force_ssl=True, http2=True, upload_limit_mb=128, read_timeout_seconds=120, access_list_id=1, ) assert result['status'] == 'created' assert next(call[2]['json'] for call in calls if call[:2] == ('POST', '/nginx/proxy-hosts'))['access_list_id'] == 1 _restore_config(previous) def test_managed_proxy_blocks_missing_or_bypassable_access_list(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() lists = [] calls = [] def request(self, method, path, **kwargs): calls.append((method, path)) if path == '/nginx/access-lists': return lists raise AssertionError('Proxy host must not be inspected or created') monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) arguments = { 'hostname': 'repopulse.itworx.test', 'forward_host': 'repopulse', 'forward_port': 8000, 'certificate_mode': 'managed', 'force_ssl': True, 'http2': True, 'upload_limit_mb': 128, 'read_timeout_seconds': 120, 'access_list_id': 1, } assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' lists.append({'id': 1, 'name': 'Jens', 'satisfy_any': True, 'items': [{'id': 7}]}) assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' lists[0]['satisfy_any'] = False lists[0]['items'] = [] assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' lists[0]['items'] = [{'id': 7}] del lists[0]['satisfy_any'] assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' assert all(path == '/nginx/access-lists' for _, path in calls) _restore_config(previous) def test_preflight_access_list_verification_is_read_only_and_redacted(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() calls = [] def request(self, method, path, **kwargs): calls.append((method, path, kwargs)) return [{ 'id': 42, 'name': 'RepoPulse operators', 'satisfy_any': False, 'items': [{'username': 'private-user', 'password': 'private-password'}], 'clients': [], }] monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) result = NginxProxyManagerConnector().validate_managed_access_list(42) assert result == { 'success': True, 'status': 'verified', 'message': 'required_access_list_verified', 'accessListId': 42, } assert calls == [('GET', '/nginx/access-lists', { 'params': {'limit': 1000, 'expand': 'items,clients'}, })] assert 'private-user' not in str(result) assert 'private-password' not in str(result) def test_managed_proxy_rejects_access_list_lost_after_creation(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.core.config import settings previous = _set_npm_config() settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' host_reads = 0 def request(self, method, path, **kwargs): nonlocal host_reads if path == '/nginx/access-lists': return [{'id': 1, 'satisfy_any': False, 'items': [{'id': 7}]}] if method == 'GET' and path == '/nginx/proxy-hosts': host_reads += 1 return [] if host_reads == 1 else [{ 'id': 42, 'domain_names': ['repopulse.itworx.test'], 'forward_host': 'repopulse', 'forward_port': 8000, 'access_list_id': 0, }] if method == 'POST' and path == '/nginx/proxy-hosts': return {'id': 42} raise AssertionError((method, path)) monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) result = NginxProxyManagerConnector().ensure_managed_proxy_host( hostname='repopulse.itworx.test', forward_host='repopulse', forward_port=8000, certificate_mode='managed', force_ssl=True, http2=True, upload_limit_mb=128, read_timeout_seconds=120, access_list_id=1, ) assert result['status'] == 'ownership_unverified' _restore_config(previous) def test_enrollment_proxy_creation_requires_persisted_ownership_marker(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.core.config import settings previous = _set_npm_config() settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' requests = [] def request(self, method, path, **kwargs): requests.append((method, path, kwargs)) if method == 'POST': return {'id': 42} if len([item for item in requests if item[0] == 'GET']) == 1: return [] return [{ 'id': 42, 'domain_names': ['support.itworx.test'], 'forward_host': 'wsbc-portal', 'forward_port': 8080, 'meta': {'appops_enrollment_id': 'enr-0123456789abcdef0123'}, }] monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) result = NginxProxyManagerConnector().ensure_managed_proxy_host( hostname='support.itworx.test', forward_host='wsbc-portal', forward_port=8080, certificate_mode='managed', force_ssl=True, http2=True, upload_limit_mb=256, read_timeout_seconds=180, ownership_id='enr-0123456789abcdef0123', ) assert result['success'] is True assert result['status'] == 'created' assert len([item for item in requests if item[0] == 'GET']) == 2 _restore_config(previous) def test_enrollment_proxy_creation_stops_when_ownership_is_not_persisted(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.core.config import settings previous = _set_npm_config() settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' calls = {'get': 0} def request(self, method, path, **kwargs): if method == 'POST': return {'id': 42} calls['get'] += 1 return [] if calls['get'] == 1 else [{ 'id': 42, 'domain_names': ['support.itworx.test'], 'forward_host': 'wsbc-portal', 'forward_port': 8080, 'meta': {}, }] monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) result = NginxProxyManagerConnector().ensure_managed_proxy_host( hostname='support.itworx.test', forward_host='wsbc-portal', forward_port=8080, certificate_mode='managed', force_ssl=True, http2=True, upload_limit_mb=256, read_timeout_seconds=180, ownership_id='enr-0123456789abcdef0123', ) assert result == { 'success': False, 'status': 'ownership_unverified', 'message': 'managed_proxy_creation_ownership_not_verified', 'hostId': 42, } _restore_config(previous) def test_typed_proxy_host_does_not_take_over_existing_hostname(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() monkeypatch.setattr( NginxProxyManagerConnector, '_request', lambda self, method, path, **kwargs: [{ 'id': 12, 'domain_names': ['support.itworx.test'], 'forward_host': 'other-portal', 'forward_port': 8080, }], raising=True, ) result = NginxProxyManagerConnector().ensure_managed_proxy_host( hostname='support.itworx.test', forward_host='wsbc-portal', forward_port=8080, certificate_mode='existing', force_ssl=True, http2=True, upload_limit_mb=256, read_timeout_seconds=180, ) assert result == { 'success': False, 'status': 'conflict', 'message': 'hostname_owned_by_different_target', } _restore_config(previous) def test_typed_proxy_host_rejects_insecure_existing_target(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() monkeypatch.setattr( NginxProxyManagerConnector, '_request', lambda self, method, path, **kwargs: [{ 'id': 12, 'domain_names': ['support.itworx.test'], 'forward_host': 'wsbc-portal', 'forward_port': 8080, 'ssl_forced': False, 'certificate_id': None, }], raising=True, ) result = NginxProxyManagerConnector().ensure_managed_proxy_host( hostname='support.itworx.test', forward_host='wsbc-portal', forward_port=8080, certificate_mode='existing', force_ssl=True, http2=True, upload_limit_mb=256, read_timeout_seconds=180, ) assert result == { 'success': False, 'status': 'conflict', 'message': 'existing_proxy_host_does_not_meet_managed_security_contract', } _restore_config(previous) def test_npm_proxy_hosts_certificates_and_access_lists_normalize(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() calls = [] def request(self, method, path, **kwargs): calls.append((path, kwargs)) return { "/nginx/proxy-hosts": _proxy_hosts(), "/nginx/certificates": _certificates(), "/nginx/access-lists": _access_lists(), }[path] monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) monkeypatch.setattr( NginxProxyManagerConnector, "_request", request, raising=True, ) hosts = NginxProxyManagerConnector().proxy_hosts() certs = NginxProxyManagerConnector().certificates() lists = NginxProxyManagerConnector().access_lists() assert hosts["success"] is True assert hosts["items"][0]["domainNames"] == ["media.example.com"] assert hosts["items"][0]["forwardHost"] == "plex" assert hosts["items"][0]["forwardPort"] == 32400 assert hosts["items"][0]["sslForced"] is True assert hosts["items"][0]["accessListId"] == 2 assert hosts["items"][0]["locationsCount"] == 1 assert certs["items"][0]["provider"] == "letsencrypt" assert certs["items"][1]["isExpired"] is True assert certs["items"][1]["isExpiringSoon"] is True assert lists["items"][0]["name"] == "Authentik forward auth" assert lists["items"][0]["itemsCount"] == 1 assert all(kwargs.get("params") == {"limit": 100} for _, kwargs in calls) _restore_config(previous) def test_npm_exposure_and_security_warnings(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) monkeypatch.setattr( NginxProxyManagerConnector, "_request", lambda self, method, path, **kwargs: { "/nginx/proxy-hosts": _proxy_hosts(), "/nginx/certificates": _certificates(), "/nginx/access-lists": _access_lists(), }[path], raising=True, ) summary = NginxProxyManagerConnector().exposure_summary() warnings = NginxProxyManagerConnector().security_warnings() assert summary["totalProxyHosts"] == 2 assert summary["enabledProxyHosts"] == 2 assert summary["hostsWithoutCertificate"] == 1 assert summary["hostsWithoutAccessList"] == 1 assert summary["hostsWithoutSslForce"] == 1 assert any(item["type"] == "host_without_certificate" for item in warnings["items"]) assert any(item["type"] == "public_host_without_access_list" for item in warnings["items"]) assert any(item["type"] == "ssl_not_forced" for item in warnings["items"]) expiring = NginxProxyManagerConnector._build_warnings( [ { "id": 3, "domainNames": ["soon.example.com"], "enabled": True, "sslForced": True, "hstsEnabled": True, "certificateId": 11, "accessListId": 2, "forwardScheme": "https", "forwardHost": "soon", "forwardPort": 443, } ], {11: {"isExpiringSoon": True, "expiresInDays": 30, "expiresOn": "2026-08-01T00:00:00Z"}}, ) expiring_warning = next(item for item in expiring if item["type"] == "certificate_expiring_soon") assert expiring_warning["evidence"][0] == "soon.example.com" assert expiring_warning["evidence"][1] == "30 days" _restore_config(previous) def test_npm_reuses_read_results_within_connector_instance(monkeypatch): from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector previous = _set_npm_config() calls = [] def request(self, method, path, **kwargs): calls.append(path) return { "/nginx/proxy-hosts": _proxy_hosts(), "/nginx/certificates": _certificates(), "/nginx/access-lists": _access_lists(), }[path] monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) monkeypatch.setattr(NginxProxyManagerConnector, "_request", request, raising=True) connector = NginxProxyManagerConnector() summary = connector.exposure_summary() hosts = connector.proxy_hosts() warnings = connector.security_warnings() assert summary["success"] is True assert hosts["total"] == 2 assert warnings["total"] >= 1 assert calls.count("/nginx/proxy-hosts") == 1 assert calls.count("/nginx/certificates") == 1 assert calls.count("/nginx/access-lists") == 1 _restore_config(previous) def test_npm_routes_are_read_only_and_shape_is_consistent(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) previous = _set_npm_config() from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.main import app monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) monkeypatch.setattr( NginxProxyManagerConnector, "_request", lambda self, method, path, **kwargs: { "/nginx/proxy-hosts": _proxy_hosts(), "/nginx/certificates": _certificates(), "/nginx/access-lists": _access_lists(), }[path], raising=True, ) with TestClient(app) as client: for path in [ "/api/apps/nginx-proxy-manager/health", "/api/apps/nginx-proxy-manager/proxy-hosts", "/api/apps/nginx-proxy-manager/certificates", "/api/apps/nginx-proxy-manager/certificates/expiring", "/api/apps/nginx-proxy-manager/access-lists", "/api/apps/nginx-proxy-manager/exposure-summary", "/api/apps/nginx-proxy-manager/host-reachability", "/api/apps/nginx-proxy-manager/security-warnings", "/api/apps/npm/health", ]: response = client.get(path) assert response.status_code == 200 payload = response.json() assert {"success", "status", "message", "data"}.issubset(payload.keys()) assert client.post("/api/apps/nginx-proxy-manager/proxy-hosts").status_code == 405 _restore_config(previous) def test_network_exposure_uses_npm_metadata_without_demo_hosts(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) previous = _set_npm_config() from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.main import app monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) monkeypatch.setattr( NginxProxyManagerConnector, "_request", lambda self, method, path, **kwargs: { "/nginx/proxy-hosts": _proxy_hosts(), "/nginx/certificates": _certificates(), "/nginx/access-lists": _access_lists(), }[path], raising=True, ) with TestClient(app) as client: payload = client.get("/api/network/exposure").json() assert [item["host"] for item in payload] == ["media.example.com", "open.example.com"] assert all(item["source"] == "reverse_proxy" for item in payload) assert "unraid.example.net" not in str(payload) assert payload[0]["protected"] is True assert payload[0]["risk"] == "low" assert payload[1]["protected"] is False assert payload[1]["risk"] == "high" _restore_config(previous) def test_network_exposure_uses_authentik_coverage_for_protection(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) previous = _set_npm_config() from backend.app.connectors.authentik import AuthentikConnector from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.main import app hosts = [ { "id": 10, "domain_names": ["protected.example.com"], "forward_host": "protected-app", "forward_port": 8080, "forward_scheme": "http", "enabled": True, "ssl_forced": True, "hsts_enabled": True, "certificate_id": 10, "access_list_id": 0, }, { "id": 11, "domain_names": ["open.example.com"], "forward_host": "open-app", "forward_port": 8081, "forward_scheme": "http", "enabled": True, "ssl_forced": True, "hsts_enabled": True, "certificate_id": 10, "access_list_id": 0, }, ] monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) monkeypatch.setattr( NginxProxyManagerConnector, "_request", lambda self, method, path, **kwargs: { "/nginx/proxy-hosts": hosts, "/nginx/certificates": _certificates(), "/nginx/access-lists": [], }[path], raising=True, ) monkeypatch.setattr( AuthentikConnector, "auth_coverage", lambda self: { "success": True, "status": "ok", "publicHosts": [ {"hostId": 10, "coverageStatus": "protected", "authentikMatches": ["protected.example.com"]}, {"hostId": 11, "coverageStatus": "unprotected", "authentikMatches": []}, ], }, raising=True, ) with TestClient(app) as client: payload = client.get("/api/network/exposure").json() protected = next(item for item in payload if item["host"] == "protected.example.com") open_host = next(item for item in payload if item["host"] == "open.example.com") assert protected["protected"] is True assert protected["protectionSource"] == "authentik" assert protected["authCoverageStatus"] == "protected" assert protected["risk"] == "low" assert open_host["protected"] is False assert open_host["protectionSource"] == "none" assert open_host["risk"] == "high" assert "No NPM access list or Authentik match" in open_host["riskReasons"] assert open_host["externalChangeRequired"] is True _restore_config(previous) def test_network_exposure_reads_npm_and_authentik_in_parallel(tmp_path, monkeypatch): from threading import Barrier _reset_db(tmp_path, monkeypatch) previous = _set_npm_config() from backend.app.connectors.authentik import AuthentikConnector from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector from backend.app.main import app barrier = Barrier(3, timeout=1) def wait_and_return(payload): def _inner(self): barrier.wait() return payload return _inner monkeypatch.setattr( NginxProxyManagerConnector, "proxy_hosts", wait_and_return({"success": True, "status": "ok", "items": [{"id": 10, "domainNames": ["protected.example.com"], "forwardPort": 8080, "forwardScheme": "http", "enabled": True, "sslForced": True, "certificateId": 10, "accessListId": 0}]}), raising=True, ) monkeypatch.setattr( NginxProxyManagerConnector, "certificates", wait_and_return({"success": True, "status": "ok", "items": [{"id": 10, "isExpired": False, "isExpiringSoon": False}]}), raising=True, ) monkeypatch.setattr( AuthentikConnector, "auth_coverage", wait_and_return({"success": True, "status": "ok", "publicHosts": [{"hostId": 10, "coverageStatus": "protected", "authentikMatches": ["protected.example.com"]}]}), raising=True, ) with TestClient(app) as client: payload = client.get("/api/network/exposure").json() assert payload[0]["protected"] is True assert payload[0]["protectionSource"] == "authentik" _restore_config(previous) def test_network_exposure_reuses_persistent_cache_after_live_context_loss(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) previous = _set_npm_config() from backend.app.api import routes_observability from backend.app.main import app monkeypatch.setattr( routes_observability, "_build_network_exposure", lambda: [ { "source": "reverse_proxy", "host": "cached.example.com", "port": 443, "protocol": "https", "protected": True, "protectionSource": "authentik", "authCoverageStatus": "protected", "authentikMatches": ["cached.example.com"], "appId": "plex", "risk": "low", } ], raising=True, ) with TestClient(app) as client: first = client.get("/api/network/exposure").json() def fail_live_build(): raise AssertionError("persistent network exposure cache should avoid live connector reads") monkeypatch.setattr(routes_observability, "_build_network_exposure", fail_live_build, raising=True) with TestClient(app) as client: second = client.get("/api/network/exposure").json() assert second == first assert second[0]["host"] == "cached.example.com" _restore_config(previous) def test_network_exposure_missing_npm_config_does_not_infer_public_hosts(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) previous = _set_npm_config(url=None, identity=None, secret=None) from backend.app.main import app with TestClient(app) as client: payload = client.get("/api/network/exposure").json() assert payload == [] _restore_config(previous) def test_npm_policy_blocks_mutations_and_unknowns_fail_closed(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) from backend.app.main import app with TestClient(app) as client: for action in [ "npm.delete_proxy_host", "npm.update_proxy_host", "npm.renew_certificate", "npm.modify_access_list", "npm.create_user", "npm.mass_edit", "npm.unknown_mutation", ]: payload = client.post("/api/policies/evaluate", json={"action": action, "target": "nginx_proxy_manager", "source": "manual"}).json() assert payload["decision"] == "blocked" read = client.post("/api/policies/evaluate", json={"action": "npm.exposure_summary", "target": "nginx_proxy_manager", "source": "voice"}).json() assert read["decision"] == "allowed" def test_npm_inventory_classification_and_candidate(tmp_path, monkeypatch): _reset_db(tmp_path, monkeypatch) from backend.app.core.config import settings from backend.app.main import app from backend.app.services.app_classifier import classify_container for key in list(settings.apis): monkeypatch.delitem(settings.apis, key) result = classify_container({"name": "nginx-proxy-manager", "image": "jc21/nginx-proxy-manager:latest", "ports": ["81:81"]}) assert result["app_type"] == "nginx_proxy_manager" assert result["category"] == "network" assert result["subcategory"] == "reverse_proxy" assert result["connector"] == "nginx_proxy_manager" assert result["criticality"] == "critical" assert result["protected"] is True monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) monkeypatch.setattr( "backend.app.connectors.portainer.PortainerConnector.list_containers", lambda self: {"status": "ok", "containers": [{"id": "npm-container", "name": "nginx-proxy-manager", "image": "jc21/nginx-proxy-manager", "state": "running", "status": "running", "ports": ["81:81"]}]}, raising=True, ) with TestClient(app) as client: client.post("/api/inventory/discover") candidates = client.get("/api/connectors/nginx_proxy_manager/candidates").json() assert len(candidates) == 1 assert candidates[0]["connectorId"] == "nginx_proxy_manager" assert candidates[0]["appType"] == "nginx_proxy_manager" assert candidates[0]["missingSecrets"] == ["NPM_URL", "NPM_IDENTITY", "NPM_SECRET"]