Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,535 @@
|
||||
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
|
||||
Reference in new issue
Block a user