Files
leocrm/tests/test_miniapp_registry.py
T

332 lines
13 KiB
Python
Raw Normal View History

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