536 lines
19 KiB
Python
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
|