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

1055 lines
46 KiB
Python

from datetime import UTC, datetime, timedelta
from pathlib import Path
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, Session, create_engine
def _reset_db(tmp_path, monkeypatch):
db_path = tmp_path / "ai_security_test.db"
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
from backend.app.core.config import settings
from backend.app.db import session
from backend.app.db.init_db import init_db
previous = {
"apis": dict(settings.apis),
"environment": settings.environment,
"secret_key": settings.secret_key,
"secret_key_source": settings.secret_key_source,
}
for key in settings.apis:
settings.apis[key] = None if key.endswith(("_URL", "_API_KEY", "_TOKEN", "_SECRET", "_PASSWORD")) else settings.apis[key]
settings.environment = "development"
settings.secret_key = "test-ai-security-key"
settings.secret_key_source = "env"
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()
return previous
def _restore_config(previous):
from backend.app.core.config import settings
settings.apis.clear()
settings.apis.update(previous["apis"])
settings.environment = previous["environment"]
settings.secret_key = previous["secret_key"]
settings.secret_key_source = previous["secret_key_source"]
def _client(tmp_path, monkeypatch):
previous = _reset_db(tmp_path, monkeypatch)
from backend.app.main import app
return TestClient(app), previous
def _npm_exposure():
return {
"success": True,
"status": "ok",
"totalProxyHosts": 2,
"enabledProxyHosts": 2,
"publicHosts": 2,
"hostsWithoutCertificate": 1,
"hostsWithExpiredCertificate": 0,
"hostsWithExpiringCertificate": 1,
"hostsWithoutAccessList": 1,
"hostsWithoutSslForce": 1,
"hostsWithWebsocket": 1,
"warnings": [
{"type": "host_without_certificate", "severity": "high", "title": "open.example.com has no certificate", "evidence": ["open.example.com"], "suggestedAction": "review_certificate"},
{"type": "certificate_expiring_soon", "severity": "warning", "title": "plex.example.com certificate expires soon", "evidence": ["12"], "suggestedAction": "review_certificate_in_npm"},
],
}
def _npm_expiring_certificate_exposure():
return {
**_npm_exposure(),
"warnings": [
{
"type": "certificate_expiring_soon",
"severity": "warning",
"title": "auth.example.com certificate expires soon",
"evidence": ["auth.example.com", "30 days", "2026-08-07 07:13:42"],
"suggestedAction": "review_certificate_in_npm",
}
],
}
def _auth_coverage():
return {
"success": True,
"status": "ok",
"summary": {"totalPublicHosts": 2, "protectedHosts": 1, "likelyProtectedHosts": 0, "unprotectedHosts": 1, "npmStatus": "ok"},
"publicHosts": [
{"hostId": 1, "domains": ["plex.example.com"], "target": "http://plex:32400", "coverageStatus": "protected", "authentikMatches": ["plex.example.com"]},
{"hostId": 2, "domains": ["open.example.com"], "target": "http://unknown:8080", "coverageStatus": "unprotected", "authentikMatches": []},
],
}
def _media_snapshot_with_stalled_download():
return {
"sonarrHealth": {"success": True, "status": "connected"},
"sonarrWanted": {"success": True, "status": "connected", "total": 0, "items": []},
"sonarrQueue": {"success": True, "status": "connected", "total": 0, "items": []},
"prowlarrHealth": {"success": True, "status": "connected"},
"prowlarrIndexers": {"success": True, "status": "ok", "total": 1, "enabled": 1, "failing": 0, "items": []},
"prowlarrApplications": {"success": True, "status": "ok", "total": 1, "items": []},
"prowlarrSync": {"success": True, "status": "ok", "indexersFailing": 0},
"delugeHealth": {"success": True, "status": "connected"},
"delugeSession": {"success": True, "status": "ok", "active": 0, "paused": 0, "stalled": 2},
"delugeActive": {"success": True, "status": "ok", "total": 0, "items": []},
"delugeStalled": {"success": True, "status": "ok", "total": 2, "items": [{"id": "stalled-1", "name": "Example"}]},
"delugePaused": {"success": True, "status": "ok", "total": 0, "items": []},
"delugeSpeeds": {"success": True, "status": "ok", "active": 0, "paused": 0, "stalled": 2},
"plexHealth": {"success": True, "status": "connected"},
"plexLibraries": {"success": True, "status": "ok", "total": 1, "libraries": []},
"plexSessions": {"success": True, "status": "ok", "total": 0, "sessions": []},
"plexRecent": {"success": True, "status": "ok", "total": 0, "items": []},
"plexStreams": {"success": True, "status": "ok", "total": 0, "transcoding": 0},
"tautulliHealth": {"success": True, "status": "connected"},
"tautulliActivity": {"success": True, "status": "ok", "streamCount": 0, "sessions": []},
"tautulliHistory": {"success": True, "status": "ok", "total": 0, "items": []},
"tautulliStats": {"success": True, "status": "ok", "totalPlays": 0},
"tautulliStreams": {"success": True, "status": "ok", "activeStreams": 0, "transcoding": 0},
"tautulliLibraries": {"success": True, "status": "ok", "total": 1, "items": []},
"widefrogHealth": {"success": True, "status": "connected"},
"widefrogQueue": {"success": True, "status": "ok", "total": 0, "items": []},
"widefrogFailed": {"success": True, "status": "ok", "failed": 0, "items": []},
"widefrogActivity": {"success": True, "status": "ok", "recent": []},
"widefrogDownloads": {"success": True, "status": "ok", "active": 0, "items": []},
}
def test_ai_security_summary_generated_with_no_data_is_partial(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
with client:
payload = client.get("/api/ai/security/summary").json()
assert payload["status"] in {"partial", "warning"}
assert 0 <= payload["score"] <= 100
assert payload["dataCompleteness"]["percentage"] < 100
assert "secret_storage" in payload["dataCompleteness"]["connectedSources"]
assert payload["criticalFindings"] == 0
assert payload["scoreBreakdown"]["finalScore"] == payload["score"]
assert set(payload["scoreBreakdown"]["missingSources"]) >= {"npm", "authentik", "unraid"}
assert payload["scoreBreakdown"]["coverageStatus"] in {"partial", "warning"}
assert payload["scoreBreakdown"]["coverageSources"]["secret_storage"]["status"] == "connected"
assert payload["scoreBreakdown"]["coverageSources"]["npm"]["status"] == "missing_config"
assert payload["scoreBreakdown"]["coverageSources"]["authentik"]["status"] == "missing_config"
assert payload["scoreBreakdown"]["coverageSources"]["unraid"]["status"] == "missing_config"
_restore_config(previous)
def test_ai_security_missing_npm_and_auth_does_not_infer_unprotected_public_hosts(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
with client:
findings = client.get("/api/ai/security/findings").json()
summary = client.get("/api/ai/security/summary").json()
finding_ids = {finding["id"] for finding in findings}
assert "ai-sec-authentik-missing-public-hosts" not in finding_ids
assert "ai-sec-auth-public-host-without-authentik" not in finding_ids
assert summary["status"] in {"partial", "warning"}
assert summary["criticalFindings"] == 0
_restore_config(previous)
def test_ai_security_npm_and_auth_coverage_findings(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: _npm_exposure(), raising=True)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": [{"id": 2, "domainNames": ["open.example.com"], "enabled": True}]}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: _auth_coverage(), raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "items": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "items": []}, raising=True)
with client:
findings = client.get("/api/ai/security/findings").json()
types = {finding["id"] for finding in findings}
assert "ai-sec-npm-host-without-certificate" in types
assert "ai-sec-npm-certificate-expiring-soon" in types
assert "ai-sec-auth-public-host-without-authentik" in types
assert all(action["executable"] is False for finding in findings for action in finding["suggestedActions"])
_restore_config(previous)
def test_ai_security_npm_certificate_context_stays_evidence_not_affected(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: _npm_expiring_certificate_exposure(), raising=True)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": [{"id": 2, "domainNames": ["auth.example.com"], "enabled": True}]}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {**_auth_coverage(), "publicHosts": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "items": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "available": True, "embeddedExpected": True, "items": []}, raising=True)
with client:
findings = client.get("/api/ai/security/findings").json()
finding = next(item for item in findings if item["id"] == "ai-sec-npm-certificate-expiring-soon")
assert finding["affectedResources"] == ["auth.example.com"]
assert finding["evidence"][0]["value"] == ["auth.example.com", "30 days", "2026-08-07 07:13:42"]
_restore_config(previous)
def test_ai_security_snapshot_runs_external_reads_in_parallel(monkeypatch, tmp_path):
from threading import Barrier
client, previous = _client(tmp_path, monkeypatch)
barrier = Barrier(3, timeout=1)
def wait_payload(payload):
def _inner(self):
barrier.wait()
return payload
return _inner
monkeypatch.setattr("backend.app.services.ai_security_reviewer._REVIEW_CACHE", {}, raising=False)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", wait_payload(_npm_exposure()), raising=True)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", wait_payload(_auth_coverage()), raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "items": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "available": True, "embeddedExpected": True, "items": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.unraid.UnraidConnector.test_connection", wait_payload({"success": True, "status": "connected"}), raising=True)
with client:
payload = client.get("/api/ai/security/summary").json()
assert payload["scoreBreakdown"]["coverageSources"]["npm"]["status"] == "connected"
assert payload["scoreBreakdown"]["coverageSources"]["authentik"]["status"] == "connected"
assert payload["scoreBreakdown"]["coverageSources"]["unraid"]["status"] == "connected"
_restore_config(previous)
def test_ai_security_snapshot_reuses_npm_and_auth_instances(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
from backend.app.core.config import settings
from backend.app.services import ai_security_reviewer
settings.apis["NPM_URL"] = "https://npm.example.test"
settings.apis["NPM_IDENTITY"] = "admin@example.test"
settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE"
settings.apis["AUTHENTIK_URL"] = "https://auth.example.test"
settings.apis["AUTHENTIK_TOKEN"] = "AUTHENTIK_TOKEN_VALUE"
settings.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"] = True
npm_calls = []
auth_calls = []
def npm_request(self, method, path, **kwargs):
npm_calls.append(path)
return {
"/nginx/proxy-hosts": [
{
"id": 1,
"domain_names": ["media.example.com"],
"forward_host": "plex",
"forward_port": 32400,
"forward_scheme": "http",
"enabled": True,
"ssl_forced": True,
"hsts_enabled": True,
"certificate_id": 10,
"access_list_id": 2,
},
],
"/nginx/certificates": [{"id": 10, "domain_names": ["media.example.com"], "expires_on": "2099-01-01T00:00:00Z"}],
"/nginx/access-lists": [{"id": 2, "name": "Authentik"}],
}[path]
def auth_paged(self, path, params=None):
auth_calls.append(path)
return {
"/core/applications/": [{"pk": "app-media", "name": "Media", "slug": "media", "launch_url": "https://media.example.com", "provider": 101}],
"/core/providers/": [{"pk": 101, "name": "Media proxy", "type": "authentik_providers_proxy.proxyprovider", "assigned_application_slug": "media"}],
"/providers/proxy/": [{"pk": 101, "name": "Media proxy", "external_host": "https://media.example.com", "assigned_application_slug": "media"}],
"/providers/oauth2/": [],
"/outposts/instances/": [{"pk": "outpost-1", "name": "Embedded Outpost", "health": "healthy", "providers": [101]}],
"/events/events/": [{"pk": "evt-1", "action": "login", "created": "2026-01-01T10:00:00Z"}],
}[path]
monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True)
monkeypatch.setattr(NginxProxyManagerConnector, "_request", npm_request, raising=True)
monkeypatch.setattr(AuthentikConnector, "_paged", auth_paged, raising=True)
ai_security_reviewer._clear_review_cache()
with client:
payload = client.get("/api/ai/security/summary").json()
assert payload["scoreBreakdown"]["coverageSources"]["npm"]["status"] == "connected"
assert payload["scoreBreakdown"]["coverageSources"]["authentik"]["status"] == "connected"
assert npm_calls.count("/nginx/proxy-hosts") <= 2
assert npm_calls.count("/nginx/certificates") == 1
assert npm_calls.count("/nginx/access-lists") == 1
assert auth_calls.count("/core/applications/") == 1
assert auth_calls.count("/core/providers/") == 1
assert auth_calls.count("/providers/proxy/") == 1
assert auth_calls.count("/outposts/instances/") == 1
assert auth_calls.count("/events/events/") == 1
_restore_config(previous)
def test_ai_security_reuses_persistent_review_after_memory_cache_loss(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
with client:
first = client.get("/api/ai/security/summary").json()
from backend.app.services import ai_security_reviewer
ai_security_reviewer._clear_review_cache()
def fail_snapshot(session):
raise AssertionError("persistent AI Security cache should avoid a cold snapshot")
monkeypatch.setattr(ai_security_reviewer, "_snapshot", fail_snapshot)
with client:
second = client.get("/api/ai/security/summary").json()
assert second["generatedAt"] == first["generatedAt"]
assert second["score"] == first["score"]
_restore_config(previous)
def test_ai_security_persistent_review_ignores_other_dashboard_cache_rows(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
with client:
first = client.get("/api/ai/security/summary").json()
safety = client.get("/api/safety/dashboard").json()
assert safety["cache"]["source"] in {"fresh", "cache"}
from backend.app.services import ai_security_reviewer
ai_security_reviewer._clear_review_cache()
def fail_snapshot(session):
raise AssertionError("dashboard cache rows should not invalidate AI Security persistent cache")
monkeypatch.setattr(ai_security_reviewer, "_snapshot", fail_snapshot)
with client:
second = client.get("/api/ai/security/summary").json()
assert second["generatedAt"] == first["generatedAt"]
assert second["score"] == first["score"]
_restore_config(previous)
def test_ai_security_uses_persistent_media_summary_cache(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.session import engine
from backend.app.services import ai_security_reviewer, media_pipeline_service
media_pipeline_service._SNAPSHOT_CACHE.clear()
monkeypatch.setattr(media_pipeline_service, "_build_snapshot", lambda: _media_snapshot_with_stalled_download())
with Session(engine) as session:
cached = media_pipeline_service.summary(session, force_refresh=True)
assert cached["status"] == "warning"
assert cached["counts"]["stalledDownloads"] == 2
media_pipeline_service._SNAPSHOT_CACHE.clear()
ai_security_reviewer._clear_review_cache()
def fail_media_snapshot():
raise AssertionError("AI Security should reuse the persistent Media Pipeline summary cache")
monkeypatch.setattr(media_pipeline_service, "_build_snapshot", fail_media_snapshot)
with client:
findings = client.get("/api/ai/security/findings?scope=media").json()
media_finding = next(item for item in findings if item["id"] == "ai-sec-media-pipeline-issue")
assert media_finding["affectedResources"] == ["deluge"]
_restore_config(previous)
def test_ai_security_access_list_warning_honors_authentik_coverage(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary",
lambda self: {
"success": True,
"status": "ok",
"warnings": [
{
"type": "public_host_without_access_list",
"severity": "high",
"title": "protected.example.com has no access list",
"evidence": ["protected.example.com", "http://protected-app:8080"],
"suggestedAction": "review_access_protection",
},
{
"type": "public_host_without_access_list",
"severity": "high",
"title": "open.example.com has no access list",
"evidence": ["open.example.com", "http://open-app:8081"],
"suggestedAction": "review_access_protection",
},
],
},
raising=True,
)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": []}, raising=True)
monkeypatch.setattr(
"backend.app.connectors.authentik.AuthentikConnector.auth_coverage",
lambda self: {
"success": True,
"status": "ok",
"publicHosts": [
{"hostId": 1, "domains": ["protected.example.com"], "coverageStatus": "protected"},
{"hostId": 2, "domains": ["open.example.com"], "coverageStatus": "unprotected"},
],
},
raising=True,
)
with client:
findings = client.get("/api/ai/security/findings?scope=exposure").json()
assert not any(finding["id"] == "ai-sec-npm-host-without-access-list" for finding in findings)
_restore_config(previous)
def test_ai_security_authentik_missing_config_with_public_hosts(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": [{"id": 1, "domainNames": ["public.example.com"], "enabled": True}]}, raising=True)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: {**_npm_exposure(), "warnings": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {"success": False, "status": "missing_config", "publicHosts": []}, raising=True)
with client:
findings = client.get("/api/ai/security/findings?scope=auth").json()
assert any(finding["id"] == "ai-sec-authentik-missing-public-hosts" for finding in findings)
_restore_config(previous)
def test_ai_security_connected_npm_public_hosts_with_missing_auth_is_evidence_based(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "status": "ok", "items": [{"id": 1, "domainNames": ["public.example.com"], "enabled": True}]}, raising=True)
monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: {**_npm_exposure(), "warnings": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {"success": False, "status": "missing_config", "publicHosts": []}, raising=True)
with client:
findings = client.get("/api/ai/security/findings?scope=auth").json()
finding = next(item for item in findings if item["id"] == "ai-sec-authentik-missing-public-hosts")
assert finding["severity"] in {"medium", "high"}
assert any(evidence["source"] == "npm" and evidence["label"] == "publicHosts" and evidence["value"] == 1 for evidence in finding["evidence"])
_restore_config(previous)
def test_ai_security_reported_embedded_outpost_with_unknown_health_is_not_unavailable(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {"success": True, "status": "ok", "publicHosts": []}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "status": "warning", "items": [{"type": "embedded_outpost_unavailable", "severity": "warning", "title": "Expected embedded outpost is unavailable or unknown", "evidence": ["warning"]}]}, raising=True)
monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "status": "warning", "available": False, "embeddedExpected": True, "items": [{"id": "embedded", "health": "unknown"}]}, raising=True)
with client:
findings = client.get("/api/ai/security/findings?scope=auth").json()
assert not any(finding["id"] == "ai-sec-auth-outpost-unavailable" for finding in findings)
_restore_config(previous)
def test_ai_security_critical_status_requires_critical_or_high_risk_evidence(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import Policy
from backend.app.db.session import engine
with Session(engine) as session:
policy = session.get(Policy, "policy-volume-delete")
policy.manual = "allowed"
session.add(policy)
session.commit()
with client:
payload = client.get("/api/ai/security/summary").json()
assert payload["status"] == "critical"
assert payload["criticalFindings"] >= 1
assert any(item.get("findingId") == "ai-sec-policy-volume-delete-not-blocked" for item in payload["scoreBreakdown"]["penalties"])
_restore_config(previous)
def test_ai_security_ignores_stale_changed_discovery_when_latest_is_managed(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import App, AppContainerMapping, DiscoveredContainer, DiscoveryRun
from backend.app.db.session import engine
with Session(engine) as session:
app = session.get(App, "nginx_proxy_manager")
if not app:
app = App(id="nginx_proxy_manager", name="Nginx Proxy Manager", category="Network")
app.criticality = "critical"
app.protected = True
app.needs_review = False
app.discovery_status = "managed"
session.add(app)
old_run = DiscoveryRun(
id="disc-old-npm",
status="complete",
started_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=1),
finished_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=1),
)
new_run = DiscoveryRun(
id="disc-new-npm",
status="complete",
started_at=datetime.now(UTC).replace(tzinfo=None),
finished_at=datetime.now(UTC).replace(tzinfo=None),
)
session.add(old_run)
session.add(new_run)
session.add(
DiscoveredContainer(
id="dc-old-npm",
discovery_run_id=old_run.id,
container_id="npm-container",
name="NginxProxyManager",
image="jlesage/nginx-proxy-manager",
state="running",
status="healthy",
suggested_app_type="nginx_proxy_manager",
suggested_category="network",
suggested_criticality="critical",
suggested_connector="nginx_proxy_manager",
suggested_policy_profile="reverse_proxy",
import_status="changed",
matched_app_id="nginx_proxy_manager",
)
)
session.add(
DiscoveredContainer(
id="dc-new-npm",
discovery_run_id=new_run.id,
container_id="npm-container",
name="NginxProxyManager",
image="jlesage/nginx-proxy-manager",
state="running",
status="healthy",
suggested_app_type="nginx_proxy_manager",
suggested_category="network",
suggested_criticality="critical",
suggested_connector="nginx_proxy_manager",
suggested_policy_profile="reverse_proxy",
import_status="managed",
matched_app_id="nginx_proxy_manager",
)
)
session.add(
AppContainerMapping(
id="map-npm-container",
app_id="nginx_proxy_manager",
container_id="npm-container",
container_name="NginxProxyManager",
source="discovery",
is_primary=True,
last_seen_run_id=new_run.id,
)
)
session.commit()
with client:
findings = client.get("/api/ai/security/findings").json()
assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-old-npm" for finding in findings)
assert not any(
finding["category"] == "inventory" and "NginxProxyManager" in finding["title"]
for finding in findings
)
_restore_config(previous)
def test_ai_security_ignores_appops_deployment_discovery_artifacts(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import DiscoveredContainer, DiscoveryRun
from backend.app.db.session import engine
with Session(engine) as session:
run = DiscoveryRun(
id="disc-appops-deploy-artifacts",
status="complete",
started_at=datetime.now(UTC).replace(tzinfo=None),
finished_at=datetime.now(UTC).replace(tzinfo=None),
)
session.add(run)
for suffix, name in (
("current", "unraid-appops-gateway-1216"),
("rollback", "unraid-appops-gateway-1216-pre-20260712T212606Z"),
):
session.add(DiscoveredContainer(
id=f"dc-appops-{suffix}",
discovery_run_id=run.id,
container_id=f"appops-{suffix}",
name=name,
image="unraid-appops-gateway:test",
state="exited" if suffix == "rollback" else "running",
status="healthy",
suggested_app_type="unraid_appops_gateway",
suggested_category="infrastructure",
suggested_criticality="critical",
suggested_connector="none",
suggested_policy_profile="protected_service",
import_status="discovered",
matched_app_id="unraid_appops_gateway_1216",
))
session.commit()
with client:
findings = client.get("/api/ai/security/findings").json()
assert not any(
finding["category"] == "inventory" and "unraid-appops-gateway" in finding["title"].lower()
for finding in findings
)
_restore_config(previous)
def test_ai_security_ignores_changed_discovery_for_protected_mapped_app(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun
from backend.app.db.session import engine
with Session(engine) as session:
app = session.get(App, "nginx_proxy_manager")
if not app:
app = App(id="nginx_proxy_manager", name="Nginx Proxy Manager", category="Network")
app.criticality = "critical"
app.protected = True
app.needs_review = False
app.discovery_status = "managed"
run = DiscoveryRun(id="disc-current-npm", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None))
session.add(app)
session.add(run)
session.add(
DiscoveredContainer(
id="dc-current-npm",
discovery_run_id=run.id,
container_id="npm-container",
name="NginxProxyManager",
image="jlesage/nginx-proxy-manager",
state="running",
status="healthy",
suggested_app_type="nginx_proxy_manager",
suggested_category="network",
suggested_criticality="critical",
suggested_connector="nginx_proxy_manager",
suggested_policy_profile="reverse_proxy",
import_status="changed",
matched_app_id="nginx_proxy_manager",
)
)
session.commit()
with client:
findings = client.get("/api/ai/security/findings").json()
assert not any(
finding["category"] == "inventory" and "NginxProxyManager" in finding["title"]
for finding in findings
)
_restore_config(previous)
def test_ai_security_critical_discovery_links_to_matched_app_detail(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun
from backend.app.db.session import engine
with Session(engine) as session:
app = session.get(App, "homeassistant")
if not app:
app = App(id="homeassistant", name="Home Assistant", category="Automation")
app.criticality = "critical"
app.protected = False
app.needs_review = True
session.add(app)
run = DiscoveryRun(id="disc-ha-review", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None))
session.add(run)
session.add(
DiscoveredContainer(
id="dc-ha-review",
discovery_run_id=run.id,
container_id="ha-container",
name="homeassistant",
image="ghcr.io/home-assistant/home-assistant",
state="running",
status="healthy",
suggested_app_type="home_assistant",
suggested_category="automation",
suggested_criticality="critical",
suggested_connector="home_assistant",
suggested_policy_profile="home_automation",
import_status="changed",
matched_app_id="homeassistant",
)
)
session.commit()
with client:
findings = client.get("/api/ai/security/findings").json()
finding = next(item for item in findings if item["id"] == "ai-sec-inventory-critical-review-dc-ha-review")
hrefs = {action["href"] for action in finding["suggestedActions"]}
assert "/apps/homeassistant" in hrefs
_restore_config(previous)
def test_ai_security_ignores_discovered_row_when_reviewed_canonical_app_exists(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun
from backend.app.db.session import engine
with Session(engine) as session:
app = session.get(App, "home_assistant")
if not app:
app = App(id="home_assistant", name="Home Assistant", category="Infrastructure")
app.criticality = "medium"
app.protected = False
app.needs_review = False
app.discovery_status = "managed"
app.connector_status = "connected"
session.add(app)
run = DiscoveryRun(id="disc-ha-stale", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None))
session.add(run)
session.add(
DiscoveredContainer(
id="dc-ha-stale",
discovery_run_id=run.id,
container_id="ha-container",
name="homeassistant",
image="ghcr.io/home-assistant/home-assistant",
state="running",
status="healthy",
suggested_app_type="home_assistant",
suggested_category="automation",
suggested_criticality="critical",
suggested_connector="home_assistant",
suggested_policy_profile="home_automation",
import_status="discovered",
)
)
session.commit()
with client:
findings = client.get("/api/ai/security/findings").json()
assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-stale" for finding in findings)
assert not any(
finding["category"] == "inventory" and "homeassistant" in finding["title"].lower()
for finding in findings
)
_restore_config(previous)
def test_ai_security_review_refresh_replaces_cached_findings(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun
from backend.app.db.session import engine
with Session(engine) as session:
app = session.get(App, "home_assistant")
if not app:
app = App(id="home_assistant", name="Home Assistant", category="Infrastructure")
app.criticality = "medium"
app.needs_review = True
app.discovery_status = "managed"
session.add(app)
run = DiscoveryRun(id="disc-ha-cache", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None))
session.add(run)
session.add(
DiscoveredContainer(
id="dc-ha-cache",
discovery_run_id=run.id,
container_id="ha-cache-container",
name="homeassistant",
image="ghcr.io/home-assistant/home-assistant",
state="running",
status="healthy",
suggested_app_type="home_assistant",
suggested_category="automation",
suggested_criticality="critical",
suggested_connector="home_assistant",
suggested_policy_profile="home_automation",
import_status="discovered",
)
)
session.commit()
with client:
before = client.get("/api/ai/security/findings").json()
assert any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-cache" for finding in before)
with Session(engine) as session:
app = session.get(App, "home_assistant")
app.needs_review = False
session.add(app)
session.commit()
with client:
refreshed = client.post("/api/ai/security/review").json()
after = client.get("/api/ai/security/findings").json()
assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-cache" for finding in refreshed["findings"])
assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-cache" for finding in after)
_restore_config(previous)
def test_ai_security_secret_policy_audit_and_media_findings(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import AuditEvent, Policy
from backend.app.db.session import engine
with Session(engine) as session:
policy = session.get(Policy, "policy-unraid-mover")
policy.voice = "allowed"
session.add(policy)
session.add(AuditEvent(id="audit-block-1", source="voice", identity="ha", action_id="container.restart", target="authentik", risk="high", policy_decision="blocked", result="failed"))
session.add(AuditEvent(id="audit-block-2", source="voice", identity="ha", action_id="container.restart", target="authentik", risk="high", policy_decision="blocked", result="failed"))
session.add(AuditEvent(id="audit-block-3", source="voice", identity="ha", action_id="container.restart", target="authentik", risk="high", policy_decision="blocked", result="failed"))
session.commit()
monkeypatch.setattr("backend.app.services.media_pipeline_service.summary", lambda session: {"success": True, "status": "warning", "issues": [{"id": "deluge-stalled", "severity": "warning", "title": "Deluge stalled downloads", "description": "2 stalled downloads", "affected": ["deluge"]}], "connectors": {"deluge": {"status": "connected"}}}, raising=False)
with client:
findings = client.get("/api/ai/security/findings").json()
recommendations = client.get("/api/ai/security/recommendations").json()
assert any(finding["id"] == "ai-sec-policy-unraid-mover-voice-allowed" for finding in findings)
assert any(finding["id"] == "ai-sec-audit-repeated-blocked-attempts" for finding in findings)
assert any(finding["category"] == "connector_config" and "missing" in finding["title"].lower() for finding in findings)
media_finding = next(finding for finding in findings if finding["id"] == "ai-sec-media-pipeline-issue")
assert "Deluge stalled downloads" in media_finding["title"]
assert media_finding["affectedResources"] == ["deluge"]
assert any(recommendation["id"] == "rec-ai-sec-media-pipeline-issue" for recommendation in recommendations)
_restore_config(previous)
def test_ai_security_media_finding_uses_summary_counts_when_issues_missing(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
monkeypatch.setattr(
"backend.app.services.media_pipeline_service.summary",
lambda session: {
"success": True,
"status": "warning",
"message": "Media pipeline has items requiring attention.",
"counts": {"stalledDownloads": 4, "failedWidefrogJobs": 0, "failingIndexers": 0, "transcodes": 0},
"risk": {"level": "medium", "reasons": ["Deluge has stalled downloads"]},
"connectors": {"deluge": {"status": "connected"}},
},
raising=False,
)
with client:
findings = client.get("/api/ai/security/findings").json()
media_finding = next(finding for finding in findings if finding["id"] == "ai-sec-media-pipeline-issue")
assert media_finding["affectedResources"] == ["deluge"]
assert media_finding["evidence"][1]["value"] == ["Deluge has stalled downloads"]
_restore_config(previous)
def test_ai_security_missing_secret_actions_deep_link_to_connector_setup(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
with client:
findings = client.get("/api/ai/security/findings?scope=secrets").json()
finding = next(item for item in findings if item["id"] == "ai-sec-missing-secret-portainer_api_key")
hrefs = {action["href"] for action in finding["suggestedActions"]}
assert "/setup?step=connectors&connector=portainer" in hrefs
assert "/connectors" not in hrefs
_restore_config(previous)
def test_ai_security_ignores_stale_setup_failures_after_later_success(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.db.models import AuditEvent
from backend.app.db.session import engine
with Session(engine) as session:
base = datetime.now(UTC).replace(tzinfo=None)
for index in range(3):
session.add(
AuditEvent(
id=f"audit-old-portainer-fail-{index}",
source="manual",
identity="setup",
action_id="setup.test.portainer",
target="portainer",
risk="low",
policy_decision="allowed",
result="failed",
timestamp=base - timedelta(minutes=10 - index),
)
)
session.add(
AuditEvent(
id="audit-new-portainer-success",
source="manual",
identity="setup",
action_id="setup.test.portainer",
target="portainer",
risk="low",
policy_decision="allowed",
result="success",
timestamp=base,
)
)
session.commit()
with client:
findings = client.get("/api/ai/security/findings?scope=audit").json()
assert not any(finding["id"] == "ai-sec-audit-repeated-blocked-attempts" for finding in findings)
_restore_config(previous)
def test_ai_security_lifecycle_routes_and_accept_does_not_execute(tmp_path, monkeypatch):
client, previous = _client(tmp_path, monkeypatch)
with client:
review = client.post("/api/ai/security/review").json()
finding_id = review["findings"][0]["id"]
recommendation_id = review["recommendations"][0]["id"]
acknowledged = client.post(f"/api/ai/security/findings/{finding_id}/acknowledge").json()
dismissed = client.post(f"/api/ai/security/findings/{finding_id}/dismiss").json()
accepted = client.post(f"/api/ai/security/recommendations/{recommendation_id}/accept").json()
assert acknowledged["status"] == "acknowledged"
assert dismissed["status"] == "dismissed"
assert accepted["status"] == "accepted"
assert accepted["applied"] is False
assert accepted["recommendation"]["canAutoApply"] is False
assert all(action["executable"] is False for action in accepted["recommendation"]["suggestedActions"])
_restore_config(previous)
def test_ai_security_redacts_secret_values(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
secret = "AUTHENTIK_TOKEN_VALUE"
monkeypatch.setattr(
"backend.app.connectors.authentik.AuthentikConnector.security_warnings",
lambda self: {"success": True, "items": [{"type": "token_leak", "severity": "critical", "title": f"leaked {secret}", "evidence": [secret], "suggestedAction": "rotate"}]},
raising=True,
)
with client:
payload = client.get("/api/ai/security/findings").json()
assert secret not in str(payload)
assert "[REDACTED]" in str(payload)
_restore_config(previous)
def test_ai_security_does_not_require_optional_widefrog_api_key_when_connected(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
from backend.app.core.config import settings
from backend.app.db.models import Connector
from backend.app.db.session import engine
settings.apis["WIDEFROG_URL"] = "http://widefrog.local"
settings.apis["WIDEFROG_API_KEY"] = None
with Session(engine) as session:
connector = session.get(Connector, "widefrog")
connector.status = "connected"
connector.secret_ref = "WIDEFROG_API_KEY"
session.add(connector)
session.commit()
with client:
findings = client.get("/api/ai/security/findings?scope=secrets").json()
assert all(
finding["id"] != "ai-sec-missing-secret-widefrog_api_key"
for finding in findings
)
assert "WIDEFROG_API_KEY" not in str([finding["title"] for finding in findings])
_restore_config(previous)
def test_ai_security_score_breakdown_is_secret_safe(monkeypatch, tmp_path):
client, previous = _client(tmp_path, monkeypatch)
secret = "SUPER_SECRET_API_KEY_VALUE"
monkeypatch.setattr(
"backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary",
lambda self: {"success": False, "status": "missing_config", "message": f"missing {secret}"},
raising=True,
)
with client:
payload = client.get("/api/ai/security/summary").json()
assert "scoreBreakdown" in payload
assert secret not in str(payload["scoreBreakdown"])
assert "API_KEY_VALUE" not in str(payload["scoreBreakdown"])
_restore_config(previous)