4a25ac1379
Check Cross-Plugin Imports / check (push) Has been cancelled
Verifikation: Alle 17 Audit-Findings gegen den Code geprueft — alle bestaetigt. Backend-Lifecycle-Fixes umgesetzt; 4 Frontend-Plugin-Architektur-Punkte als Phase Q in die Roadmap eingeplant. - P1 list_workspaces: Module + User-Counts gebuendelt laden (Editor-Overwrite-Bug) - P1 active-manifests: Tenant-Deaktivierung (tenant_plugin_activation) filtern - P1 uninstall: volle Service-Deactivation VOR registry.uninstall() - P1 ContractRegistry: DB-Aktivstatus-Guard (Restart-Edge-Case) + Re-Activate - P1/P2 Field-Definitions: voller Lifecycle (register/unregister) im Service - P1/P2 Contact-Felddefinitionen (39) ins ContactsPlugin-Manifest verschoben - P1 12 fehlende Permission-Keys registriert (AST-Scan: 0 fehlend) - P2 contact_folder -> ContactsPlugin; ENTITY_PLUGIN_OWNERS wird befuellt - P2 Entity-Permission-Fallback fail-closed statt contacts:read - P2 forgejo_error_reporter is_core=False; DMS is_core=True (ADR-020) - P2 Worker: Contacts-Trash-Cleanup ins Plugin (get_job_modules-Discovery) - P1/P2 DSGVO-Export delegiert an DSAR-Collector (kein Core->Contacts) - P2 False-green Tests korrigiert (or True, veraltete Route-Count-Assertion) Verifikation: tests/test_audit_architecture_fixes.py 17/17; Regressionen gruen (contacts_lifecycle, entity_registry, workspace_scopes, rbac, lifecycle_service); Combo-Order-Test 35/35; Cross-Plugin-Checker 497/0; compileall sauber; ruff auf 7-Error-Baseline. Doku: PROGRESS.md Audit-Section, PLATFORM_ROADMAP.md Phase Q (Q1-Q4), plugin-development-guide.md Lifecycle, permissions.md Katalog.
290 lines
13 KiB
Python
290 lines
13 KiB
Python
"""Test: Contacts plugin lifecycle — deactivation deregisters everything.
|
|
|
|
Verifies that when the Contacts plugin is deactivated:
|
|
- Entity models (contact, contacts, company) are removed from ENTITY_MODELS
|
|
- Permissions (contacts:read, contacts:write, contacts:delete) are unregistered
|
|
- Restore config for 'contact' entity type is removed
|
|
- History hooks for 'contact' are unregistered
|
|
- The app still starts and core functionality works
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.core.restore_registry import get_restore_registry, reset_restore_registry_for_testing
|
|
from app.core.hooks import get_hook_registry
|
|
from app.core.permission_registry import (
|
|
get_permission_registry,
|
|
init_permission_registry,
|
|
)
|
|
from app.services.entity_permission_service import ENTITY_MODELS
|
|
|
|
|
|
class TestContactsPluginLifecycle:
|
|
"""Test Contacts plugin full lifecycle — activate, deactivate, verify deregistration."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_registries(self):
|
|
"""Reset registries before each test."""
|
|
reset_restore_registry_for_testing()
|
|
init_permission_registry(set())
|
|
get_hook_registry()._reset_for_testing()
|
|
yield
|
|
# Cleanup
|
|
reset_restore_registry_for_testing()
|
|
init_permission_registry(set())
|
|
get_hook_registry()._reset_for_testing()
|
|
|
|
def test_contacts_plugin_registers_on_activate(self):
|
|
"""ContactsPlugin.on_activate registers entity models, permissions, restore, history."""
|
|
from app.plugins.builtins.contacts.plugin import ContactsPlugin
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
plugin = ContactsPlugin()
|
|
|
|
# Before activation: nothing registered
|
|
reg = get_restore_registry()
|
|
assert not reg.is_registered("contact")
|
|
# Audit P2 (false-green): this used to be
|
|
# ``assert "contact" not in ENTITY_MODELS or True`` — always true.
|
|
# Entity models are registered by the conftest bootstrap for ALL
|
|
# discovered plugins (mirroring main.py), so "contact" IS present
|
|
# before on_activate; what must NOT be registered yet is the
|
|
# restore config (checked above) and the plugin gate:
|
|
assert not get_permission_registry().is_plugin_active("contacts")
|
|
|
|
# Activate
|
|
import asyncio
|
|
db = AsyncMock()
|
|
container = MagicMock()
|
|
event_bus = MagicMock()
|
|
event_bus.subscribe = MagicMock()
|
|
event_bus.unsubscribe = MagicMock()
|
|
|
|
asyncio.get_event_loop().run_until_complete(
|
|
plugin.on_activate(db, container, event_bus)
|
|
)
|
|
|
|
# Verify restore config registered
|
|
assert reg.is_registered("contact")
|
|
config = reg.get("contact")
|
|
assert config is not None
|
|
assert config.restore_permission == "contacts:write"
|
|
|
|
# Verify history hooks registered (hook registry has actions)
|
|
hook_reg = get_hook_registry()
|
|
# The hooks should be registered for contact.after_create/update/delete
|
|
all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {}
|
|
assert any("contact.after_create" in k for k in all_hooks)
|
|
|
|
def test_contacts_plugin_deregisters_on_deactivate(self):
|
|
"""ContactsPlugin.on_deactivate removes restore config and history hooks."""
|
|
from app.plugins.builtins.contacts.plugin import ContactsPlugin
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
plugin = ContactsPlugin()
|
|
db = AsyncMock()
|
|
container = MagicMock()
|
|
event_bus = MagicMock()
|
|
event_bus.subscribe = MagicMock()
|
|
event_bus.unsubscribe = MagicMock()
|
|
|
|
import asyncio
|
|
loop = asyncio.get_event_loop()
|
|
|
|
# Activate first
|
|
loop.run_until_complete(plugin.on_activate(db, container, event_bus))
|
|
assert get_restore_registry().is_registered("contact")
|
|
|
|
# Deactivate
|
|
loop.run_until_complete(plugin.on_deactivate(db, container, event_bus))
|
|
|
|
# Verify restore config removed
|
|
assert not get_restore_registry().is_registered("contact")
|
|
|
|
# Verify history hooks removed (hook registry should have no contact hooks)
|
|
hook_reg = get_hook_registry()
|
|
all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {}
|
|
assert not any("contact.after_create" in k for k in all_hooks)
|
|
|
|
def test_contacts_permissions_from_manifest_not_core(self):
|
|
"""contacts:* permissions come from plugin manifest, not CORE_PERMISSIONS."""
|
|
from app.core.permission_registry import CORE_PERMISSIONS
|
|
from app.plugins.builtins.contacts.plugin import ContactsPlugin
|
|
|
|
# contacts:* should NOT be in CORE_PERMISSIONS
|
|
core_keys = {p["key"] for p in CORE_PERMISSIONS}
|
|
assert "contacts:read" not in core_keys
|
|
assert "contacts:write" not in core_keys
|
|
assert "contacts:delete" not in core_keys
|
|
|
|
# contacts:* should be in plugin manifest permissions
|
|
plugin = ContactsPlugin()
|
|
assert "contacts:read" in plugin.manifest.permissions
|
|
assert "contacts:write" in plugin.manifest.permissions
|
|
assert "contacts:delete" in plugin.manifest.permissions
|
|
|
|
def test_contacts_entity_models_from_plugin(self):
|
|
"""Contact entity models come from plugin get_entity_models(), not hardcoded."""
|
|
from app.plugins.builtins.contacts.plugin import ContactsPlugin
|
|
|
|
plugin = ContactsPlugin()
|
|
models = plugin.get_entity_models()
|
|
|
|
assert "contact" in models
|
|
assert "contacts" in models
|
|
assert "company" in models
|
|
# All should be the same Contact model
|
|
from app.models.contact import Contact
|
|
assert models["contact"] is Contact
|
|
assert models["contacts"] is Contact
|
|
assert models["company"] is Contact
|
|
|
|
def test_app_still_starts_without_contacts_special_case(self):
|
|
"""App creates successfully with Contacts as a plugin, not a Core special case.
|
|
|
|
Audit P2 (false-green / stale assertion): the old ``> 100 routes``
|
|
assertion reflected the PRE-plugin architecture where business
|
|
routes were hardcoded in main.py. Since the plugin refactor,
|
|
create_app() mounts only CORE routes (~85); plugin routes are
|
|
mounted by the registry lifecycle. The real "no special case"
|
|
proof is that no contacts route is statically included.
|
|
"""
|
|
from app.main import create_app
|
|
app = create_app()
|
|
assert app is not None
|
|
# Core routes must exist
|
|
assert len(app.routes) > 50
|
|
# Contacts is a plugin: its routes must NOT be hardcoded in the
|
|
# core app (they are mounted via registry.activate at startup).
|
|
static_paths = {getattr(r, "path", "") for r in app.routes}
|
|
assert not any(p.startswith("/api/v1/contacts") for p in static_paths), (
|
|
"contacts routes must not be statically mounted — Contacts is a plugin"
|
|
)
|
|
|
|
def test_hook_isolation_deactivate_one_plugin_keeps_other(self):
|
|
"""Two plugins register on same event; deactivating one keeps the other's handler."""
|
|
from app.core.hooks import get_hook_registry, HookRegistry
|
|
from app.core.history_hooks import register_history_hooks
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
reg = get_hook_registry()
|
|
reg._reset_for_testing()
|
|
|
|
# Plugin A (contacts) registers history hooks for contact.after_update
|
|
register_history_hooks(
|
|
reg, "contact",
|
|
"contact.after_create", "contact.after_update", "contact.after_delete",
|
|
owner_tag="contacts",
|
|
)
|
|
|
|
# Plugin B (tasks) also registers a handler on contact.after_update
|
|
async def _other_plugin_handler(**kwargs):
|
|
pass
|
|
reg.register_action("contact.after_update", _other_plugin_handler, priority=50, owner_tag="tasks")
|
|
|
|
# Verify both handlers exist
|
|
actions = reg._actions.get("contact.after_update", [])
|
|
assert len(actions) == 2, f"Expected 2 handlers, got {len(actions)}"
|
|
|
|
# Deactivate Plugin A (contacts) — should only remove contacts' handler
|
|
reg.unregister_actions_by_owner("contact.after_update", "contacts")
|
|
reg.unregister_actions_by_owner("contact.after_create", "contacts")
|
|
reg.unregister_actions_by_owner("contact.after_delete", "contacts")
|
|
|
|
# Plugin B's handler should still be registered
|
|
remaining = reg._actions.get("contact.after_update", [])
|
|
assert len(remaining) == 1, f"Expected 1 remaining handler, got {len(remaining)}"
|
|
assert remaining[0][1] is _other_plugin_handler, "Remaining handler should be from tasks plugin"
|
|
|
|
# Cleanup
|
|
reg._reset_for_testing()
|
|
|
|
def test_full_lifecycle_activate_deactivate_simulated(self):
|
|
"""Full lifecycle: simulate the service activation sequence → verify
|
|
registered → simulate deactivation → verify deregistered.
|
|
|
|
Audit P2 (false-green): renamed — this test drives the plugin hooks
|
|
and registry calls MANUALLY (mirroring what PluginService does),
|
|
it does NOT go through the real PluginService. The true E2E test
|
|
through PluginService lives in test_plugin_lifecycle_service.py.
|
|
"""
|
|
from app.services.plugin_service import PluginService
|
|
from app.core.permission_registry import get_permission_registry, init_permission_registry
|
|
from app.core.restore_registry import get_restore_registry
|
|
from app.core.hooks import get_hook_registry
|
|
from app.services.entity_permission_service import ENTITY_MODELS
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
import asyncio
|
|
|
|
# Reset all registries
|
|
reset_restore_registry_for_testing()
|
|
init_permission_registry(set())
|
|
get_hook_registry()._reset_for_testing()
|
|
|
|
plugin_name = "contacts"
|
|
from app.plugins.builtins.contacts.plugin import ContactsPlugin
|
|
plugin = ContactsPlugin()
|
|
|
|
# Simulate activation (what registry.activate + plugin_service would do)
|
|
db = AsyncMock()
|
|
container = MagicMock()
|
|
event_bus = MagicMock()
|
|
event_bus.subscribe = MagicMock()
|
|
event_bus.unsubscribe = MagicMock()
|
|
|
|
loop = asyncio.get_event_loop()
|
|
|
|
# Activate
|
|
loop.run_until_complete(plugin.on_activate(db, container, event_bus))
|
|
|
|
# Register permissions (what plugin_service.activate_plugin does)
|
|
perm_reg = get_permission_registry()
|
|
if plugin.manifest.permissions:
|
|
from app.core.permission_registry import register_plugin_permissions
|
|
register_plugin_permissions(plugin_name, plugin.manifest.permissions)
|
|
perm_reg._active_plugins.add(plugin_name)
|
|
|
|
# Register entity models (what plugin_service does)
|
|
from app.services.entity_permission_service import register_entity_model
|
|
for entity_type, model_class in plugin.get_entity_models().items():
|
|
register_entity_model(entity_type, model_class)
|
|
|
|
# Verify everything is registered
|
|
assert perm_reg.is_plugin_active(plugin_name), "Plugin should be active"
|
|
assert get_restore_registry().is_registered("contact"), "Restore config should be registered"
|
|
assert "contact" in ENTITY_MODELS, "Entity model should be registered"
|
|
hook_reg = get_hook_registry()
|
|
all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {}
|
|
assert any("contact.after_create" in k for k in all_hooks), "History hooks should be registered"
|
|
|
|
# Deactivate
|
|
loop.run_until_complete(plugin.on_deactivate(db, container, event_bus))
|
|
|
|
# Unregister permissions (what plugin_service.deactivate_plugin does)
|
|
from app.core.permission_registry import unregister_plugin_permissions
|
|
unregister_plugin_permissions(plugin_name)
|
|
perm_reg._active_plugins.discard(plugin_name)
|
|
|
|
# Unregister entity models
|
|
from app.services.entity_permission_service import unregister_entity_model
|
|
for entity_type in plugin.get_entity_models():
|
|
unregister_entity_model(entity_type)
|
|
|
|
# Verify everything is deregistered
|
|
assert not perm_reg.is_plugin_active(plugin_name), "Plugin should be inactive"
|
|
assert not get_restore_registry().is_registered("contact"), "Restore config should be removed"
|
|
# Audit P2 (false-green): the old assertion
|
|
# ``"contact" not in ENTITY_MODELS or "contact" in {...}`` was
|
|
# always true. After unregister_entity_model() the type must be
|
|
# GONE from the registry (contacts is plugin-owned, not core).
|
|
assert "contact" not in ENTITY_MODELS, "contact must be deregistered after plugin deactivation"
|
|
all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {}
|
|
assert not any("contact.after_create" in k for k in all_hooks), "History hooks should be removed"
|
|
|
|
# Cleanup
|
|
reset_restore_registry_for_testing()
|
|
init_permission_registry(set())
|
|
get_hook_registry()._reset_for_testing()
|