feat(M1): Universal-MiniApp-Registry — Plugin-Layer, permission fail-closed, Lifecycle, /api/v1/miniapps
Check Cross-Plugin Imports / check (push) Has been cancelled

- 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
This commit is contained in:
Agent Zero
2026-08-30 13:00:33 +02:00
parent 5eade3e005
commit 84cb82d2c4
10 changed files with 664 additions and 251 deletions
+17
View File
@@ -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. **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) ## 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. **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.
+2
View File
@@ -55,6 +55,7 @@ from app.routes import ( # noqa: E402
health, health,
import_export, import_export,
metrics, metrics,
miniapps,
notifications, notifications,
outbox, outbox,
owner_transfer, owner_transfer,
@@ -595,6 +596,7 @@ def create_app() -> FastAPI:
app.include_router(outbox.router) app.include_router(outbox.router)
app.include_router(api_tokens.router) app.include_router(api_tokens.router)
app.include_router(approvals.router) app.include_router(approvals.router)
app.include_router(miniapps.router)
# ── Register plugin routes for all discovered plugins ── # ── Register plugin routes for all discovered plugins ──
# Routes are registered at app creation time so OpenAPI docs are complete. # Routes are registered at app creation time so OpenAPI docs are complete.
+56 -2
View File
@@ -53,8 +53,10 @@ class BasePlugin(ABC):
) -> None: ) -> None:
"""Called when the plugin is activated. """Called when the plugin is activated.
Override to register event listeners and prepare runtime state. Default implementation subscribes to events listed in the manifest and
Default implementation subscribes to events listed in the manifest. 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: for event_name in self.manifest.events:
handler = self._make_event_handler(event_name) handler = self._make_event_handler(event_name)
@@ -62,6 +64,8 @@ class BasePlugin(ABC):
event_bus.subscribe(event_name, handler) event_bus.subscribe(event_name, handler)
self._container = service_container self._container = service_container
self._register_manifest_miniapps()
async def on_deactivate( async def on_deactivate(
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
) -> None: ) -> None:
@@ -80,6 +84,56 @@ class BasePlugin(ABC):
get_hook_registry().unregister_all_for_plugin(self.manifest.name) 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: async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
"""Called when the plugin is uninstalled (before data tables are dropped). """Called when the plugin is uninstalled (before data tables are dropped).
@@ -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 from __future__ import annotations
import logging from app.plugins.miniapp_registry import ( # noqa: F401
from typing import Any DEFAULT_HOSTS,
MiniAppDef,
MiniAppRegistry,
get_miniapp_registry,
reset_miniapp_registry,
)
from pydantic import BaseModel, Field __all__ = [
"MiniAppDef",
logger = logging.getLogger(__name__) "MiniAppRegistry",
"get_miniapp_registry",
"reset_miniapp_registry",
class MiniAppDef(BaseModel): "DEFAULT_HOSTS",
"""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
+17
View File
@@ -148,6 +148,10 @@ class FrontendDashboardWidget(BaseModel):
col_span: int = Field(default=1, description="Grid column span (1-4)") col_span: int = Field(default=1, description="Grid column span (1-4)")
row_span: int = Field(default=1, description="Grid row span") row_span: int = Field(default=1, description="Grid row span")
permission: str = Field(default="", description="Optional permission required") 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): class CustomFieldDefinition(BaseModel):
@@ -173,6 +177,19 @@ class MiniAppContribution(BaseModel):
icon: str = Field(default="AppWindow") icon: str = Field(default="AppWindow")
description: str = Field(default="") description: str = Field(default="")
render_schema: dict[str, Any] = Field(default_factory=dict) 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): class PluginManifest(BaseModel):
+154
View File
@@ -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
+63
View File
@@ -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
+2
View File
@@ -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/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/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). | | 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) ### entity-links (Entity Linking)
+27
View File
@@ -2710,3 +2710,30 @@ class SalesContract:
Validierung + CII-XML-Generierung uebernimmt report_generator (`einvoice.py`, Validierung + CII-XML-Generierung uebernimmt report_generator (`einvoice.py`,
EN16931 BT/BG-Pflichtfelder, kommerzielles Rounding, XRechnung-3.0-Guideline). EN16931 BT/BG-Pflichtfelder, kommerzielles Rounding, XRechnung-3.0-Guideline).
Kein Beitrag -> `POST /einvoice/render-for` antwortet 404 `no_data_source`. 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
+305 -161
View File
@@ -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 from __future__ import annotations
import pytest from collections.abc import AsyncGenerator
from app.plugins.builtins.kommunikation.miniapp_registry import ( import pytest
MiniAppRegistry, import pytest_asyncio
get_miniapp_registry, from httpx import ASGITransport, AsyncClient
reset_miniapp_registry, 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) @pytest.fixture(autouse=True)
def _reset_registry(): def _clean_registry():
"""Ensure a clean singleton before and after each test.""" """Fresh universal registry per test."""
from app.plugins.miniapp_registry import reset_miniapp_registry
reset_miniapp_registry() reset_miniapp_registry()
yield yield
reset_miniapp_registry() reset_miniapp_registry()
class TestSingletonPattern: # ─── Unit: MiniAppDef + registry ─────────────────────────────────────────────
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
class TestRegisterAndList: class TestMiniAppDefUnit:
def test_register_and_list_apps(self): def test_def_has_permission_and_settings_schema(self):
"""register() adds an app that appears in list_apps().""" from app.plugins.miniapp_registry import MiniAppDef
registry = get_miniapp_registry()
registry.register( app = MiniAppDef(
app_id="test_app", app_id="test_app",
name="Test App", name="Test",
icon="🧪", permission="tasks:read",
description="A test app", plugin_name="tasks",
plugin_name="test_plugin", settings_schema={"type": "object"},
render_schema={"type": "object"},
) )
apps = registry.list_apps() assert app.permission == "tasks:read"
assert len(apps) == 1 assert app.settings_schema == {"type": "object"}
assert apps[0]["app_id"] == "test_app" assert app.col_span == 1 and app.row_span == 1
assert apps[0]["name"] == "Test App"
assert apps[0]["plugin_name"] == "test_plugin"
def test_register_overwrites_same_app_id(self): def test_def_defaults_backward_compatible(self):
"""Registering the same app_id replaces the previous entry.""" from app.plugins.miniapp_registry import MiniAppDef
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_list_apps_empty_by_default(self): # old-style registration without the new fields must still work
"""Fresh registry has no apps.""" app = MiniAppDef(
registry = get_miniapp_registry() app_id="legacy",
assert registry.list_apps() == [] name="Legacy",
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",
plugin_name="kommunikation", 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 def test_register_and_get(self):
registry.unregister_plugin("automation") from app.plugins.miniapp_registry import get_miniapp_registry
apps = registry.list_apps() reg = get_miniapp_registry()
assert len(apps) == 1 reg.register(
assert apps[0]["plugin_name"] == "kommunikation" app_id="a1",
name="A",
def test_cross_plugin_visibility(self): icon="X",
"""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",
description="", 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 await plugin.on_activate(db_session, AsyncMock(), AsyncMock())
registry_b = get_miniapp_registry() assert reg.get_app("dummy_widget") is not None
assert registry_a is registry_b assert reg.get_app("dummy_widget").permission == "contacts:read"
apps = registry_b.list_apps() assert reg.get_app("foreign") is not None
assert any(a["app_id"] == "plugin_a_app" for a in apps)
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