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