From 84cb82d2c4dcd325730a918f23878bde0f43f09d Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 30 Aug 2026 13:00:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(M1):=20Universal-MiniApp-Registry=20?= =?UTF-8?q?=E2=80=94=20Plugin-Layer,=20permission=20fail-closed,=20Lifecyc?= =?UTF-8?q?le,=20/api/v1/miniapps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/plugins/miniapp_registry.py: Registry aus kommunikation in Plugin-Layer gehoben (Plattform-Konzept, hosts chat/dashboard/window) - MiniAppDef: permission (fail-closed) + settings_schema + col/row_span + hosts + component + order + builtin - MiniAppContribution + FrontendDashboardWidget (Manifest-Schema) um M1-Felder erweitert — dashboard_widgets ist Alias von miniapps (ein Contribution-Typ, #359-Philosophie) - BasePlugin.on_activate: automatische Manifest-Registrierung; on_deactivate: unregister_plugin (nur eigene Apps) - GET /api/v1/miniapps (server-seitig permission-gefiltert, ?host=) + GET /api/v1/miniapps/{app_id} (403/404 fail-closed) - kommunikation/miniapp_registry.py = Kompatibilitaets-Bruecke (Bestands-Importer unveraendert) - Doku: api-documentation.md + plugin-development-guide.md (MiniApp-Beitragsmuster) TDD: Rot 16 failed -> Gruen 16/16; Regressionen: contracts 23/23, lifecycle+route_order 4/4; ruff clean (M1-Dateien, Vorbestand per Stash bewiesen); create_app OK --- PROGRESS.md | 17 + app/main.py | 2 + app/plugins/base.py | 58 ++- .../kommunikation/miniapp_registry.py | 109 +--- app/plugins/manifest.py | 17 + app/plugins/miniapp_registry.py | 154 ++++++ app/routes/miniapps.py | 63 +++ docs/api-documentation.md | 2 + docs/plugin-development-guide.md | 27 + tests/test_miniapp_registry.py | 466 ++++++++++++------ 10 files changed, 664 insertions(+), 251 deletions(-) create mode 100644 app/plugins/miniapp_registry.py create mode 100644 app/routes/miniapps.py diff --git a/PROGRESS.md b/PROGRESS.md index bab5f8e..3054ef8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -87,6 +87,23 @@ **Live-Bestand analysiert (2026-08-29):** miniapp_registry (kommunikation, 92 Z.), MiniAppContribution (LÜCKE: kein permission-Feld), FrontendDashboardWidget (LÜCKE: kein settings_schema), MiniAppBlock.tsx (Chat-Host fertig), DashboardGrid + 4 Widgets, Dashboard.tsx (170 Z.) mit hardcodierten StatCards/ActivityFeed/System-Metrics (Rückbau-Bestand für M4), @dnd-kit vorhanden. +## Phase M1 — Universal-MiniApp-Registry (2026-08-30) ✅ + +**Umgesetzt:** +- `app/plugins/miniapp_registry.py` (154 Z.): Registry in den Plugin-Layer gehoben (Plattform-Konzept). MiniAppDef erweitert um `permission` (fail-closed, leer = jeder), `settings_schema`, `col_span`/`row_span`, `hosts` (chat/dashboard/window), `component`, `order`, `builtin`. +- Kompatibilitäts-Brücke: `kommunikation/miniapp_registry.py` re-exportiert die Universal-Registry — alle Bestands-Importer (kommunikation contracts, automation routes, tests) unverändert lauffähig. +- Lifecycle: `BasePlugin.on_activate` registriert Manifest-Beiträge automatisch (miniapps + dashboard_widgets-Alias mit component/spans/permission — ein Contribution-Typ, #359-Philosophie); `on_deactivate` entfernt per `unregister_plugin` nur die eigenen Apps. +- Manifest-Schema: `MiniAppContribution` + `FrontendDashboardWidget` um M1-Felder erweitert (settings_schema, hosts etc.). +- API: `GET /api/v1/miniapps` (Server-seitig permission-gefiltert, ?host=), `GET /api/v1/miniapps/{app_id}` (403 fail-closed / 404). + +**Verifiziert (2026-08-30):** +- TDD: Rot 16 errors/failed → ✅ Grün **16/16** (tests/test_miniapp_registry.py: Registry-Unit 6, Bridge-Import 1, Manifest-Registrierung+Lifecycle 3, API 6 inkl. Viewer-Filter-Beweis + Host-Filter + 403/404) +- ✅ Regression: test_contracts.py 23/23, plugin_lifecycle + route_order 4/4, create_app OK +- ✅ ruff clean (M1-Dateien); 2 Ruff-Funde in automation/knowledge = per Stash bewiesener Vorbestand +- ✅ Doku: api-documentation.md (2 Endpoints), plugin-development-guide.md (MiniApp-Beitrag-Muster) + +**Offen in Phase M:** M2 Dashboard-Backend (dashboards-Tabelle), M3 Builder-Frontend (konsumiert /api/v1/miniapps), M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts. + ## Phase N — Workspace-Scopes (2026-08-30 geplant, user-abgestimmt) **User-Vision:** Workspaces als voll anpassbare Arbeitskontexte — jedes Modul pro Workspace auf Teilmengen einschränkbar (z.B. nur Kontakt-Ordner X+Y, nur DMS-Ordner "Angebote", nur Mail-Postfach vertrieb@, nur Kalender "Vertrieb"). Admin-definiert, für zugewiesene User-Gruppen. diff --git a/app/main.py b/app/main.py index 42a4eb0..4388e66 100644 --- a/app/main.py +++ b/app/main.py @@ -55,6 +55,7 @@ from app.routes import ( # noqa: E402 health, import_export, metrics, + miniapps, notifications, outbox, owner_transfer, @@ -595,6 +596,7 @@ def create_app() -> FastAPI: app.include_router(outbox.router) app.include_router(api_tokens.router) app.include_router(approvals.router) + app.include_router(miniapps.router) # ── Register plugin routes for all discovered plugins ── # Routes are registered at app creation time so OpenAPI docs are complete. diff --git a/app/plugins/base.py b/app/plugins/base.py index 8f3cf2a..7a99fde 100644 --- a/app/plugins/base.py +++ b/app/plugins/base.py @@ -53,8 +53,10 @@ class BasePlugin(ABC): ) -> None: """Called when the plugin is activated. - Override to register event listeners and prepare runtime state. - Default implementation subscribes to events listed in the manifest. + Default implementation subscribes to events listed in the manifest and + registers manifest MiniApps (Phase M1): ``miniapps`` contributions plus + ``dashboard_widgets`` entries (alias — one contribution type, #359 + philosophy). Registered automatically here; no per-plugin code needed. """ for event_name in self.manifest.events: handler = self._make_event_handler(event_name) @@ -62,6 +64,8 @@ class BasePlugin(ABC): event_bus.subscribe(event_name, handler) self._container = service_container + self._register_manifest_miniapps() + async def on_deactivate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus ) -> None: @@ -80,6 +84,56 @@ class BasePlugin(ABC): get_hook_registry().unregister_all_for_plugin(self.manifest.name) + # Unregister MiniApps owned by this plugin (Phase M1) + from app.plugins.miniapp_registry import get_miniapp_registry + + get_miniapp_registry().unregister_plugin(self.manifest.name) + + def _register_manifest_miniapps(self) -> None: + """Register manifest MiniApps in the universal registry (Phase M1). + + Sources: + - ``manifest.miniapps`` — native MiniApp contributions + - ``manifest.dashboard_widgets`` — alias: FrontendDashboardWidget entries + become MiniApps with component path + spans + permission so existing + plugin manifests keep working without changes. + """ + from app.plugins.miniapp_registry import get_miniapp_registry + + registry = get_miniapp_registry() + name = self.manifest.name + + for m in getattr(self.manifest, "miniapps", None) or []: + registry.register( + app_id=m.app_id, + name=m.name, + icon=m.icon, + description=m.description, + plugin_name=name, + render_schema=m.render_schema, + permission=getattr(m, "permission", ""), + settings_schema=getattr(m, "settings_schema", {}), + col_span=getattr(m, "col_span", 1), + row_span=getattr(m, "row_span", 1), + hosts=getattr(m, "hosts", None), + order=getattr(m, "order", 100), + ) + + for w in getattr(self.manifest, "dashboard_widgets", None) or []: + registry.register( + app_id=w.id, + name=w.label or w.id, + icon=w.icon, + description="", + plugin_name=name, + permission=w.permission, + col_span=w.col_span, + row_span=w.row_span, + hosts=["chat", "dashboard", "window"], + component=w.component, + order=w.order, + ) + async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None: """Called when the plugin is uninstalled (before data tables are dropped). diff --git a/app/plugins/builtins/kommunikation/miniapp_registry.py b/app/plugins/builtins/kommunikation/miniapp_registry.py index e9777b8..31e0e4a 100644 --- a/app/plugins/builtins/kommunikation/miniapp_registry.py +++ b/app/plugins/builtins/kommunikation/miniapp_registry.py @@ -1,92 +1,25 @@ -"""Mini-App registry for plugin-provided interactive chat components.""" +"""Compatibility bridge — the MiniApp registry moved to the plugin layer. + +The registry is a platform concept now (Phase M1): MiniApps are universal +building blocks for Chat, Dashboard and Windows. This module re-exports the +universal registry so every existing importer (kommunikation contracts, +automation routes, tests) keeps working unchanged. +""" from __future__ import annotations -import logging -from typing import Any +from app.plugins.miniapp_registry import ( # noqa: F401 + DEFAULT_HOSTS, + MiniAppDef, + MiniAppRegistry, + get_miniapp_registry, + reset_miniapp_registry, +) -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) - - -class MiniAppDef(BaseModel): - """Definition of a mini-app that plugins can register.""" - - app_id: str = Field(..., description="Unique app identifier") - name: str = Field(..., description="Display name") - icon: str = Field(default="app", description="Icon name") - description: str = Field(default="", description="App description") - plugin_name: str = Field(..., description="Plugin that registered this app") - render_schema: dict[str, Any] = Field( - default_factory=dict, description="JSON schema for frontend rendering" - ) - - -class MiniAppRegistry: - """Registry for mini-apps that plugins provide for chat embedding.""" - - def __init__(self) -> None: - self._apps: dict[str, MiniAppDef] = {} - - def register( - self, - app_id: str, - name: str, - icon: str, - description: str, - plugin_name: str, - render_schema: dict[str, Any] | None = None, - ) -> None: - """Register a mini-app.""" - app = MiniAppDef( - app_id=app_id, - name=name, - icon=icon, - description=description, - plugin_name=plugin_name, - render_schema=render_schema or {}, - ) - self._apps[app_id] = app - logger.info(f"Mini-app registered: {app_id} by {plugin_name}") - - def unregister(self, app_id: str) -> None: - """Unregister a mini-app.""" - app = self._apps.pop(app_id, None) - if app: - logger.info(f"Mini-app unregistered: {app_id}") - - def unregister_plugin(self, plugin_name: str) -> None: - """Unregister all mini-apps from a specific plugin.""" - to_remove = [app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name] - for app_id in to_remove: - self._apps.pop(app_id, None) - if to_remove: - logger.info(f"Unregistered {len(to_remove)} mini-apps from plugin {plugin_name}") - - def list_apps(self) -> list[dict[str, Any]]: - """List all available mini-apps for frontend.""" - return [app.model_dump() for app in self._apps.values()] - - def get_app(self, app_id: str) -> MiniAppDef | None: - """Get a specific mini-app definition.""" - return self._apps.get(app_id) - - -# ─── Singleton helpers ─── - -_registry: MiniAppRegistry | None = None - - -def get_miniapp_registry() -> MiniAppRegistry: - """Return the shared singleton MiniAppRegistry instance.""" - global _registry - if _registry is None: - _registry = MiniAppRegistry() - return _registry - - -def reset_miniapp_registry() -> None: - """Reset the singleton instance (useful for tests).""" - global _registry - _registry = None +__all__ = [ + "MiniAppDef", + "MiniAppRegistry", + "get_miniapp_registry", + "reset_miniapp_registry", + "DEFAULT_HOSTS", +] diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index 501f92f..aa76a1d 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -148,6 +148,10 @@ class FrontendDashboardWidget(BaseModel): col_span: int = Field(default=1, description="Grid column span (1-4)") row_span: int = Field(default=1, description="Grid row span") permission: str = Field(default="", description="Optional permission required") + settings_schema: dict[str, Any] = Field( + default_factory=dict, + description="JSON schema for per-instance settings (Phase M1)", + ) class CustomFieldDefinition(BaseModel): @@ -173,6 +177,19 @@ class MiniAppContribution(BaseModel): icon: str = Field(default="AppWindow") description: str = Field(default="") render_schema: dict[str, Any] = Field(default_factory=dict) + # Phase M1: universal MiniApp fields + permission: str = Field(default="", description="Required permission (fail-closed)") + settings_schema: dict[str, Any] = Field( + default_factory=dict, description="JSON schema for per-instance settings" + ) + col_span: int = Field(default=1, ge=1, le=12, description="Grid column span") + row_span: int = Field(default=1, ge=1, le=12, description="Grid row span") + hosts: list[str] = Field( + default_factory=lambda: ["chat", "dashboard", "window"], + description="Hosts this MiniApp may appear in", + ) + component: str = Field(default="", description="Frontend component path") + order: int = Field(default=100, description="Sort order in palettes") class PluginManifest(BaseModel): diff --git a/app/plugins/miniapp_registry.py b/app/plugins/miniapp_registry.py new file mode 100644 index 0000000..114be1a --- /dev/null +++ b/app/plugins/miniapp_registry.py @@ -0,0 +1,154 @@ +"""Universal MiniApp registry — platform-level (Phase M1). + +MiniApps are the platform's universal UI building blocks, hostable in Chat, +Dashboard, Windows and AI agent output. Every plugin registers its MiniApps +here (manifest-driven via BasePlugin lifecycle or programmatically at +runtime). The registry is deliberately host-agnostic: hosts declare where +an app may appear, the registry itself never renders anything. + +History: grew out of kommunikation/miniapp_registry.py (chat-only, 92 +lines). That module is now a compatibility bridge re-exporting this one — +all existing importers (kommunikation contracts, automation routes) keep +working unchanged. + +Security model (fail-closed): +- ``MiniAppDef.permission`` — apps requiring a permission the user lacks are + filtered out server-side by the /api/v1/miniapps listing and answered + with 403 on single-app lookup. Empty permission = visible to everyone + (backward compatible with the old chat miniapps). +- Hosts additionally restrict where an app may appear (?host= filter). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +DEFAULT_HOSTS: list[str] = ["chat", "dashboard", "window"] + + +class MiniAppDef(BaseModel): + """Definition of a MiniApp that plugins can register.""" + + app_id: str = Field(..., description="Unique app identifier") + name: str = Field(..., description="Display name") + icon: str = Field(default="AppWindow", description="Icon name (lucide or emoji)") + description: str = Field(default="", description="App description") + plugin_name: str = Field(..., description="Plugin that registered this app") + render_schema: dict[str, Any] = Field( + default_factory=dict, description="JSON schema for frontend rendering" + ) + # ── Phase M1 extensions ── + permission: str = Field( + default="", + description="Required permission (fail-closed). Empty = everyone.", + ) + settings_schema: dict[str, Any] = Field( + default_factory=dict, + description="JSON schema for the per-instance settings form", + ) + col_span: int = Field(default=1, ge=1, le=12, description="Grid column span") + row_span: int = Field(default=1, ge=1, le=12, description="Grid row span") + hosts: list[str] = Field( + default_factory=lambda: list(DEFAULT_HOSTS), + description="Hosts this app may appear in (chat, dashboard, window)", + ) + component: str = Field( + default="", description="Frontend component path (for manifest-contributed apps)" + ) + order: int = Field(default=100, description="Sort order in palettes") + builtin: bool = Field( + default=True, description="Registered in-process (vs. future remote apps)" + ) + + +class MiniAppRegistry: + """Registry for MiniApps provided by system and plugins.""" + + def __init__(self) -> None: + self._apps: dict[str, MiniAppDef] = {} + + def register( + self, + app_id: str, + name: str, + icon: str = "AppWindow", + description: str = "", + plugin_name: str = "system", + render_schema: dict[str, Any] | None = None, + permission: str = "", + settings_schema: dict[str, Any] | None = None, + col_span: int = 1, + row_span: int = 1, + hosts: list[str] | None = None, + component: str = "", + order: int = 100, + ) -> None: + """Register or replace a MiniApp.""" + app = MiniAppDef( + app_id=app_id, + name=name, + icon=icon, + description=description, + plugin_name=plugin_name, + render_schema=render_schema or {}, + permission=permission, + settings_schema=settings_schema or {}, + col_span=col_span, + row_span=row_span, + hosts=hosts if hosts is not None else list(DEFAULT_HOSTS), + component=component, + order=order, + ) + self._apps[app_id] = app + logger.info("MiniApp registered: %s by %s", app_id, plugin_name) + + def unregister(self, app_id: str) -> None: + """Unregister a MiniApp.""" + app = self._apps.pop(app_id, None) + if app: + logger.info("MiniApp unregistered: %s", app_id) + + def unregister_plugin(self, plugin_name: str) -> None: + """Unregister all MiniApps from a specific plugin.""" + to_remove = [ + app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name + ] + for app_id in to_remove: + self._apps.pop(app_id, None) + if to_remove: + logger.info("Unregistered %d MiniApps from plugin %s", len(to_remove), plugin_name) + + def list_apps(self, host: str | None = None) -> list[dict[str, Any]]: + """List MiniApp definitions, optionally filtered by host.""" + apps = self._apps.values() + if host: + apps = [a for a in apps if host in a.hosts] + return [app.model_dump() for app in apps] + + def get_app(self, app_id: str) -> MiniAppDef | None: + """Get a specific MiniApp definition.""" + return self._apps.get(app_id) + + +# ─── Singleton helpers ── + +_registry: MiniAppRegistry | None = None + + +def get_miniapp_registry() -> MiniAppRegistry: + """Return the shared singleton MiniAppRegistry instance.""" + global _registry + if _registry is None: + _registry = MiniAppRegistry() + return _registry + + +def reset_miniapp_registry() -> None: + """Reset the singleton instance (useful for tests).""" + global _registry + _registry = None diff --git a/app/routes/miniapps.py b/app/routes/miniapps.py new file mode 100644 index 0000000..a4bbd52 --- /dev/null +++ b/app/routes/miniapps.py @@ -0,0 +1,63 @@ +"""MiniApps API — universal registry listing (Phase M1). + +Platform-level endpoint (the registry is a platform concept, not owned by +a single plugin): lists MiniApps with server-side permission filtering +(fail-closed) and optional host filter. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from app.core.permissions import check_permission +from app.deps import get_current_user +from app.plugins.miniapp_registry import get_miniapp_registry + +router = APIRouter(prefix="/api/v1/miniapps", tags=["miniapps"]) + + +def _user_permits(current_user: dict, app: dict) -> bool: + """Empty permission = visible to everyone; otherwise fail-closed check.""" + required = app.get("permission") or "" + if not required: + return True + if current_user.get("is_system_admin"): + return True + return check_permission(current_user, required) + + +@router.get("") +async def list_miniapps( + host: str | None = None, + current_user: dict = Depends(get_current_user), +): + """List MiniApps visible to the current user (permission-filtered). + + ``?host=chat|dashboard|window`` filters by the hosts declared on the + MiniApp definition. + """ + registry = get_miniapp_registry() + items = [a for a in registry.list_apps(host=host) if _user_permits(current_user, a)] + items.sort(key=lambda a: a.get("order", 100)) + return {"items": items, "total": len(items)} + + +@router.get("/{app_id}") +async def get_miniapp( + app_id: str, + current_user: dict = Depends(get_current_user), +): + """Get a single MiniApp definition (403 without permission, 404 unknown).""" + app = get_miniapp_registry().get_app(app_id) + if app is None: + raise HTTPException(404, detail={"detail": "MiniApp not found", "code": "not_found"}) + data = app.model_dump() + if not _user_permits(current_user, data): + raise HTTPException( + 403, + detail={ + "detail": f"Permission '{data['permission']}' required", + "code": "forbidden", + }, + ) + return data diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 82483ef..0c7c02a 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -524,6 +524,8 @@ Admin-only. Rebuild regenerates the embedding + TSV; purge sets embedding/TSV to | POST | `/api/v1/reports/einvoice/render` | Rechnungsdaten -> EN16931/XRechnung CII-XML (Phase L5 Format-Layer). | | POST | `/api/v1/reports/einvoice/validate` | Rechnungsdaten pruefen (BT/BG-Pflichtfelder, 422 mit Fehlliste). | | POST | `/api/v1/reports/einvoice/render-for` | E-Invoice fuer Entitaet via einvoice_data()-Contract (Verkaufsmodul-Andockpunkt). | +| GET | `/api/v1/miniapps` | Verfuegbare MiniApps (server-seitig permission-gefiltert, ?host=chat\|dashboard\|window) (Phase M1) | +| GET | `/api/v1/miniapps/{app_id}` | Einzelne MiniApp (403 ohne Permission, 404 unbekannt) (Phase M1) | ### entity-links (Entity Linking) diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index d5ef504..ad92213 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -2710,3 +2710,30 @@ class SalesContract: Validierung + CII-XML-Generierung uebernimmt report_generator (`einvoice.py`, EN16931 BT/BG-Pflichtfelder, kommerzielles Rounding, XRechnung-3.0-Guideline). Kein Beitrag -> `POST /einvoice/render-for` antwortet 404 `no_data_source`. + +## MiniApp-Beitrag (Phase M1 — universal, Chat + Dashboard + Windows) + +MiniApps sind die universalen UI-Bausteine der Plattform. Registrierung: + +1. **Manifest (deklarativ, empfohlen):** `dashboard_widgets`- oder `miniapps`-Eintraege werden bei Plugin-Aktivierung AUTOMATISCH in die Universal-Registry uebernommen (BasePlugin.on_activate). Bestehende Manifeste laufen unveraendert weiter. +2. **Programmatisch (z.B. dynamische Apps):** `get_miniapp_registry().register(...)` aus `app.plugins.miniapp_registry`. + +```python +# Manifest-Beispiel (beide Formen sind gleichwertig) +dashboard_widgets=[FrontendDashboardWidget( + id="tasks_summary", label="Tasks", component="@/components/dashboard/TasksSummaryWidget", + col_span=1, row_span=1, permission="tasks:read", settings_schema={...}, +)] +miniapps=[MiniAppContribution( + app_id="my_app", name="My App", permission="myplugin:read", + settings_schema={"type": "object", "properties": {...}}, + hosts=["chat", "dashboard", "window"], component="@/components/myplugin/MyMiniApp", +)] +``` + +**Regeln:** +- `permission` wird server-seitig fail-closed gefiltert (`GET /api/v1/miniapps`, 403 bei Einzel-Lookup) +- `settings_schema` speist generische Per-Instanz-Settings-Forms (Dashboard-Builder M3) +- `hosts` beschraenkt wo die App erscheinen darf (chat/dashboard/window) +- Deaktivierung des Plugins entfernt seine MiniApps automatisch (unregister_plugin) +- Alte Chat-Registry-Imports (`kommunikation.miniapp_registry`) funktionieren ueber Kompatibilitaets-Bruecke weiter diff --git a/tests/test_miniapp_registry.py b/tests/test_miniapp_registry.py index fcead13..49649d1 100644 --- a/tests/test_miniapp_registry.py +++ b/tests/test_miniapp_registry.py @@ -1,187 +1,331 @@ -"""Tests for MiniAppRegistry singleton pattern and plugin lifecycle integration.""" +"""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 -import pytest +from collections.abc import AsyncGenerator -from app.plugins.builtins.kommunikation.miniapp_registry import ( - MiniAppRegistry, - get_miniapp_registry, - reset_miniapp_registry, +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 _reset_registry(): - """Ensure a clean singleton before and after each test.""" +def _clean_registry(): + """Fresh universal registry per test.""" + from app.plugins.miniapp_registry import reset_miniapp_registry reset_miniapp_registry() yield reset_miniapp_registry() -class TestSingletonPattern: - def test_get_miniapp_registry_returns_same_instance(self): - """get_miniapp_registry() must return the same object every call.""" - r1 = get_miniapp_registry() - r2 = get_miniapp_registry() - assert r1 is r2 - assert isinstance(r1, MiniAppRegistry) - - def test_reset_miniapp_registry_creates_new_instance(self): - """After reset, a new instance is created.""" - r1 = get_miniapp_registry() - reset_miniapp_registry() - r2 = get_miniapp_registry() - assert r1 is not r2 - - def test_reset_miniapp_registry_sets_none(self): - """reset_miniapp_registry() should set the global to None.""" - get_miniapp_registry() # ensure singleton exists - reset_miniapp_registry() - # Access the module-level global directly - import app.plugins.builtins.kommunikation.miniapp_registry as mod - assert mod._registry is None +# ─── Unit: MiniAppDef + registry ───────────────────────────────────────────── -class TestRegisterAndList: - def test_register_and_list_apps(self): - """register() adds an app that appears in list_apps().""" - registry = get_miniapp_registry() - registry.register( +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 App", - icon="🧪", - description="A test app", - plugin_name="test_plugin", - render_schema={"type": "object"}, + name="Test", + permission="tasks:read", + plugin_name="tasks", + settings_schema={"type": "object"}, ) - apps = registry.list_apps() - assert len(apps) == 1 - assert apps[0]["app_id"] == "test_app" - assert apps[0]["name"] == "Test App" - assert apps[0]["plugin_name"] == "test_plugin" + assert app.permission == "tasks:read" + assert app.settings_schema == {"type": "object"} + assert app.col_span == 1 and app.row_span == 1 - def test_register_overwrites_same_app_id(self): - """Registering the same app_id replaces the previous entry.""" - registry = get_miniapp_registry() - registry.register( - app_id="dup_app", - name="First", - icon="1", - description="", - plugin_name="plugin_a", - ) - registry.register( - app_id="dup_app", - name="Second", - icon="2", - description="", - plugin_name="plugin_b", - ) - apps = registry.list_apps() - assert len(apps) == 1 - assert apps[0]["name"] == "Second" + def test_def_defaults_backward_compatible(self): + from app.plugins.miniapp_registry import MiniAppDef - def test_list_apps_empty_by_default(self): - """Fresh registry has no apps.""" - registry = get_miniapp_registry() - assert registry.list_apps() == [] - - -class TestUnregisterPlugin: - def test_unregister_plugin_removes_all_apps(self): - """unregister_plugin() removes all apps from that plugin.""" - registry = get_miniapp_registry() - registry.register("a1", "A1", "x", "", "plugin_x") - registry.register("a2", "A2", "x", "", "plugin_x") - registry.register("b1", "B1", "x", "", "plugin_y") - - registry.unregister_plugin("plugin_x") - - apps = registry.list_apps() - assert len(apps) == 1 - assert apps[0]["app_id"] == "b1" - - def test_unregister_plugin_noop_for_unknown(self): - """unregister_plugin() for a non-existent plugin is a no-op.""" - registry = get_miniapp_registry() - registry.register("a1", "A1", "x", "", "plugin_x") - registry.unregister_plugin("nonexistent") - assert len(registry.list_apps()) == 1 - - def test_unregister_single_app(self): - """unregister() removes a specific app by id.""" - registry = get_miniapp_registry() - registry.register("a1", "A1", "x", "", "plugin_x") - registry.register("a2", "A2", "x", "", "plugin_x") - - registry.unregister("a1") - apps = registry.list_apps() - assert len(apps) == 1 - assert apps[0]["app_id"] == "a2" - - -class TestPluginLifecycleIntegration: - """Test that plugin activation/deactivation uses the shared singleton.""" - - def test_activation_registers_in_shared_registry(self): - """When a plugin registers miniapps, they appear in the shared registry.""" - registry = get_miniapp_registry() - # Simulate what automation/plugin.py on_activate does - registry.register( - app_id="automation_agent", - name="Agent Runner", - icon="🤖", - description="Run an AI agent", - plugin_name="automation", - render_schema={"type": "object"}, - ) - - # A different caller (e.g. a route) should see the same apps - shared = get_miniapp_registry() - assert shared is registry - apps = shared.list_apps() - assert any(a["app_id"] == "automation_agent" for a in apps) - - def test_deactivation_removes_from_shared_registry(self): - """When a plugin deactivates, its miniapps are removed from the shared registry.""" - registry = get_miniapp_registry() - registry.register( - app_id="automation_agent", - name="Agent Runner", - icon="🤖", - description="Run an AI agent", - plugin_name="automation", - ) - registry.register( - app_id="komm_contact", - name="Contact Picker", - icon="👤", - description="Pick a contact", + # 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 - # Simulate on_deactivate for automation - registry.unregister_plugin("automation") + def test_register_and_get(self): + from app.plugins.miniapp_registry import get_miniapp_registry - apps = registry.list_apps() - assert len(apps) == 1 - assert apps[0]["plugin_name"] == "kommunikation" - - def test_cross_plugin_visibility(self): - """Apps registered by plugin A are visible to plugin B via the singleton.""" - # Plugin A registers - registry_a = get_miniapp_registry() - registry_a.register( - app_id="plugin_a_app", - name="A App", - icon="x", + reg = get_miniapp_registry() + reg.register( + app_id="a1", + name="A", + icon="X", description="", - plugin_name="plugin_a", + 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", ) - # Plugin B queries - registry_b = get_miniapp_registry() - assert registry_a is registry_b - apps = registry_b.list_apps() - assert any(a["app_id"] == "plugin_a_app" for a in apps) + 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