a4d0f0c35d
Check Cross-Plugin Imports / check (push) Has been cancelled
- D-GEN: RestoreRegistry mit RestoreConfig (model_class, restore_permission, excluded_fields, special_handler) - D-HOOK: history_hooks.py mit register_history_hooks() für after_create/update/delete - D-CORE: Company create+update record_history in companies.py - D-PLUG: Task/Calendar/DMS record_history in services/routes - D-SOFT: Alle registrierten Entitäten haben deleted_at + un-delete via Registry - D-MAIL: Mail special_handler (IMAP Trash-Move, Folder-Verify) + record_history in delete/move - D-TRASH: GET /entity-history/trash (filterbar, paginiert) + Frontend Trash.tsx - D-TOAST: UndoToast.tsx (5s Auto-Dismiss, useUndoToast Hook) - D-HIST-UI: HistoryPanel.tsx (Timeline, Diff-View, Restore-Button) - D-BULK: POST /entity-history/bulk-restore mit partial_success Semantik - D-RET: POST /entity-history/retention/archive (GDPR hard-delete >90 Tage) - D-TEST: 26 Tests in test_restore_registry.py, alle grün - D-DOC: test-strategy.md + security_kernel.md aktualisiert Backend: 10 Dateien, Frontend: 7 Dateien, Tests: 1 Datei, Docs: 3 Dateien 26/26 Tests passed, TSC 0 errors, App import 492 routes
444 lines
15 KiB
Python
444 lines
15 KiB
Python
"""Tests for Phase D — Restore Registry, History Hooks, Trash, Bulk Restore, Retention."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.core.restore_registry import (
|
|
RestoreConfig,
|
|
RestoreRegistry,
|
|
get_restore_registry,
|
|
reset_restore_registry_for_testing,
|
|
)
|
|
from app.core.history_hooks import (
|
|
_extract_entity_id,
|
|
register_history_hooks,
|
|
reset_history_hooks_for_testing,
|
|
)
|
|
from app.services.entity_history_service import (
|
|
archive_old_history,
|
|
bulk_restore,
|
|
list_trash,
|
|
record_history,
|
|
restore_from_history,
|
|
)
|
|
|
|
|
|
# ─── Restore Registry Tests ───
|
|
|
|
|
|
class TestRestoreRegistry:
|
|
"""Tests for the RestoreRegistry singleton."""
|
|
|
|
def setup_method(self):
|
|
reset_restore_registry_for_testing()
|
|
|
|
def test_register_and_get(self):
|
|
reg = get_restore_registry()
|
|
config = RestoreConfig(
|
|
entity_type="test_entity",
|
|
model_class=MagicMock,
|
|
restore_permission="test:write",
|
|
)
|
|
reg.register(config)
|
|
assert reg.get("test_entity") is config
|
|
|
|
def test_get_unregistered_returns_none(self):
|
|
reg = get_restore_registry()
|
|
assert reg.get("nonexistent") is None
|
|
|
|
def test_is_registered(self):
|
|
reg = get_restore_registry()
|
|
config = RestoreConfig(
|
|
entity_type="test_entity",
|
|
model_class=MagicMock,
|
|
restore_permission="test:write",
|
|
)
|
|
reg.register(config)
|
|
assert reg.is_registered("test_entity") is True
|
|
assert reg.is_registered("nonexistent") is False
|
|
|
|
def test_list_registered(self):
|
|
reg = get_restore_registry()
|
|
reg.register(RestoreConfig("alpha", MagicMock, "a:write"))
|
|
reg.register(RestoreConfig("beta", MagicMock, "b:write"))
|
|
assert reg.list_registered() == ["alpha", "beta"]
|
|
|
|
def test_overwrite_warns(self, caplog):
|
|
reg = get_restore_registry()
|
|
reg.register(RestoreConfig("test", MagicMock, "t:write"))
|
|
reg.register(RestoreConfig("test", MagicMock, "t:write2"))
|
|
assert reg.get("test").restore_permission == "t:write2"
|
|
|
|
def test_excluded_fields_merge(self):
|
|
config = RestoreConfig(
|
|
entity_type="test",
|
|
model_class=MagicMock,
|
|
restore_permission="t:write",
|
|
excluded_fields=frozenset({"custom_field", "secret"}),
|
|
)
|
|
excluded = config.all_excluded_fields
|
|
assert "id" in excluded
|
|
assert "tenant_id" in excluded
|
|
assert "deleted_at" in excluded
|
|
assert "custom_field" in excluded
|
|
assert "secret" in excluded
|
|
|
|
|
|
class TestRestoreFromHistory:
|
|
"""Tests for restore_from_history with registry."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_unsupported_entity_type_raises(self):
|
|
"""Restore should raise ValueError for unregistered entity types."""
|
|
reset_restore_registry_for_testing()
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
user_id = uuid.uuid4()
|
|
history_id = uuid.uuid4()
|
|
|
|
# Mock get_history_entry to return an entry with unregistered type
|
|
with patch("app.services.entity_history_service.get_history_entry") as mock_get:
|
|
mock_entry = MagicMock()
|
|
mock_entry.entity_type = "unsupported_type"
|
|
mock_entry.entity_id = uuid.uuid4()
|
|
mock_entry.action = "delete"
|
|
mock_get.return_value = mock_entry
|
|
|
|
with pytest.raises(ValueError, match="Unsupported entity type"):
|
|
await restore_from_history(db, tenant_id, history_id, user_id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_history_not_found_raises(self):
|
|
"""Restore should raise ValueError if history entry not found."""
|
|
reset_restore_registry_for_testing()
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
user_id = uuid.uuid4()
|
|
history_id = uuid.uuid4()
|
|
|
|
with patch("app.services.entity_history_service.get_history_entry") as mock_get:
|
|
mock_get.return_value = None
|
|
|
|
with pytest.raises(ValueError, match="History entry not found"):
|
|
await restore_from_history(db, tenant_id, history_id, user_id)
|
|
|
|
|
|
# ─── History Hooks Tests ───
|
|
|
|
|
|
class TestHistoryHooks:
|
|
"""Tests for hook-based history recording."""
|
|
|
|
def test_extract_entity_id_from_uuid(self):
|
|
eid = uuid.uuid4()
|
|
assert _extract_entity_id({"id": eid}) == eid
|
|
|
|
def test_extract_entity_id_from_string(self):
|
|
eid = uuid.uuid4()
|
|
assert _extract_entity_id({"id": str(eid)}) == eid
|
|
|
|
def test_extract_entity_id_none(self):
|
|
assert _extract_entity_id(None) is None
|
|
assert _extract_entity_id({}) is None
|
|
assert _extract_entity_id({"id": None}) is None
|
|
|
|
def test_extract_entity_id_invalid(self):
|
|
assert _extract_entity_id({"id": "not-a-uuid"}) is None
|
|
|
|
def test_register_history_hooks(self):
|
|
"""Test that hooks are registered correctly."""
|
|
reset_history_hooks_for_testing()
|
|
from app.core.hooks import get_hook_registry
|
|
|
|
reg = get_hook_registry()
|
|
register_history_hooks(
|
|
reg, "test_entity",
|
|
"test_entity.after_create",
|
|
"test_entity.after_update",
|
|
"test_entity.after_delete",
|
|
)
|
|
|
|
assert reg.has_action("test_entity.after_create")
|
|
assert reg.has_action("test_entity.after_update")
|
|
assert reg.has_action("test_entity.after_delete")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_hook_create_calls_record_history(self):
|
|
"""Test that after_create hook calls record_history."""
|
|
reset_history_hooks_for_testing()
|
|
from app.core.hooks import do_action, get_hook_registry
|
|
|
|
reg = get_hook_registry()
|
|
register_history_hooks(
|
|
reg, "test_entity",
|
|
"test_entity.after_create",
|
|
"test_entity.after_update",
|
|
"test_entity.after_delete",
|
|
)
|
|
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
user_id = uuid.uuid4()
|
|
entity_id = uuid.uuid4()
|
|
|
|
with patch("app.core.history_hooks.record_history", new_callable=AsyncMock) as mock_rh:
|
|
await do_action(
|
|
"test_entity.after_create",
|
|
{"id": str(entity_id), "name": "Test"},
|
|
db=db, tenant_id=tenant_id, user_id=user_id,
|
|
)
|
|
mock_rh.assert_called_once()
|
|
call_args = mock_rh.call_args
|
|
# record_history(db, tenant_id, user_id, entity_type, entity_id, action=..., snapshot_after=...)
|
|
# args: (db, tenant_id, user_id, entity_type, entity_id)
|
|
# kwargs: action=..., snapshot_after=...
|
|
assert call_args.args[3] == "test_entity" # entity_type
|
|
assert call_args.args[4] == entity_id # entity_id
|
|
assert call_args.kwargs.get("action") == "create" # action
|
|
|
|
|
|
# ─── Trash List Tests ───
|
|
|
|
|
|
class TestTrashList:
|
|
"""Tests for list_trash service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_trash_returns_delete_entries(self):
|
|
"""list_trash should only return delete actions."""
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
|
|
# Mock the query results
|
|
mock_entry1 = MagicMock()
|
|
mock_entry1.id = uuid.uuid4()
|
|
mock_entry1.entity_type = "contact"
|
|
mock_entry1.entity_id = uuid.uuid4()
|
|
mock_entry1.snapshot_before = {"name": "John"}
|
|
mock_entry1.created_at = datetime.now(timezone.utc)
|
|
mock_entry1.user_id = uuid.uuid4()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [mock_entry1]
|
|
|
|
mock_count_result = MagicMock()
|
|
mock_count_result.scalar.return_value = 1
|
|
|
|
db.execute = AsyncMock(side_effect=[mock_count_result, mock_result])
|
|
|
|
result = await list_trash(db, tenant_id, limit=50, offset=0)
|
|
|
|
assert result["total"] == 1
|
|
assert len(result["items"]) == 1
|
|
assert result["items"][0]["entity_type"] == "contact"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_trash_with_entity_type_filter(self):
|
|
"""list_trash should filter by entity_type."""
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = []
|
|
|
|
mock_count_result = MagicMock()
|
|
mock_count_result.scalar.return_value = 0
|
|
|
|
db.execute = AsyncMock(side_effect=[mock_count_result, mock_result])
|
|
|
|
result = await list_trash(db, tenant_id, entity_type="task", limit=50, offset=0)
|
|
|
|
assert result["total"] == 0
|
|
assert result["items"] == []
|
|
|
|
|
|
# ─── Bulk Restore Tests ───
|
|
|
|
|
|
class TestBulkRestore:
|
|
"""Tests for bulk_restore service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_restore_all_success(self):
|
|
"""bulk_restore should succeed for all valid entries."""
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
user_id = uuid.uuid4()
|
|
hid1 = uuid.uuid4()
|
|
hid2 = uuid.uuid4()
|
|
|
|
with patch("app.services.entity_history_service.restore_from_history", new_callable=AsyncMock) as mock_restore:
|
|
mock_restore.side_effect = [
|
|
{"id": str(uuid.uuid4()), "_entity_type": "contact", "_restored": True},
|
|
{"id": str(uuid.uuid4()), "_entity_type": "task", "_restored": True},
|
|
]
|
|
|
|
result = await bulk_restore(db, tenant_id, [hid1, hid2], user_id)
|
|
|
|
assert result["total"] == 2
|
|
assert result["succeeded"] == 2
|
|
assert result["failed"] == 0
|
|
assert result["partial_success"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_restore_partial_failure(self):
|
|
"""bulk_restore should report partial_success when some fail."""
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
user_id = uuid.uuid4()
|
|
hid1 = uuid.uuid4()
|
|
hid2 = uuid.uuid4()
|
|
|
|
with patch("app.services.entity_history_service.restore_from_history", new_callable=AsyncMock) as mock_restore:
|
|
mock_restore.side_effect = [
|
|
{"id": str(uuid.uuid4()), "_entity_type": "contact"},
|
|
ValueError("Entity not found"),
|
|
]
|
|
|
|
result = await bulk_restore(db, tenant_id, [hid1, hid2], user_id)
|
|
|
|
assert result["total"] == 2
|
|
assert result["succeeded"] == 1
|
|
assert result["failed"] == 1
|
|
assert result["partial_success"] is True
|
|
assert result["results"][1]["error"] == "Entity not found"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_restore_all_fail(self):
|
|
"""bulk_restore should report no partial_success when all fail."""
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
user_id = uuid.uuid4()
|
|
|
|
with patch("app.services.entity_history_service.restore_from_history", new_callable=AsyncMock) as mock_restore:
|
|
mock_restore.side_effect = [ValueError("Not found"), ValueError("Not found")]
|
|
|
|
result = await bulk_restore(db, tenant_id, [uuid.uuid4(), uuid.uuid4()], user_id)
|
|
|
|
assert result["succeeded"] == 0
|
|
assert result["failed"] == 2
|
|
assert result["partial_success"] is False
|
|
|
|
|
|
# ─── Retention Tests ───
|
|
|
|
|
|
class TestRetention:
|
|
"""Tests for archive_old_history service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_old_history_returns_count(self):
|
|
"""archive_old_history should return the number of archived entries."""
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.rowcount = 42
|
|
db.execute = AsyncMock(return_value=mock_result)
|
|
|
|
count = await archive_old_history(db, tenant_id, days=90)
|
|
|
|
assert count == 42
|
|
db.flush.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_old_history_zero_when_none(self):
|
|
"""archive_old_history should return 0 when no entries to archive."""
|
|
db = AsyncMock()
|
|
tenant_id = uuid.uuid4()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.rowcount = 0
|
|
db.execute = AsyncMock(return_value=mock_result)
|
|
|
|
count = await archive_old_history(db, tenant_id, days=90)
|
|
|
|
assert count == 0
|
|
|
|
|
|
# ─── Sensitive Fields Exclusion Tests ───
|
|
|
|
|
|
class TestSensitiveFieldsExclusion:
|
|
"""Tests that sensitive fields are excluded from restore."""
|
|
|
|
def test_default_excluded_fields(self):
|
|
"""Default excluded fields should include id, tenant_id, timestamps."""
|
|
from app.core.restore_registry import _DEFAULT_EXCLUDED
|
|
|
|
assert "id" in _DEFAULT_EXCLUDED
|
|
assert "tenant_id" in _DEFAULT_EXCLUDED
|
|
assert "created_at" in _DEFAULT_EXCLUDED
|
|
assert "updated_at" in _DEFAULT_EXCLUDED
|
|
assert "deleted_at" in _DEFAULT_EXCLUDED
|
|
assert "search_tsv" in _DEFAULT_EXCLUDED
|
|
assert "embedding" in _DEFAULT_EXCLUDED
|
|
|
|
def test_contact_excluded_fields(self):
|
|
"""Contact should exclude search_tsv, embedding, and relationship IDs."""
|
|
reset_restore_registry_for_testing()
|
|
from app.core.restore_registry import register_default_entities
|
|
|
|
register_default_entities()
|
|
reg = get_restore_registry()
|
|
config = reg.get("contact")
|
|
|
|
assert config is not None
|
|
excluded = config.all_excluded_fields
|
|
assert "search_tsv" in excluded
|
|
assert "embedding" in excluded
|
|
assert "default_person_id" in excluded
|
|
assert "admin_contactperson_id" in excluded
|
|
|
|
def test_dms_file_excludes_storage_path(self):
|
|
"""DMS File should exclude storage_path, content_hash, size_bytes."""
|
|
reset_restore_registry_for_testing()
|
|
from app.core.restore_registry import register_default_entities
|
|
|
|
register_default_entities()
|
|
reg = get_restore_registry()
|
|
config = reg.get("dms_file")
|
|
|
|
assert config is not None
|
|
excluded = config.all_excluded_fields
|
|
assert "storage_path" in excluded
|
|
assert "content_hash" in excluded
|
|
assert "size_bytes" in excluded
|
|
assert "uploaded_by" in excluded
|
|
|
|
def test_mail_excludes_message_id_and_raw_path(self):
|
|
"""Mail should exclude message_id, rfc822_size, raw_path."""
|
|
reset_restore_registry_for_testing()
|
|
from app.core.restore_registry import register_default_entities
|
|
|
|
register_default_entities()
|
|
reg = get_restore_registry()
|
|
config = reg.get("mail")
|
|
|
|
assert config is not None
|
|
excluded = config.all_excluded_fields
|
|
assert "message_id" in excluded
|
|
assert "rfc822_size" in excluded
|
|
assert "raw_path" in excluded
|
|
assert config.special_handler is not None
|
|
|
|
def test_all_default_entities_registered(self):
|
|
"""register_default_entities should register all 5 entity types."""
|
|
reset_restore_registry_for_testing()
|
|
from app.core.restore_registry import register_default_entities
|
|
|
|
register_default_entities()
|
|
reg = get_restore_registry()
|
|
registered = reg.list_registered()
|
|
|
|
assert "contact" in registered
|
|
assert "task" in registered
|
|
assert "calendar_entry" in registered
|
|
assert "dms_file" in registered
|
|
assert "mail" in registered
|