928 lines
33 KiB
Python
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"] = 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"]
|