"""M1 — Universal-MiniApp-Registry tests. Phase M: MiniAppDef with permission (fail-closed), settings_schema, spans; manifest-driven registration in the plugin lifecycle; /api/v1/miniapps listing server-side permission-filtered; backward-compatible bridge for the old kommunikation singleton import path. """ from __future__ import annotations from collections.abc import AsyncGenerator import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from app.core.db import close_engine, reset_engine_for_testing from app.core.permission_registry import init_permission_registry from app.core.service_container import get_container from app.main import create_app from app.plugins.builtins.permissions import PermissionsPlugin from app.plugins.builtins.report_generator import ReportGeneratorPlugin from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import ( ORIGIN_HEADER, login_client, seed_tenant_and_users, ) @pytest_asyncio.fixture async def miniapps_app(engine: AsyncEngine, redis_client): """App with permissions + report_generator + tasks installed & activated.""" reset_engine_for_testing(engine) app = create_app() registry = reset_registry_for_testing() registry.initialize(engine, app) init_permission_registry(active_plugin_names={"permissions", "report_generator", "dms", "kommunikation", "tasks"}) container = get_container() await container.initialize() registry.register_plugin(PermissionsPlugin()) registry.register_plugin(ReportGeneratorPlugin()) from app.plugins.builtins.dms.plugin import DmsPlugin from app.plugins.builtins.kommunikation.plugin import KommunikationPlugin from app.plugins.builtins.tasks.plugin import TasksPlugin registry.register_plugin(DmsPlugin()) registry.register_plugin(KommunikationPlugin()) registry.register_plugin(TasksPlugin()) reset_plugin_service_for_testing(registry) _sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) async with _sf() as session: for plugin_name in ( "permissions", "report_generator", "dms", "kommunikation", "tasks", ): await registry.install(session, plugin_name) await registry.activate(session, plugin_name) await session.commit() yield app await close_engine() @pytest_asyncio.fixture async def miniapps_client(miniapps_app) -> AsyncGenerator[AsyncClient, None]: transport = ASGITransport(app=miniapps_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest.fixture(autouse=True) def _clean_registry(): """Fresh universal registry per test.""" from app.plugins.miniapp_registry import reset_miniapp_registry reset_miniapp_registry() yield reset_miniapp_registry() # ─── Unit: MiniAppDef + registry ───────────────────────────────────────────── class TestMiniAppDefUnit: def test_def_has_permission_and_settings_schema(self): from app.plugins.miniapp_registry import MiniAppDef app = MiniAppDef( app_id="test_app", name="Test", permission="tasks:read", plugin_name="tasks", settings_schema={"type": "object"}, ) assert app.permission == "tasks:read" assert app.settings_schema == {"type": "object"} assert app.col_span == 1 and app.row_span == 1 def test_def_defaults_backward_compatible(self): from app.plugins.miniapp_registry import MiniAppDef # old-style registration without the new fields must still work app = MiniAppDef( app_id="legacy", name="Legacy", plugin_name="kommunikation", ) assert app.permission == "" # empty = visible to everyone (old behavior) assert app.settings_schema == {} assert app.col_span == 1 def test_register_and_get(self): from app.plugins.miniapp_registry import get_miniapp_registry reg = get_miniapp_registry() reg.register( app_id="a1", name="A", icon="X", description="", plugin_name="tasks", permission="tasks:read", ) app = reg.get_app("a1") assert app is not None assert app.permission == "tasks:read" def test_register_overwrites_same_app_id(self): from app.plugins.miniapp_registry import get_miniapp_registry reg = get_miniapp_registry() reg.register(app_id="dup", name="One", icon="x", description="", plugin_name="tasks") reg.register(app_id="dup", name="Two", icon="x", description="", plugin_name="tasks") assert reg.get_app("dup").name == "Two" def test_unregister_plugin_cleans_only_own_apps(self): from app.plugins.miniapp_registry import get_miniapp_registry reg = get_miniapp_registry() reg.register(app_id="t1", name="T", icon="x", description="", plugin_name="tasks") reg.register(app_id="k1", name="K", icon="x", description="", plugin_name="kommunikation") reg.unregister_plugin("tasks") assert reg.get_app("t1") is None assert reg.get_app("k1") is not None def test_list_apps_returns_dicts_with_all_fields(self): from app.plugins.miniapp_registry import get_miniapp_registry reg = get_miniapp_registry() reg.register( app_id="full", name="Full", icon="Y", description="desc", plugin_name="tasks", permission="tasks:read", settings_schema={"type": "object"}, col_span=2, row_span=1, ) items = reg.list_apps() assert len(items) == 1 item = items[0] assert item["app_id"] == "full" assert item["permission"] == "tasks:read" assert item["settings_schema"] == {"type": "object"} assert item["col_span"] == 2 assert item["builtin"] is True class TestBridgeImport: """Old import path (kommunikation.miniapp_registry) must keep working.""" def test_old_path_is_same_class(self): from app.plugins.builtins.kommunikation import miniapp_registry as old_mod from app.plugins.miniapp_registry import MiniAppDef, MiniAppRegistry assert old_mod.MiniAppDef is MiniAppDef assert old_mod.MiniAppRegistry is MiniAppRegistry assert old_mod.get_miniapp_registry() is not None # ─── Lifecycle: manifest-driven registration ──────────────────────────────── @pytest.mark.asyncio class TestManifestRegistration: async def test_manifest_dashboard_widgets_registered_on_activate( self, miniapps_app ): """Activating a plugin auto-registers its manifest miniapps (dashboard_widgets alias).""" from app.plugins.miniapp_registry import get_miniapp_registry reg = get_miniapp_registry() app = reg.get_app("tasks_summary") assert app is not None, ( "tasks_summary must be registered via manifest dashboard_widgets alias" ) assert app.permission == "tasks:read" assert app.plugin_name == "tasks" async def test_manifest_widget_carries_component_path( self, miniapps_app ): from app.plugins.miniapp_registry import get_miniapp_registry app = get_miniapp_registry().get_app("tasks_summary") assert app is not None assert app.component == "@/components/dashboard/TasksSummaryWidget" assert "dashboard" in app.hosts async def test_lifecycle_register_and_unregister(self, db_session): """on_activate registers manifest miniapps; on_deactivate removes exactly the plugin's own apps (dummy plugin, direct lifecycle call).""" from unittest.mock import AsyncMock from app.plugins.base import BasePlugin from app.plugins.manifest import FrontendDashboardWidget, PluginManifest from app.plugins.miniapp_registry import get_miniapp_registry class DummyPlugin(BasePlugin): manifest = PluginManifest( name="dummy_miniapp_test", version="1.0.0", display_name="Dummy", description="lifecycle test plugin", dashboard_widgets=[ FrontendDashboardWidget( id="dummy_widget", label_key="dummy", label="Dummy Widget", component="@/components/dashboard/DummyWidget", permission="contacts:read", ) ], ) plugin = DummyPlugin() reg = get_miniapp_registry() reg.register( app_id="foreign", name="F", icon="x", description="", plugin_name="someone_else", ) await plugin.on_activate(db_session, AsyncMock(), AsyncMock()) assert reg.get_app("dummy_widget") is not None assert reg.get_app("dummy_widget").permission == "contacts:read" assert reg.get_app("foreign") is not None await plugin.on_deactivate(db_session, AsyncMock(), AsyncMock()) assert reg.get_app("dummy_widget") is None assert reg.get_app("foreign") is not None # ─── API: /api/v1/miniapps with server-side permission filter ────────────── @pytest.mark.asyncio class TestMiniAppsAPI: async def test_list_requires_auth(self, miniapps_client: AsyncClient): resp = await miniapps_client.get("/api/v1/miniapps") assert resp.status_code in (401, 403) async def test_admin_sees_all_registered_apps( self, miniapps_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(miniapps_client, "admin@tenanta.com") resp = await miniapps_client.get("/api/v1/miniapps", headers=ORIGIN_HEADER) assert resp.status_code == 200, f"{resp.status_code} {resp.text}" items = resp.json()["items"] app_ids = {i["app_id"] for i in items} assert "tasks_summary" in app_ids # from tasks manifest (dashboard_widgets alias) # all items carry the new fields for item in items: assert "permission" in item assert "settings_schema" in item async def test_viewer_sees_only_permitted_apps( self, miniapps_client: AsyncClient, db_session ): """Viewer (contacts:read only) must NOT see tasks_summary (tasks:read).""" await seed_tenant_and_users(db_session) await login_client(miniapps_client, "viewer@tenanta.com") resp = await miniapps_client.get("/api/v1/miniapps", headers=ORIGIN_HEADER) assert resp.status_code == 200 items = resp.json()["items"] app_ids = {i["app_id"] for i in items} assert "tasks_summary" not in app_ids, ( f"viewer sees tasks app without tasks:read permission: {app_ids}" ) async def test_filter_by_host_parameter( self, miniapps_client: AsyncClient, db_session ): """?host=chat|dashboard filters by hosts declared on MiniAppDef.""" await seed_tenant_and_users(db_session) await login_client(miniapps_client, "admin@tenanta.com") resp = await miniapps_client.get( "/api/v1/miniapps?host=dashboard", headers=ORIGIN_HEADER ) assert resp.status_code == 200 for item in resp.json()["items"]: assert "dashboard" in item["hosts"] async def test_single_app_visibility_check( self, miniapps_client: AsyncClient, db_session ): """GET /api/v1/miniapps/{app_id} — visible or 403 for wrong permission.""" await seed_tenant_and_users(db_session) await login_client(miniapps_client, "viewer@tenanta.com") resp = await miniapps_client.get( "/api/v1/miniapps/tasks_summary", headers=ORIGIN_HEADER ) # viewer lacks tasks:read -> fail-closed 403 assert resp.status_code == 403 async def test_unknown_app_404(self, miniapps_client: AsyncClient, db_session): await seed_tenant_and_users(db_session) await login_client(miniapps_client, "admin@tenanta.com") resp = await miniapps_client.get( "/api/v1/miniapps/no_such_app", headers=ORIGIN_HEADER ) assert resp.status_code == 404