"""M4 — System-Rückbau tests. Core-owned MiniApps (audit_activity, system_metrics) registered by the host; contacts_stats as a native manifest miniapp with component (fixes the base.py gap where native miniapps did not carry their frontend component); the dashboard seed only places renderable apps (component present) — chat interaction apps without a component stay off dashboard layouts. """ from __future__ import annotations import pytest from httpx import AsyncClient from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users @pytest.fixture(autouse=True) def _registry_with_system_apps(): """Fresh registry per test WITH the core system miniapps registered.""" from app.plugins.miniapp_registry import ( reset_miniapp_registry, ) reset_miniapp_registry() from app.core.system_miniapps import register_system_miniapps register_system_miniapps() yield reset_miniapp_registry() # ═══════════════════════════════════════════════════════════════ # Unit: system miniapp definitions # ═══════════════════════════════════════════════════════════════ class TestSystemMiniAppDefs: def test_audit_activity_definition(self): from app.plugins.miniapp_registry import get_miniapp_registry app = get_miniapp_registry().get_app("audit_activity") assert app is not None, "audit_activity must be registered" assert app.plugin_name == "system" assert app.permission == "audit:read" assert app.component == "@/components/dashboard/AuditActivityWidget" assert "dashboard" in app.hosts # settings_schema drives the generic settings form (max_items) fields = app.settings_schema.get("fields", []) assert any(f["name"] == "max_items" for f in fields) def test_system_metrics_definition(self): from app.plugins.miniapp_registry import get_miniapp_registry app = get_miniapp_registry().get_app("system_metrics") assert app is not None, "system_metrics must be registered" assert app.plugin_name == "system" assert app.permission == "settings:read" assert app.component == "@/components/dashboard/SystemMetricsWidget" assert app.col_span >= 2 def test_native_manifest_miniapp_carries_component(self): """contacts_stats lives in the native miniapps manifest section and carries its frontend component (base.py must pass it through).""" from app.plugins.builtins.contacts.plugin import ContactsPlugin contributions = ContactsPlugin().manifest.miniapps stats = [m for m in contributions if m.app_id == "contacts_stats"] assert len(stats) == 1 assert stats[0].component == "@/components/dashboard/ContactsStatsWidget" assert stats[0].permission == "contacts:read" def test_base_plugin_registers_native_miniapp_with_component(self): """The lifecycle path registers native miniapps INCLUDING component (M4 fix for the M1 gap where only the dashboard_widgets alias passed components through).""" from app.plugins.builtins.contacts.plugin import ContactsPlugin from app.plugins.miniapp_registry import get_miniapp_registry plugin = ContactsPlugin() plugin._register_manifest_miniapps() app = get_miniapp_registry().get_app("contacts_stats") assert app is not None assert app.component == "@/components/dashboard/ContactsStatsWidget" # dashboard_widgets alias still works assert get_miniapp_registry().get_app("recent_contacts") is not None # ═══════════════════════════════════════════════════════════════ # API: permission-filtered visibility # ═══════════════════════════════════════════════════════════════ @pytest.mark.asyncio class TestSystemMiniAppsApi: async def test_admin_sees_system_apps(self, client: AsyncClient, db_session): await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/miniapps", headers=ORIGIN_HEADER) assert resp.status_code == 200 app_ids = {i["app_id"] for i in resp.json()["items"]} assert "audit_activity" in app_ids assert "system_metrics" in app_ids async def test_viewer_does_not_see_system_apps( self, client: AsyncClient, db_session ): """Viewer has neither audit:read nor settings:read -> fail-closed.""" await seed_tenant_and_users(db_session) await login_client(client, "viewer@tenanta.com") resp = await client.get("/api/v1/miniapps", headers=ORIGIN_HEADER) assert resp.status_code == 200 app_ids = {i["app_id"] for i in resp.json()["items"]} assert "audit_activity" not in app_ids assert "system_metrics" not in app_ids # ═══════════════════════════════════════════════════════════════ # Seed: only renderable apps (component) go onto dashboard layouts # ═══════════════════════════════════════════════════════════════ @pytest.mark.asyncio class TestSeedComponentFilter: async def test_seed_excludes_apps_without_component( self, client: AsyncClient, db_session ): """Chat interaction apps without a frontend component must not be seeded onto dashboard layouts (production measurement 2026-08-30: 9 seeded widgets, only 3 renderable).""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") from app.plugins.miniapp_registry import get_miniapp_registry reg = get_miniapp_registry() reg.register( app_id="renderable_app", name="Renderable", plugin_name="test", permission="", component="@/components/x", order=90, ) reg.register( app_id="chat_only_app", name="Chat Only", plugin_name="test", permission="", component="", order=95, ) resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER) assert resp.status_code == 200 items = resp.json() assert len(items) == 1 seeded_ids = { w["app_id"] for w in items[0]["layout"]["tabs"][0]["widgets"] } assert "renderable_app" in seeded_ids assert "chat_only_app" not in seeded_ids # system apps are renderable and (for the admin) permitted -> seeded assert "audit_activity" in seeded_ids assert "system_metrics" in seeded_ids