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

928 lines
33 KiB
Python

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"] = False
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"]