fix(arch): externes Audit — 13 Backend-Fixes (Workspace-Modules, Tenant-Manifeste, Lifecycle, Contracts, Permissions)
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.
This commit is contained in:
Agent Zero
2026-09-13 02:25:01 +02:00
parent 86cea5d6c4
commit 4a25ac1379
25 changed files with 1020 additions and 141 deletions
+2 -2
View File
@@ -77,7 +77,7 @@ for _plugin_name in _registry.list_discovered():
# every plugin's entity models are registered in ENTITY_MODELS —
# the core registry itself carries core entities only.
for _entity_type, _model_class in _entity_models.items():
register_entity_model(_entity_type, _model_class)
register_entity_model(_entity_type, _model_class, plugin_name=_plugin_name)
# Also import the plugin's __init__ to ensure all models are loaded
import importlib
try:
@@ -105,7 +105,7 @@ def _ensure_plugin_entity_models():
if _plugin is None:
continue
for _entity_type, _model_class in _plugin.get_entity_models().items():
register_entity_model(_entity_type, _model_class)
register_entity_model(_entity_type, _model_class, plugin_name=_plugin_name)
yield
# Also import core models that may be missing
+503
View File
@@ -0,0 +1,503 @@
"""Regression tests for the external architecture audit (leocrm-full.zip, 2026-09).
Covers the confirmed findings with live counterchecks (AGENTS.md rule —
no finding is marked done without a measurement):
- F1 list_workspaces() returns modules (WorkspaceManager overwrite bug)
- F2 active-manifests respects tenant deactivation (signature level)
- F4 ContractRegistry fails closed for DB-inactive plugins
(restart edge case) and reopens on activation
- F5 field definitions have a full lifecycle (register/unregister)
- F9 permission catalog: every used key is registered
- F10 contact field definitions are plugin-owned (not in core list)
- F11 forgejo_error_reporter is not is_core
- F12 dms is a platform core plugin (ADR-020)
- F13 core worker has no contact import; contacts trash cleanup is a
plugin job discovered via get_job_modules()
- F14 the legacy dsgvo-export route delegates to the single DSAR collector
- F15 get_entity_read_permission fails closed instead of contacts:read
The frontend architecture items (static plugin routes, STATIC_COMPONENT_MAP,
widgetRegistry) are tracked as Phase Q — not covered here.
"""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.services import workspace_service
@pytest.fixture(autouse=True)
def _restore_global_registries():
"""Snapshot & restore global registries mutated by these tests.
The F5 lifecycle test deactivates the real 'contacts' plugin through
PluginService, which (since the audit fix) centrally unregisters its
CONTRACT and clears permission-registry state. The conftest resets
entity models but NOT the contract registry — without this restore,
get_contract('contacts') stays fail-closed for every later test in the
same process (suite-order dependence).
"""
from app.plugins.builtins.contracts import get_contract_registry
from app.core.permission_registry import get_permission_registry
from app.services.entity_permission_service import (
ENTITY_MODELS,
ENTITY_PLUGIN_OWNERS,
)
perm_reg = get_permission_registry()
saved_active = set(perm_reg._active_plugins)
saved_plugin_perms = {k: list(v) for k, v in perm_reg._plugin_permissions.items()}
saved_field_defs = {k: list(v) for k, v in perm_reg._field_definitions.items()}
cr = get_contract_registry()
saved_contracts = dict(cr._contracts)
saved_loaded = set(cr._loaded)
saved_unreg = set(cr._unregistered)
saved_db_inactive = set(cr._db_inactive)
saved_entity_models = dict(ENTITY_MODELS)
saved_owners = dict(ENTITY_PLUGIN_OWNERS)
yield
perm_reg._active_plugins.clear()
perm_reg._active_plugins.update(saved_active)
perm_reg._plugin_permissions.clear()
perm_reg._plugin_permissions.update(saved_plugin_perms)
perm_reg._field_definitions.clear()
perm_reg._field_definitions.update(saved_field_defs)
cr._contracts.clear()
cr._contracts.update(saved_contracts)
cr._loaded.clear()
cr._loaded.update(saved_loaded)
cr._unregistered.clear()
cr._unregistered.update(saved_unreg)
cr._db_inactive.clear()
cr._db_inactive.update(saved_db_inactive)
ENTITY_MODELS.clear()
ENTITY_MODELS.update(saved_entity_models)
ENTITY_PLUGIN_OWNERS.clear()
ENTITY_PLUGIN_OWNERS.update(saved_owners)
# ── F1: list_workspaces returns modules ─────────────────────────────────────
async def _seed_tenant_and_user(db: AsyncSession) -> dict:
"""Seed a tenant and a user, return IDs (test_workspaces.py pattern)."""
from app.core.auth import hash_password
tenant = Tenant(name="Audit Tenant", slug=f"audit-{uuid.uuid4().hex[:8]}")
db.add(tenant)
await db.flush()
user = User(
email=f"audit-{uuid.uuid4().hex[:8]}@example.com",
name="Audit User",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db.add(user)
await db.flush()
db.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
await db.flush()
return {"tenant": tenant, "user": user}
@pytest.mark.asyncio
async def test_f1_list_workspaces_returns_modules(db_session: AsyncSession):
"""Audit F1: list_workspaces must include configured modules.
Previously every workspace came back with modules: [] and the
WorkspaceManager editor overwrote existing config as all-hidden.
"""
seed = await _seed_tenant_and_user(db_session)
created = await workspace_service.create_workspace(
db_session, seed["tenant"].id, seed["user"].id, name="AuditWS",
)
ws_id = uuid.UUID(created["id"])
await workspace_service.set_workspace_modules(
db_session,
seed["tenant"].id,
ws_id,
[
{"module_key": "contacts", "is_visible": True, "menu_order": 10, "config": {}},
{"module_key": "calendar", "is_visible": False, "menu_order": 20, "config": {}},
],
)
result = await workspace_service.list_workspaces(db_session, seed["tenant"].id)
assert result["total"] == 1
listed = result["items"][0]
assert listed["name"] == "AuditWS"
# THE regression: modules must be present with their is_visible flags
mods = {m["module_key"]: m["is_visible"] for m in listed["modules"]}
assert mods == {"contacts": True, "calendar": False}
# ── F2: active-manifests tenant filtering (signature level) ────────────────
def test_f2_get_active_manifests_accepts_tenant_id():
"""Audit F2: the registry manifest API accepts and honours tenant_id."""
import inspect
from app.plugins.registry import PluginRegistry
sig = inspect.signature(PluginRegistry.get_active_manifests)
assert "tenant_id" in sig.parameters, (
"get_active_manifests must accept tenant_id so tenant-deactivated "
"plugins are hidden from the UI manifests"
)
# Service passes it through
from app.services.plugin_service import PluginService
sig = inspect.signature(PluginService.get_active_manifests)
assert "tenant_id" in sig.parameters
@pytest.mark.asyncio
async def test_f2_manifests_exclude_tenant_disabled_plugins(db_session: AsyncSession):
"""Audit F2: a plugin deactivated for the tenant is filtered out."""
from sqlalchemy import text
seed = await _seed_tenant_and_user(db_session)
# tenant_plugin_activation is created by migration 0066 only (no ORM
# model) — test DBs built via create_all lack it, so create it if needed.
await db_session.execute(
text(
"CREATE TABLE IF NOT EXISTS tenant_plugin_activation ("
"id UUID PRIMARY KEY DEFAULT gen_random_uuid(), "
"tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, "
"plugin_name VARCHAR(100) NOT NULL, "
"is_active BOOLEAN NOT NULL DEFAULT true, "
"activated_by UUID, "
"created_at TIMESTAMPTZ NOT NULL DEFAULT now(), "
"updated_at TIMESTAMPTZ NOT NULL DEFAULT now())"
)
)
await db_session.execute(
text(
"INSERT INTO tenant_plugin_activation (tenant_id, plugin_name, is_active) "
"VALUES (:tid, 'forgejo_error_reporter', false)"
),
{"tid": str(seed["tenant"].id)},
)
await db_session.flush()
from app.models.plugin import Plugin as PluginModel
from app.plugins.registry import get_registry
registry = get_registry()
if not registry.list_discovered():
registry.discover_builtins()
# Seed a globally-ACTIVE plugin record so the manifest is served at all
# (test DBs have no plugin rows unless installed through the registry).
db_session.add(PluginModel(
name="forgejo_error_reporter",
display_name="Forgejo Error Reporter",
version="1.0.0",
status="active",
installed=True,
active=True,
is_core=False,
))
await db_session.flush()
all_manifests = await registry.get_active_manifests(db_session, tenant_id=None)
assert any(m["name"] == "forgejo_error_reporter" for m in all_manifests), (
"globally active plugin must appear in manifests (test premise)"
)
filtered = await registry.get_active_manifests(
db_session, tenant_id=seed["tenant"].id
)
assert all(m["name"] != "forgejo_error_reporter" for m in filtered), (
"tenant-deactivated plugin must not appear in the UI manifests"
)
# ── F4: contract registry restart guard ────────────────────────────────────
def test_f4_contract_registry_fails_closed_for_db_inactive():
"""Audit F4: contracts of DB-inactive plugins are not resurrected."""
from app.plugins.builtins.contracts import get_contract, get_contract_registry
reg = get_contract_registry()
reg._reset_for_testing()
try:
reg.mark_db_inactive({"kommunikation"})
# Even after lazy-load attempts the contract must stay unavailable
assert get_contract("kommunikation") is None
finally:
reg._reset_for_testing()
def test_f4_contract_registry_reopens_on_activation():
"""Audit F4: activating a plugin clears its inactive markers."""
from app.plugins.builtins.contracts import get_contract_registry
reg = get_contract_registry()
reg._reset_for_testing()
try:
reg.mark_db_inactive({"some_plugin"})
reg._unregistered.add("some_plugin")
reg.mark_plugin_active("some_plugin")
assert "some_plugin" not in reg._db_inactive
assert "some_plugin" not in reg._unregistered
finally:
reg._reset_for_testing()
# ── F5: field definitions lifecycle ─────────────────────────────────────────
def test_f5_field_definitions_full_lifecycle():
"""Audit F5: register → present, unregister → gone."""
from app.core.permission_registry import PermissionRegistry
reg = PermissionRegistry()
reg.initialize()
defs = [{"module": "x", "field": "y", "label": "Y", "sensitivity": "normal"}]
reg.register_field_definitions("audit_plugin", defs)
assert any(d.get("plugin_key", d.get("field")) or True for d in defs)
all_defs = reg.get_all_field_definitions()
assert any(d.get("field") == "y" for d in all_defs)
reg.unregister_field_definitions("audit_plugin")
all_defs = reg.get_all_field_definitions()
assert not any(d.get("field") == "y" and d.get("module") == "x" for d in all_defs)
def test_f5_deactivate_plugin_unregisters_field_definitions():
"""Audit F5: PluginService.deactivate_plugin removes field definitions."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
from app.core.permission_registry import get_permission_registry, init_permission_registry
from app.plugins.builtins.contacts.plugin import ContactsPlugin
from app.services.plugin_service import PluginService
init_permission_registry(set())
perm_reg = get_permission_registry()
perm_reg._active_plugins.add("contacts")
perm_reg.register_field_definitions("contacts", ContactsPlugin().get_field_definitions())
assert any(
d.get("module") == "contacts"
for d in perm_reg.get_all_field_definitions()
)
service = PluginService()
# deactivate_plugin resolves the record via registry._get_plugin_record —
# a MagicMock plugin record with active=True triggers the cleanup path.
# NOTE: service._registry IS the global singleton — save & restore the
# patched methods so the conftest bootstrap stays intact for later tests
# (order-dependence guard).
mock_record = MagicMock()
mock_record.active = True
mock_record.status = "active"
_orig_get_record = service._registry._get_plugin_record
_orig_deactivate = service._registry.deactivate
_orig_get_plugin = service._registry.get_plugin
service._registry._get_plugin_record = AsyncMock(return_value=mock_record)
service._registry.deactivate = AsyncMock(return_value=mock_record)
service._registry.get_plugin = MagicMock(return_value=ContactsPlugin())
try:
asyncio.get_event_loop().run_until_complete(
service.deactivate_plugin(AsyncMock(), "contacts")
)
finally:
service._registry._get_plugin_record = _orig_get_record
service._registry.deactivate = _orig_deactivate
service._registry.get_plugin = _orig_get_plugin
assert not any(
d.get("module") == "contacts"
for d in perm_reg.get_all_field_definitions()
), "deactivation must unregister the plugin's field definitions"
# ── F9: permission catalog completeness ─────────────────────────────────────
def test_f9_all_audit_permission_keys_registered():
"""Audit F9: the 12 previously missing keys are grantable now."""
from app.core.permission_registry import (
CORE_PERMISSIONS,
get_permission_registry,
init_permission_registry,
)
init_permission_registry(set())
get_permission_registry().register_plugin_permissions(
"permissions", ["permissions:read", "permissions:admin"]
)
get_permission_registry().register_plugin_permissions(
"forgejo_error_reporter", ["system:read"]
)
known = get_permission_registry().get_all()
keys = {p["key"] for p in known}
core_expected = [
"automation:admin",
"bank-accounts:read",
"bank-accounts:write",
"delegations:read",
"delegations:write",
"policies:read",
"policies:write",
"templates:read",
"templates:write",
]
for key in core_expected:
assert key in {p["key"] for p in CORE_PERMISSIONS}, f"{key} missing in CORE_PERMISSIONS"
for key in ["permissions:read", "permissions:admin", "system:read"]:
assert key in keys, f"{key} must be grantable"
assert get_permission_registry().is_valid("delegations:read")
# ── F10: contact field definitions are plugin-owned ────────────────────────
def test_f10_contact_field_definitions_plugin_owned():
"""Audit F10: contacts:* field defs come from the plugin manifest."""
from app.core.permission_registry import CORE_FIELD_DEFINITIONS
from app.plugins.builtins.contacts.plugin import ContactsPlugin
core_modules = {fd["module"] for fd in CORE_FIELD_DEFINITIONS}
assert "contacts" not in core_modules, "core must not own contacts field definitions"
plugin_defs = ContactsPlugin().get_field_definitions()
assert len(plugin_defs) >= 30, "contacts plugin should carry its ~39 field definitions"
assert all(fd["module"] == "contacts" for fd in plugin_defs)
# Spot-check sensitive fields are still classified sensitive after the move
by_field = {fd["field"]: fd for fd in plugin_defs}
assert by_field["mobilephone"]["sensitivity"] == "sensitive"
assert by_field["bic"]["sensitivity"] == "sensitive"
# ── F11/F12: plugin classification ─────────────────────────────────────────
def test_f11_forgejo_error_reporter_not_core():
"""Audit F11: the test/staging-only reporter is deactivatable."""
from app.plugins.builtins.forgejo_error_reporter.plugin import ForgejoErrorReporterPlugin
assert ForgejoErrorReporterPlugin().manifest.is_core is False
def test_f12_dms_is_platform_core_plugin():
"""Audit F12/ADR-020: the core schema builds on the DMS files table."""
from app.plugins.builtins.dms.plugin import DmsPlugin
assert DmsPlugin().manifest.is_core is True
# ── F13: worker decoupled from contacts ────────────────────────────────────
def test_f13_core_worker_has_no_contact_import():
"""Audit F13: app/core/worker.py must not import contact models."""
import inspect
import app.core.worker as worker
src = inspect.getsource(worker)
assert "from app.models.contact import" not in src
def test_f13_contacts_trash_job_discovered_via_job_modules():
"""Audit F13: contacts plugin owns its trash cleanup job."""
from app.core.job_registry import get_job
from app.plugins.builtins.contacts.jobs import cleanup_contacts_trash_job
from app.plugins.builtins.contacts.plugin import ContactsPlugin
modules = ContactsPlugin().get_job_modules()
assert "app.plugins.builtins.contacts.jobs" in modules
import importlib
importlib.import_module("app.plugins.builtins.contacts.jobs")
assert get_job("cleanup_contacts_trash") is cleanup_contacts_trash_job
# ── F14: single DSAR export path ───────────────────────────────────────────
def test_f14_dsgvo_route_delegates_to_dsar_collector():
"""Audit F14: the legacy export route delegates, it does not reimplement."""
import inspect
import app.routes.system_settings as ss
src = inspect.getsource(ss)
assert "_dsar_collect_user_data" in src, "route must delegate to the DSAR collector"
assert "from app.models.contact import" not in src, (
"the export route must not know contacts internals"
)
# ── F15: entity permission fallback fails closed ───────────────────────────
def test_f15_entity_read_permission_fails_closed():
"""Audit F15: unmapped entities no longer default to contacts:read."""
from app.services.entity_permission_service import (
ENTITY_PLUGIN_OWNERS,
get_entity_read_permission,
)
saved = ENTITY_PLUGIN_OWNERS.pop("zzz_audit_unknown", None)
try:
perm = get_entity_read_permission("zzz_audit_unknown")
assert perm != "contacts:read"
assert perm == "__unmapped__:read"
finally:
if saved is not None:
ENTITY_PLUGIN_OWNERS["zzz_audit_unknown"] = saved
def test_f15_contact_folder_registered_by_plugin():
"""Audit F15: contact_folder comes from ContactsPlugin, not core."""
from app.plugins.builtins.contacts.plugin import ContactsPlugin
models = ContactsPlugin().get_entity_models()
assert "contact_folder" in models
from app.services.entity_permission_service import get_entity_read_permission
assert get_entity_read_permission("contact_folder") == "contacts:read"
# ── Uninstall lifecycle (audit P1) ─────────────────────────────────────────
def test_f3_uninstall_deactivates_via_service_first():
"""Audit P1: uninstall_plugin runs the full service deactivation first."""
import inspect
from app.services.plugin_service import PluginService
src = inspect.getsource(PluginService.uninstall_plugin)
assert "self.deactivate_plugin(" in src, (
"uninstall must run the full service-level deactivation (permissions, "
"active set, entity models) before registry.uninstall()"
)
+15 -1
View File
@@ -29,14 +29,28 @@ def test_core_registry_source_has_no_static_contacts_entries():
def test_contacts_plugin_is_single_source_for_its_entities():
"""ContactsPlugin.get_entity_models() defines contact/contacts/company."""
"""ContactsPlugin.get_entity_models() defines contact/contacts/company/contact_folder.
Audit P2: contact_folder moved from the static core ENTITY_MODELS map to
the plugin (it is contacts-owned domain data) — the plugin is the single
source for ALL its entities.
"""
from app.models.contact_folder import ContactFolder
plugin_models = ContactsPlugin().get_entity_models()
assert plugin_models == {
"contact": Contact,
"contacts": Contact,
"company": Contact,
"contact_folder": ContactFolder,
}
# The core registry must NOT carry contact_folder statically anymore —
# it appears there only through the plugin bootstrap (conftest mirrors
# main.py: every discovered plugin's entities get registered).
from app.services import entity_permission_service as eps
core_static_types = set(eps.ENTITY_MODELS.keys()) - set(plugin_models.keys())
assert "contact_folder" not in core_static_types
def test_bootstrap_registers_contacts_entities_via_plugin():
"""After the real bootstrap path (conftest mirrors main.py lifespan:
+36 -10
View File
@@ -46,7 +46,13 @@ class TestContactsPluginLifecycle:
# Before activation: nothing registered
reg = get_restore_registry()
assert not reg.is_registered("contact")
assert "contact" not in ENTITY_MODELS or True # May be in core models
# 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
@@ -136,11 +142,26 @@ class TestContactsPluginLifecycle:
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."""
"""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
assert len(app.routes) > 100 # Should have many routes
# 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."""
@@ -180,12 +201,14 @@ class TestContactsPluginLifecycle:
# Cleanup
reg._reset_for_testing()
def test_full_lifecycle_activate_deactivate_via_service(self):
"""Full lifecycle: activate via PluginService → verify registered → deactivate → verify deregistered.
def test_full_lifecycle_activate_deactivate_simulated(self):
"""Full lifecycle: simulate the service activation sequence → verify
registered → simulate deactivation → verify deregistered.
This is the E2E lifecycle test (Phase 5.2): tests that activation/deactivation
through the service layer properly registers/deregisters permissions,
entity models, restore config, and history hooks.
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
@@ -252,8 +275,11 @@ class TestContactsPluginLifecycle:
# 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"
assert "contact" not in ENTITY_MODELS or "contact" in {"contact", "contacts", "company"}, \
"Entity model may still be in core ENTITY_MODELS"
# 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"
+25 -2
View File
@@ -411,12 +411,35 @@ class TestPermissionRegistryUnit:
assert mail_defs[0]["field"] == "subject"
def test_get_all_field_definitions_includes_core(self):
"""get_all_field_definitions() includes core field definitions."""
"""get_all_field_definitions() includes core field definitions.
Audit P1/P2: contacts:* field definitions moved to the ContactsPlugin
manifest — the core keeps only core-owned fields (users). Plugin
definitions join via register_field_definitions() (full lifecycle:
register/unregister).
"""
reg = PermissionRegistry()
reg.initialize()
all_defs = reg.get_all_field_definitions()
user_defs = [d for d in all_defs if d.get("module") == "users"]
assert len(user_defs) > 0
# Contacts are plugin-owned now — nothing in the core list
contact_defs = [d for d in all_defs if d.get("module") == "contacts"]
assert len(contact_defs) > 0
assert contact_defs == []
# Plugin definitions appear once registered ...
reg.register_field_definitions("contacts", [
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"},
])
all_defs = reg.get_all_field_definitions()
contact_defs = [d for d in all_defs if d.get("module") == "contacts"]
assert len(contact_defs) == 1
# ... and disappear on unregister (audit: contribution fully
# lifecycle-integrated)
reg.unregister_field_definitions("contacts")
all_defs = reg.get_all_field_definitions()
contact_defs = [d for d in all_defs if d.get("module") == "contacts"]
assert contact_defs == []
# ═══════════════════════════════════════════════════════════════