149 lines
8.2 KiB
Python
149 lines
8.2 KiB
Python
"""Exposure preferences suppress ACL diagnostics without granting protection."""
|
|
from fastapi.testclient import TestClient
|
|
import pytest
|
|
from sqlmodel import Session, select
|
|
|
|
from backend.app.db.models import AppSetting, AuditEvent, GatewayUser
|
|
from backend.tests.test_nginx_proxy_manager_connector import _reset_db, _proxy_hosts, _certificates, _set_npm_config, _restore_config
|
|
|
|
|
|
def _stored_preference(engine, enabled):
|
|
with Session(engine) as db:
|
|
db.add(AppSetting(key="exposure:preferences", value_json={"nginxAccessListChecksEnabled": enabled}, is_secret=False))
|
|
db.commit()
|
|
|
|
|
|
def test_disabling_acl_checks_removes_only_acl_connector_warnings(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.db import session
|
|
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
|
_stored_preference(session.engine, False)
|
|
previous = _set_npm_config()
|
|
calls = []
|
|
|
|
def request(_connector, method, path, **_kwargs):
|
|
calls.append((method, path))
|
|
return {"/nginx/proxy-hosts": _proxy_hosts(), "/nginx/certificates": _certificates(), "/nginx/access-lists": []}[path]
|
|
|
|
monkeypatch.setattr(NginxProxyManagerConnector, "_request", request)
|
|
try:
|
|
connector = NginxProxyManagerConnector()
|
|
warnings = connector.security_warnings()["items"]
|
|
summary = connector.exposure_summary()
|
|
assert not any(item["type"] == "public_host_without_access_list" for item in warnings)
|
|
assert not any(item["type"] == "public_host_without_access_list" for item in summary["warnings"])
|
|
assert {"host_without_certificate", "ssl_not_forced", "unknown_proxy_target"} <= {item["type"] for item in warnings}
|
|
assert summary["hostsWithoutAccessList"] == 1 # Metadata remains truthful.
|
|
assert ("GET", "/nginx/access-lists") not in calls
|
|
assert all(method == "GET" for method, _path in calls)
|
|
finally:
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_disabled_acl_checks_do_not_mark_public_hosts_protected(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.db import session
|
|
from backend.app.api import routes_observability
|
|
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
|
_stored_preference(session.engine, False)
|
|
monkeypatch.setattr(NginxProxyManagerConnector, "proxy_hosts", lambda _self: {"success": True, "items": [
|
|
{"id": 1, "domainNames": ["open.example.test"], "enabled": True, "certificateId": 1, "sslForced": True, "accessListId": None},
|
|
{"id": 2, "domainNames": ["sso.example.test"], "enabled": True, "certificateId": 1, "sslForced": True, "accessListId": None},
|
|
{"id": 3, "domainNames": ["acl.example.test"], "enabled": True, "certificateId": 1, "sslForced": True, "accessListId": 2},
|
|
]})
|
|
monkeypatch.setattr(NginxProxyManagerConnector, "certificates", lambda _self: {"success": True, "items": [{"id": 1, "isExpired": False, "isExpiringSoon": False}]})
|
|
monkeypatch.setattr(routes_observability, "_auth_coverage_by_host", lambda: {"2": {"coverageStatus": "protected"}})
|
|
rows = routes_observability._build_network_exposure()
|
|
assert rows[0]["protected"] is False
|
|
assert rows[0]["risk"] == "high"
|
|
assert all("access list" not in reason.lower() for reason in rows[0]["riskReasons"])
|
|
assert rows[1]["protected"] is True
|
|
assert rows[1]["protectionSource"] == "authentik"
|
|
assert rows[2]["protected"] is True
|
|
assert rows[2]["protectionSource"] == "npm_access_list"
|
|
|
|
|
|
def test_exposure_preferences_admin_write_is_strict_persistent_and_audited(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.db import session
|
|
from backend.app.main import app
|
|
with TestClient(app) as client:
|
|
assert client.get("/api/settings/exposure").json() == {"nginxAccessListChecksEnabled": True}
|
|
for invalid in ({"nginxAccessListChecksEnabled": "false"}, {"nginxAccessListChecksEnabled": 0}, {"nginxAccessListChecksEnabled": False, "other": 1}, {}):
|
|
assert client.patch("/api/settings/exposure", json=invalid).status_code == 422
|
|
response = client.patch("/api/settings/exposure", json={"nginxAccessListChecksEnabled": False})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"nginxAccessListChecksEnabled": False}
|
|
assert client.get("/api/settings/exposure").json() == response.json()
|
|
with Session(session.engine) as db:
|
|
row = db.get(AppSetting, "exposure:preferences")
|
|
assert row and row.is_secret is False
|
|
assert row.value_json == {"nginxAccessListChecksEnabled": False}
|
|
events = db.exec(select(AuditEvent).where(AuditEvent.action_id == "settings.exposure.update")).all()
|
|
assert len(events) == 1
|
|
assert events[0].before_state == {"nginxAccessListChecksEnabled": True}
|
|
assert events[0].after_state == {"nginxAccessListChecksEnabled": False}
|
|
|
|
|
|
def test_non_admin_with_settings_permission_cannot_change_exposure_preferences(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.main import app
|
|
from backend.app.services import user_access
|
|
user = GatewayUser(id="custom-settings", provider="authentik", issuer="test", subject="custom", role="custom", permissions=["settings.manage"])
|
|
monkeypatch.setattr(user_access, "current_user", lambda _request: user)
|
|
with TestClient(app) as client:
|
|
assert client.get("/api/settings/exposure").status_code == 200
|
|
assert client.patch("/api/settings/exposure", json={"nginxAccessListChecksEnabled": False}).status_code == 403
|
|
|
|
|
|
def test_corrupt_exposure_preference_keeps_acl_checks_enabled(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.db import session
|
|
from backend.app.services.exposure_preferences import get_exposure_preferences
|
|
_stored_preference(session.engine, "false")
|
|
with Session(session.engine) as db:
|
|
assert get_exposure_preferences(db).nginxAccessListChecksEnabled is True
|
|
|
|
|
|
def test_safety_signature_tracks_effective_preference_without_timestamp_change(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.db import session
|
|
from backend.app.services import safety_center_service
|
|
_stored_preference(session.engine, True)
|
|
with Session(session.engine) as db:
|
|
before = safety_center_service._source_signature(db)
|
|
row = db.get(AppSetting, "exposure:preferences")
|
|
row.value_json = {"nginxAccessListChecksEnabled": False}
|
|
db.add(row)
|
|
db.commit()
|
|
assert safety_center_service._source_signature(db) != before
|
|
|
|
|
|
def test_safety_does_not_reuse_warnings_from_different_preferences_when_refresh_fails(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.db import session
|
|
from backend.app.services import safety_center_service
|
|
monkeypatch.setattr(safety_center_service, "_dashboard_cache", {
|
|
"signature": "previous-preferences", "expiresAt": "2099-01-01T00:00:00Z", "generatedAt": "2026-01-01T00:00:00Z",
|
|
"payload": {"warnings": [{"type": "public_host_without_access_list"}]}, "generationDurationMs": 0,
|
|
})
|
|
def unavailable(_session):
|
|
raise RuntimeError("snapshot unavailable")
|
|
monkeypatch.setattr(safety_center_service, "_payload", unavailable)
|
|
with Session(session.engine) as db, pytest.raises(RuntimeError, match="snapshot unavailable"):
|
|
safety_center_service.get_dashboard(db, force_refresh=True)
|
|
|
|
|
|
def test_disabled_acl_checks_drop_stale_reviewer_acl_findings_only(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
from backend.app.db import session
|
|
from backend.app.services import ai_security_reviewer
|
|
_stored_preference(session.engine, False)
|
|
data = {"npmExposure": {"success": True, "status": "ok", "warnings": [
|
|
{"type": "public_host_without_access_list", "title": "No ACL", "evidence": ["open.example.test"]},
|
|
{"type": "certificate_expired", "title": "Certificate expired", "evidence": ["open.example.test"]},
|
|
]}, "authCoverage": {"status": "missing_config"}}
|
|
with Session(session.engine) as db:
|
|
findings = ai_security_reviewer._exposure_findings(db, data)
|
|
assert [finding.id for finding in findings] == ["ai-sec-npm-certificate-expired"]
|