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

536 lines
19 KiB
Python

from datetime import UTC, datetime
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, Session, create_engine, select
@pytest.fixture
def api_client(tmp_path, monkeypatch):
db_path = tmp_path / "setup_reconcile.db"
data_dir = tmp_path / "data"
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
monkeypatch.setenv("APPOPS_DATA_DIR", str(data_dir))
monkeypatch.setenv("APPOPS_ENV", "production")
monkeypatch.setenv("APPOPS_PORT", "1216")
monkeypatch.setenv("APPOPS_SECRET_KEY", "test-secret-key-with-enough-length")
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
for name in [
"PORTAINER_URL",
"PORTAINER_API_KEY",
"PORTAINER_ENDPOINT_ID",
"UNRAID_URL",
"UNRAID_API_KEY",
"NPM_URL",
"NPM_IDENTITY",
"NPM_SECRET",
"AUTHENTIK_URL",
"AUTHENTIK_TOKEN",
"HOME_ASSISTANT_URL",
"HOME_ASSISTANT_TOKEN",
]:
monkeypatch.delenv(name, raising=False)
from backend.app.main import app
from backend.app.core.config import settings
from backend.app.db import session
from backend.app.db.init_db import init_db
settings.db_path = str(db_path)
settings.data_dir = str(data_dir)
settings.environment = "production"
settings.port = 1216
settings.secret_key = "test-secret-key-with-enough-length"
settings.secret_key_source = "env"
settings.voice_token = "test-voice-token"
for name in [
"PORTAINER_URL",
"PORTAINER_API_KEY",
"PORTAINER_ENDPOINT_ID",
"UNRAID_URL",
"UNRAID_API_KEY",
"NPM_URL",
"NPM_IDENTITY",
"NPM_SECRET",
"AUTHENTIK_URL",
"AUTHENTIK_TOKEN",
"HOME_ASSISTANT_URL",
"HOME_ASSISTANT_TOKEN",
]:
settings.apis[name] = None
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()
try:
with TestClient(app) as client:
yield client
finally:
settings.environment = "development"
settings.port = 1216
settings.secret_key = None
settings.secret_key_source = "missing"
settings.voice_token = None
def _make_required_complete() -> None:
from backend.app.db import session as db_session
from backend.app.db.models import App, AppContainerMapping, Connector, DiscoveryRun
with Session(db_session.engine) as session:
portainer = session.get(Connector, "portainer")
portainer.status = "connected"
session.add(portainer)
run = DiscoveryRun(
id="run-required-complete",
source="portainer",
finished_at=datetime.now(UTC).replace(tzinfo=None),
status="success",
containers_found=1,
apps_matched=1,
)
session.add(run)
app = session.get(App, "widefrog")
app.needs_review = False
app.connector_status = "connected"
app.container_status = "running"
session.add(app)
session.add(
AppContainerMapping(
id="mapping-widefrog",
app_id="widefrog",
container_id="container-widefrog",
container_name="widefrog",
last_seen_run_id=run.id,
)
)
session.commit()
def test_required_complete_optional_missing_is_live_ready_after_reconcile(api_client):
_make_required_complete()
result = api_client.post("/api/setup/reconcile").json()
status = result["after"]
assert result["success"] is True
assert status["firstRunCompleted"] is True
assert status["setupComplete"] is True
assert status["liveReady"] is True
assert status["coverageComplete"] is False
assert status["securityCoveragePartial"] is True
assert status["requiredStepsComplete"] is True
assert status["reviewBlockers"] == 0
assert status["managedApps"] == len(api_client.get("/api/apps").json())
assert status["summary"]["appsManaged"] == status["managedApps"]
assert "nginx_proxy_manager" in status["missingOptionalSources"]
assert "AUTHENTIK_TOKEN" not in str(status)
def test_setup_counts_current_container_names_not_historical_docker_ids(api_client):
from backend.app.db import session as db_session
from backend.app.db.models import AppContainerMapping, DiscoveredContainer, DiscoveryRun
from backend.app.services.inventory_discovery import invalidate_discovery_cache
with Session(db_session.engine) as session:
old_run = DiscoveryRun(
id="run-old-container-id",
source="portainer",
started_at=datetime(2026, 1, 1, 10, 0, 0),
finished_at=datetime(2026, 1, 1, 10, 1, 0),
status="success",
)
current_run = DiscoveryRun(
id="run-current-container-id",
source="portainer",
started_at=datetime(2026, 1, 2, 10, 0, 0),
finished_at=datetime(2026, 1, 2, 10, 1, 0),
status="success",
)
session.add(old_run)
session.add(current_run)
session.add(DiscoveredContainer(
id="discovered-old-id",
discovery_run_id=old_run.id,
container_id="docker-old-id",
name="recreated-app",
image="local/recreated:old",
state="exited",
status="exited",
import_status="unmapped",
))
session.add(DiscoveredContainer(
id="discovered-current-id",
discovery_run_id=current_run.id,
container_id="docker-current-id",
name="recreated-app",
image="local/recreated:current",
state="running",
status="running",
import_status="managed",
matched_app_id="widefrog",
))
session.add(AppContainerMapping(
id="mapping-recreated-current",
app_id="widefrog",
container_id="docker-current-id",
container_name="recreated-app",
last_seen_run_id=current_run.id,
))
session.commit()
invalidate_discovery_cache()
status = api_client.get("/api/setup/status").json()
assert status["summary"]["containersDiscovered"] == 1
assert status["summary"]["appsNeedReview"] == 0
assert status["needsReview"] == status["summary"]["appsNeedReview"]
def test_apps_without_registered_connector_use_none_status(api_client):
apps = {app["id"]: app for app in api_client.get("/api/apps").json()}
assert apps["gitea"]["connectorStatus"] == "none"
assert apps["jellyfin"]["connectorStatus"] == "none"
def test_missing_optional_unraid_npm_auth_does_not_block_required_setup(api_client):
_make_required_complete()
status = api_client.post("/api/setup/reconcile").json()["after"]
assert status["setupComplete"] is True
assert status["liveReady"] is True
assert status["coverageComplete"] is False
assert {step["id"]: step["status"] for step in status["steps"]}["unraid"] == "warning"
assert {step["id"]: step["status"] for step in status["steps"]}["connectors"] == "warning"
def test_connector_status_sync_applies_stored_settings_once(api_client, monkeypatch):
_make_required_complete()
from backend.app.db import session as db_session
from backend.app.services import inventory_discovery, setup_service
calls = 0
def apply_once(session):
nonlocal calls
calls += 1
monkeypatch.setattr(setup_service, "apply_stored_connector_settings", apply_once)
with Session(db_session.engine) as session:
result = inventory_discovery.sync_app_connector_statuses(session)
assert result["success"] is True
assert calls == 1
def test_status_sync_refreshes_running_and_ignored_mapped_apps(api_client):
_make_required_complete()
from backend.app.db import session as db_session
from backend.app.db.models import App, AppContainerMapping, DiscoveredContainer
from backend.app.services import inventory_discovery
with Session(db_session.engine) as session:
widefrog = session.get(App, "widefrog")
widefrog.status = "warning"
widefrog.container_status = "exited"
session.add(widefrog)
gitea = session.get(App, "gitea")
gitea.status = "warning"
gitea.container_status = "exited"
session.add(gitea)
session.add(
DiscoveredContainer(
id="discovered-widefrog-running",
discovery_run_id="run-required-complete",
container_id="container-widefrog",
name="widefrog",
image="widefrog:test",
state="running",
status="healthy",
import_status="managed",
matched_app_id="widefrog",
)
)
session.add(
DiscoveredContainer(
id="discovered-widefrog-retired-helper",
discovery_run_id="run-required-complete",
container_id="container-widefrog-retired-helper",
name="widefrog-retired-helper",
image="widefrog-helper:test",
state="missing",
status="missing",
import_status="missing",
matched_app_id="widefrog",
)
)
session.add(
AppContainerMapping(
id="mapping-widefrog-retired-helper",
app_id="widefrog",
container_id="container-widefrog-retired-helper",
container_name="widefrog-retired-helper",
last_seen_run_id="run-required-complete",
)
)
session.add(
DiscoveredContainer(
id="discovered-gitea-migrate",
discovery_run_id="run-required-complete",
container_id="container-gitea-migrate",
name="gitea-migrate",
image="gitea-migrate:test",
state="exited",
status="unhealthy",
import_status="ignored",
matched_app_id="gitea",
)
)
session.add(
AppContainerMapping(
id="mapping-gitea-migrate",
app_id="gitea",
container_id="container-gitea-migrate",
container_name="gitea-migrate",
last_seen_run_id="run-required-complete",
)
)
session.commit()
inventory_discovery.invalidate_discovery_cache()
with Session(db_session.engine) as session:
result = inventory_discovery.sync_app_connector_statuses(session)
widefrog = session.get(App, "widefrog")
gitea = session.get(App, "gitea")
assert result["success"] is True
assert widefrog.status == "healthy"
assert widefrog.container_status == "running"
assert gitea.status == "healthy"
assert gitea.container_status == "ignored"
def test_latest_discovery_cache_reuses_short_lived_snapshot(api_client, monkeypatch):
_make_required_complete()
from backend.app.db import session as db_session
from backend.app.services import inventory_discovery
calls = 0
original_exec = Session.exec
def counting_exec(self, statement, *args, **kwargs):
nonlocal calls
if "discovered_containers" in str(statement):
calls += 1
return original_exec(self, statement, *args, **kwargs)
inventory_discovery.invalidate_discovery_cache()
monkeypatch.setattr(Session, "exec", counting_exec)
with Session(db_session.engine) as session:
first = inventory_discovery._latest_by_container(session)
second = inventory_discovery._latest_by_container(session)
inventory_discovery.invalidate_discovery_cache()
third = inventory_discovery._latest_by_container(session)
assert len(first) == len(second) == len(third)
assert calls == 2
def test_connectors_list_is_snapshot_by_default(api_client, monkeypatch):
from backend.app.api import routes_connectors
def fail_if_live_tested(self):
raise AssertionError("plain connector list should not run live connector tests")
monkeypatch.setattr(routes_connectors.PortainerConnector, "test_connection", fail_if_live_tested)
response = api_client.get("/api/connectors")
assert response.status_code == 200
assert any(item["id"] == "portainer" for item in response.json())
def test_setup_status_is_fast_snapshot_without_connector_sync(api_client, monkeypatch):
_make_required_complete()
from backend.app.services import inventory_discovery
def fail_if_synced(session):
raise AssertionError("setup status should not sync connector statuses")
monkeypatch.setattr(inventory_discovery, "sync_app_connector_statuses", fail_if_synced)
payload = api_client.get("/api/setup/status").json()
assert payload["requiredStepsComplete"] is True
assert payload["managedApps"] >= 1
def test_voice_step_warns_when_home_assistant_connector_is_missing(api_client):
_make_required_complete()
status = api_client.post("/api/setup/reconcile").json()["after"]
steps = {step["id"]: step["status"] for step in status["steps"]}
assert status["liveReady"] is True
assert "home_assistant" in status["missingOptionalSources"]
assert steps["voice"] == "warning"
def test_home_assistant_connector_settings_use_token_secret_contract(api_client):
payload = api_client.get("/api/settings/connectors").json()
home_assistant = next(item for item in payload if item["connectorId"] == "home_assistant")
assert home_assistant["secretRef"] == "HOME_ASSISTANT_TOKEN"
assert home_assistant["missing"] == ["HOME_ASSISTANT_URL", "HOME_ASSISTANT_TOKEN"]
assert "HOME_ASSISTANT_API_KEY" not in str(home_assistant)
def test_reconcile_sets_first_run_completed_and_audit(api_client):
from backend.app.db import session as db_session
from backend.app.db.models import AuditEvent
_make_required_complete()
payload = api_client.post("/api/setup/reconcile").json()
assert payload["before"]["firstRunCompleted"] is False
assert payload["after"]["firstRunCompleted"] is True
assert payload["after"]["setupCompletedAt"]
with Session(db_session.engine) as session:
audit = session.exec(select(AuditEvent).where(AuditEvent.action_id == "setup.reconcile")).first()
assert audit is not None
assert audit.result == "success"
def test_reconcile_with_blockers_does_not_set_first_run_completed(api_client):
payload = api_client.post("/api/setup/reconcile").json()
assert payload["success"] is False
assert payload["after"]["firstRunCompleted"] is False
assert payload["after"]["setupComplete"] is False
assert payload["blockers"]
def test_mark_complete_requires_confirm(api_client):
response = api_client.post("/api/setup/mark-complete", json={"confirm": "wrong"})
assert response.status_code == 400
assert response.json()["detail"] == "confirm_required"
def test_mark_complete_writes_audit(api_client):
from backend.app.db import session as db_session
from backend.app.db.models import AuditEvent
payload = api_client.post("/api/setup/mark-complete", json={"confirm": "MARK_SETUP_COMPLETE"}).json()
assert payload["success"] is True
assert payload["status"]["firstRunCompleted"] is True
with Session(db_session.engine) as session:
audit = session.exec(select(AuditEvent).where(AuditEvent.action_id == "setup.mark_complete")).first()
assert audit is not None
assert audit.result == "success"
def test_runtime_info_uses_setup_status(api_client):
_make_required_complete()
api_client.post("/api/setup/reconcile")
runtime = api_client.get("/api/runtime/info").json()
assert runtime["setupComplete"] is True
assert runtime["liveReady"] is True
assert runtime["firstRunCompleted"] is True
assert runtime["coverageComplete"] is False
assert runtime["canonicalPort"] == 1216
assert runtime["setupStatus"]["setupComplete"] is True
assert "nginx_proxy_manager" in runtime["missingOptionalSources"]
assert "test-secret-key" not in str(runtime)
def test_setup_status_does_not_return_secret_values(api_client):
secret = "PORTAINER_RECONCILE_SECRET"
api_client.patch(
"/api/settings/connectors/portainer",
json={"url": "http://portainer.local", "endpointId": "2", "verifyTls": False, "apiKey": secret},
)
payload = api_client.get("/api/setup/status").json()
assert secret not in str(payload)
def test_existing_first_run_completed_preserved_after_init_db(api_client):
from backend.app.db.init_db import init_db
_make_required_complete()
api_client.post("/api/setup/reconcile")
init_db()
payload = api_client.get("/api/setup/status").json()
assert payload["firstRunCompleted"] is True
def test_completed_first_run_keeps_live_ready_with_later_review_items(api_client):
from backend.app.db import session as db_session
from backend.app.db.models import DiscoveredContainer
_make_required_complete()
api_client.post("/api/setup/reconcile")
with Session(db_session.engine) as session:
session.add(
DiscoveredContainer(
id="dc-new-after-setup",
discovery_run_id="run-required-complete",
container_id="container-new-after-setup",
name="new-lab-app",
image="local/new-lab-app:latest",
state="running",
status="running",
import_status="unmapped",
suggested_app_type="unknown",
suggested_category="unknown",
suggested_criticality="low",
suggested_connector="none",
suggested_policy_profile="generic_unknown",
)
)
session.commit()
from backend.app.services.inventory_discovery import invalidate_discovery_cache
invalidate_discovery_cache()
status = api_client.get("/api/setup/status").json()
assert status["firstRunCompleted"] is True
assert status["setupComplete"] is True
assert status["liveReady"] is True
assert status["requiredStepsComplete"] is True
assert status["reviewBlockers"] == 0
assert status["summary"]["appsNeedReview"] == 1
assert status["needsReview"] == 1
review_step = {step["id"]: step for step in status["steps"]}["review"]
assert review_step["status"] == "complete"
assert review_step["label"] == "Reviewbaseline vastgelegd"
def test_notification_rules_do_not_reset_first_run_completed(api_client):
from backend.app.db import session as db_session
from backend.app.db.init_db import init_db
from backend.app.db.models import NotificationRule
_make_required_complete()
api_client.post("/api/setup/reconcile")
with Session(db_session.engine) as session:
rule = session.get(NotificationRule, "notify-coverage-incomplete")
rule.channels_json = ["dashboard", "home_assistant"]
session.add(rule)
session.commit()
init_db()
payload = api_client.get("/api/setup/status").json()
assert payload["firstRunCompleted"] is True