2026-08-30 13:00:33 +02:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-08-13 17:39:22 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
from collections.abc import AsyncGenerator
|
|
|
|
|
|
2026-08-13 17:39:22 +02:00
|
|
|
import pytest
|
2026-08-30 13:00:33 +02:00
|
|
|
import pytest_asyncio
|
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
2026-08-13 17:39:22 +02:00
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
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,
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 17:39:22 +02:00
|
|
|
@pytest.fixture(autouse=True)
|
2026-08-30 13:00:33 +02:00
|
|
|
def _clean_registry():
|
|
|
|
|
"""Fresh universal registry per test."""
|
|
|
|
|
from app.plugins.miniapp_registry import reset_miniapp_registry
|
2026-08-13 17:39:22 +02:00
|
|
|
reset_miniapp_registry()
|
|
|
|
|
yield
|
|
|
|
|
reset_miniapp_registry()
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
# ─── Unit: MiniAppDef + registry ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMiniAppDefUnit:
|
|
|
|
|
def test_def_has_permission_and_settings_schema(self):
|
|
|
|
|
from app.plugins.miniapp_registry import MiniAppDef
|
|
|
|
|
|
|
|
|
|
app = MiniAppDef(
|
2026-08-13 17:39:22 +02:00
|
|
|
app_id="test_app",
|
2026-08-30 13:00:33 +02:00
|
|
|
name="Test",
|
|
|
|
|
permission="tasks:read",
|
|
|
|
|
plugin_name="tasks",
|
|
|
|
|
settings_schema={"type": "object"},
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
2026-08-30 13:00:33 +02:00
|
|
|
assert app.permission == "tasks:read"
|
|
|
|
|
assert app.settings_schema == {"type": "object"}
|
|
|
|
|
assert app.col_span == 1 and app.row_span == 1
|
2026-08-13 17:39:22 +02:00
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
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",
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
2026-08-30 13:00:33 +02:00
|
|
|
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",
|
2026-08-13 17:39:22 +02:00
|
|
|
description="",
|
2026-08-30 13:00:33 +02:00
|
|
|
plugin_name="tasks",
|
|
|
|
|
permission="tasks:read",
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
2026-08-30 13:00:33 +02:00
|
|
|
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,
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
2026-08-30 13:00:33 +02:00
|
|
|
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
|
2026-08-13 17:39:22 +02:00
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
|
|
|
|
|
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"
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
2026-08-30 13:00:33 +02:00
|
|
|
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",
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
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
|
2026-08-13 17:39:22 +02:00
|
|
|
)
|
2026-08-30 13:00:33 +02:00
|
|
|
assert resp.status_code == 200
|
|
|
|
|
for item in resp.json()["items"]:
|
|
|
|
|
assert "dashboard" in item["hosts"]
|
2026-08-13 17:39:22 +02:00
|
|
|
|
2026-08-30 13:00:33 +02:00
|
|
|
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
|