feat(M1): Universal-MiniApp-Registry — Plugin-Layer, permission fail-closed, Lifecycle, /api/v1/miniapps
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
+17
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 B queries
|
plugin = DummyPlugin()
|
||||||
registry_b = get_miniapp_registry()
|
reg = get_miniapp_registry()
|
||||||
assert registry_a is registry_b
|
reg.register(
|
||||||
apps = registry_b.list_apps()
|
app_id="foreign", name="F", icon="x", description="",
|
||||||
assert any(a["app_id"] == "plugin_a_app" for a in apps)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user