diff --git a/app/routes/plugins.py b/app/routes/plugins.py index 219772c..da35d3b 100644 --- a/app/routes/plugins.py +++ b/app/routes/plugins.py @@ -44,46 +44,24 @@ async def list_plugins( return {"plugins": plugins, "total": len(plugins)} -@router.get("/{name}") -async def get_plugin_detail( - name: str, +@router.get("/active-manifests") +async def get_active_manifests( db: AsyncSession = Depends(get_db), - current_user: dict = Depends(require_permission("plugins:read")), + # ARCH-003 fix: every authenticated user needs the UI manifests for the + # dynamic sidebar/routes — the data is pure UI metadata; actual data + # access stays protected by each endpoint's own permission. + current_user: dict = Depends(get_current_user), ): - """Get detail for a single plugin: manifest metadata + DB status. + """Get UI manifests for all active plugins. - BUG-024 fix: this endpoint was missing entirely (404). + Returns menu_items, page_routes, detail_tabs, settings_pages, and + dashboard_widgets contributed by each active plugin. Used by the + frontend PluginRegistry to dynamically register routes, sidebar items, + settings pages, and detail tabs. """ - registry = get_registry() - plugin = registry.get_plugin(name) - if plugin is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={"detail": f"Plugin '{name}' not found", "code": "not_found"}, - ) - - m = plugin.manifest - record = registry.get_db_status(name) - return { - "name": m.name, - "version": m.version, - "display_name": getattr(m, "display_name", None) or m.name, - "description": getattr(m, "description", None), - "author": getattr(m, "author", None), - "is_core": bool(getattr(m, "is_core", False)), - "permissions": list(m.permissions), - "depends_on": list(getattr(m, "depends_on", []) or []), - "status": { - "installed": record is not None, - "active": bool(record.active) if record is not None else False, - "version_installed": getattr(record, "version", None), - }, - "menu_items": len(getattr(m, "menu_items", []) or []), - "page_routes": len(getattr(m, "page_routes", []) or []), - "detail_tabs": len(getattr(m, "detail_tabs", []) or []), - "settings_pages": len(getattr(m, "settings_pages", []) or []), - "dashboard_widgets": len(getattr(m, "dashboard_widgets", []) or []), - } + service = get_plugin_service() + manifests = await service.get_active_manifests(db) + return {"plugins": manifests, "total": len(manifests)} @router.get("/manifest") @@ -134,24 +112,46 @@ async def check_plugin_updates( return {"updates": updates, "total": len(updates)} -@router.get("/active-manifests") -async def get_active_manifests( +@router.get("/{name}") +async def get_plugin_detail( + name: str, db: AsyncSession = Depends(get_db), - # ARCH-003 fix: every authenticated user needs the UI manifests for the - # dynamic sidebar/routes — the data is pure UI metadata; actual data - # access stays protected by each endpoint's own permission. - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(require_permission("plugins:read")), ): - """Get UI manifests for all active plugins. + """Get detail for a single plugin: manifest metadata + DB status. - Returns menu_items, page_routes, detail_tabs, settings_pages, and - dashboard_widgets contributed by each active plugin. Used by the - frontend PluginRegistry to dynamically register routes, sidebar items, - settings pages, and detail tabs. + BUG-024 fix: this endpoint was missing entirely (404). """ - service = get_plugin_service() - manifests = await service.get_active_manifests(db) - return {"plugins": manifests, "total": len(manifests)} + registry = get_registry() + plugin = registry.get_plugin(name) + if plugin is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"detail": f"Plugin '{name}' not found", "code": "not_found"}, + ) + + m = plugin.manifest + record = registry.get_db_status(name) + return { + "name": m.name, + "version": m.version, + "display_name": getattr(m, "display_name", None) or m.name, + "description": getattr(m, "description", None), + "author": getattr(m, "author", None), + "is_core": bool(getattr(m, "is_core", False)), + "permissions": list(m.permissions), + "depends_on": list(getattr(m, "depends_on", []) or []), + "status": { + "installed": record is not None, + "active": bool(record.active) if record is not None else False, + "version_installed": getattr(record, "version", None), + }, + "menu_items": len(getattr(m, "menu_items", []) or []), + "page_routes": len(getattr(m, "page_routes", []) or []), + "detail_tabs": len(getattr(m, "detail_tabs", []) or []), + "settings_pages": len(getattr(m, "settings_pages", []) or []), + "dashboard_widgets": len(getattr(m, "dashboard_widgets", []) or []), + } @router.get("/{name}/config") diff --git a/tests/test_plugin_route_order.py b/tests/test_plugin_route_order.py new file mode 100644 index 0000000..0ef9e18 --- /dev/null +++ b/tests/test_plugin_route_order.py @@ -0,0 +1,40 @@ +"""Regressionstest: FastAPI-Route-Matching im Plugin-Router. + +GET /{name} (d9aed51) wurde vor /active-manifests registriert und +verschluckte literale Routen: /plugins/active-manifests wurde als +Plugin-Name 'active-manifests' interpretiert → 404 → Frontend-Sidebar +ohne Plugin-Menüeinträge (nur statische Dashboard/Kontakte/System). + +Fix: statische Routen werden vor dynamischen /{name} registriert. +""" +from fastapi.routing import APIRoute + +from app.routes.plugins import router + + +def test_active_manifests_registered_before_dynamic_name_route(): + """active-manifests muss vor /{name} registriert sein (FastAPI matcht in Reihenfolge).""" + paths = [r.path for r in router.routes if isinstance(r, APIRoute)] + assert '/api/v1/plugins/active-manifests' in paths, 'active-manifests Route fehlt' + assert '/api/v1/plugins/{name}' in paths, '/{name} Route fehlt' + assert paths.index('/api/v1/plugins/active-manifests') < paths.index( + '/api/v1/plugins/{name}' + ), ( + '/active-manifests muss VOR /{name} registriert werden — ' + 'sonst matcht /{name} das Literal und liefert 404 ' + '(Sidebar ohne Plugin-Menüeinträge)' + ) + + +def test_other_literal_routes_also_before_dynamic_name_route(): + """Alle literalen Unterrouten müssen vor /{name} registriert sein.""" + literals = [ + '/api/v1/plugins/manifest', + '/api/v1/plugins/updates', + '/api/v1/plugins/active-manifests', + ] + paths = [r.path for r in router.routes if isinstance(r, APIRoute)] + name_idx = paths.index('/api/v1/plugins/{name}') + for lit in literals: + assert lit in paths, f'{lit} fehlt' + assert paths.index(lit) < name_idx, f'{lit} muss vor /{{name}} registriert sein'