fix(i-e): Mail-Suite 46/46 gruen in 94s statt 18:29min — globales IMAP-Mock-Fixture + 3 echte Fixes
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
Root-Cause der 35 Suite-Timeouts: test_delete_folder trigger imap_delete_folder -> echter aioimaplib.IMAP4_SSL-Connect zu imap.example.com blockiert bis Netzwerk-Timeout; der blockierte Call vergiftet Event-Loop fuer alle nachfolgenden Tests (Kaskade ab 12. Test). Fixes: (1) tests/conftest.py: autouse mock_imap_connections-Fixture mit deterministischem Fake-IMAP-Client (_FakeIMAPResponse, alle Client-Methoden) via monkeypatch auf services.aioimaplib.IMAP4_SSL. (2) create_mail_account setzt owner_id=user_id gemaess OwnedMixin-Contract — vorher NULL -> get_effective_access read statt admin -> 403 bei assign_shared_users (echter Production-Bug). (3) test_download_attachment: storage_path relativ zum Storage-Root — Path-Traversal-Guard hat korrekt gearbeitet. (4) GET /mail/threads gibt Plain Array zurueck — konsistent mit Geschwister-Routen und fetchThreads(): Promise<ThreadResult[]>. Beweise: 46/46 passed in 94.41s (vorher 1 failed, 10 passed, 35 errors in 1109.94s); conftest-ruff-Findings auto-gefixt (8), Rest = Vorbestand E402 dynamische Plugin-Imports; Test nach Fix verifiziert.
This commit is contained in:
@@ -893,7 +893,10 @@ async def list_threads(
|
|||||||
threads[tid]["mail_count"] += 1
|
threads[tid]["mail_count"] += 1
|
||||||
threads[tid]["mails"].append(mail_to_response(mail))
|
threads[tid]["mails"].append(mail_to_response(mail))
|
||||||
items = list(threads.values())
|
items = list(threads.values())
|
||||||
return {"items": items, "total": len(items)}
|
# Plain array — consistent with sibling mail list routes (accounts,
|
||||||
|
# folders, templates) and with the frontend client type
|
||||||
|
# fetchThreads(): Promise<ThreadResult[]>.
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
# ─── Templates (F-MAIL-06) ───
|
# ─── Templates (F-MAIL-06) ───
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ async def create_mail_account(
|
|||||||
account = MailAccount(
|
account = MailAccount(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
owner_id=user_id,
|
||||||
email_address=data["email_address"],
|
email_address=data["email_address"],
|
||||||
display_name=data.get("display_name", ""),
|
display_name=data.get("display_name", ""),
|
||||||
imap_host=data["imap_host"],
|
imap_host=data["imap_host"],
|
||||||
|
|||||||
+101
-14
@@ -6,7 +6,6 @@ Auth helpers talk to the HTTP API (integration tests).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -40,28 +39,30 @@ from app.core.auth import hash_password
|
|||||||
from app.core.db import Base, close_engine, reset_engine_for_testing
|
from app.core.db import Base, close_engine, reset_engine_for_testing
|
||||||
from app.core.service_container import get_container # noqa: F401
|
from app.core.service_container import get_container # noqa: F401
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
from app.ai.oversight import DecisionRecordDB # noqa: F401 — ensure table is created
|
||||||
from app.models.compliance import ComplianceIncident # noqa: F401
|
from app.models.compliance import ComplianceIncident # noqa: F401
|
||||||
|
from app.models.consumer_inbox import ConsumerInbox # noqa: F401
|
||||||
from app.models.contact import Contact, ContactPerson # noqa: F401
|
from app.models.contact import Contact, ContactPerson # noqa: F401
|
||||||
from app.models.contact_merge import ContactMergeHistory # noqa: F401
|
from app.models.contact_merge import ContactMergeHistory # noqa: F401
|
||||||
|
from app.models.outbox import EventOutbox # noqa: F401
|
||||||
from app.models.plugin import Plugin, PluginMigration # noqa: F401
|
from app.models.plugin import Plugin, PluginMigration # noqa: F401
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
|
from app.models.saved_filter import SavedFilter # noqa: F401
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.models.user import User, UserTenant
|
from app.models.user import User, UserTenant
|
||||||
from app.models.user_preference import UserPreference # noqa: F401
|
from app.models.user_preference import UserPreference # noqa: F401
|
||||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
|
||||||
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
|
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
|
||||||
from app.models.outbox import EventOutbox # noqa: F401
|
|
||||||
from app.models.consumer_inbox import ConsumerInbox # noqa: F401
|
|
||||||
from app.models.saved_filter import SavedFilter # noqa: F401
|
|
||||||
from app.ai.oversight import DecisionRecordDB # noqa: F401 — ensure table is created
|
|
||||||
|
|
||||||
# Dynamically import all plugin models so Base.metadata.create_all() includes their tables.
|
# Dynamically import all plugin models so Base.metadata.create_all() includes their tables.
|
||||||
# This replaces ~30 hardcoded plugin imports with dynamic discovery (P1-14 fix).
|
# This replaces ~30 hardcoded plugin imports with dynamic discovery (P1-14 fix).
|
||||||
from app.plugins.registry import get_registry
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
_registry = get_registry()
|
_registry = get_registry()
|
||||||
_registry.discover_builtins()
|
_registry.discover_builtins()
|
||||||
for _plugin_name in _registry.list_discovered():
|
for _plugin_name in _registry.list_discovered():
|
||||||
@@ -84,19 +85,24 @@ for _plugin_name in _registry.list_discovered():
|
|||||||
|
|
||||||
# Also import core models that may be missing
|
# Also import core models that may be missing
|
||||||
# Wiki plugin models — not loaded by get_entity_models()
|
# Wiki plugin models — not loaded by get_entity_models()
|
||||||
from app.plugins.builtins.wiki.models import WikiArticle, WikiArticleVersion, WikiCategory # noqa: F401
|
from app.core.permission_registry import init_permission_registry # noqa: F401
|
||||||
|
|
||||||
# Knowledge plugin models — new plugin, ensure table is created in test-DB
|
# Knowledge plugin models — new plugin, ensure table is created in test-DB
|
||||||
from app.plugins.builtins.knowledge.models import KnowledgeExtraction # noqa: F401
|
from app.plugins.builtins.knowledge.models import KnowledgeExtraction # noqa: F401
|
||||||
|
|
||||||
# Self-improvement plugin models — new plugin, ensure tables are created in test-DB
|
# Self-improvement plugin models — new plugin, ensure tables are created in test-DB
|
||||||
from app.plugins.builtins.self_improvement.models import ( # noqa: F401
|
from app.plugins.builtins.self_improvement.models import ( # noqa: F401
|
||||||
ImprovementSignal,
|
ImpactMeasurement,
|
||||||
ImprovementPattern,
|
ImprovementPattern,
|
||||||
ImprovementProposal,
|
ImprovementProposal,
|
||||||
ImpactMeasurement,
|
ImprovementSignal,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.wiki.models import ( # noqa: F401
|
||||||
|
WikiArticle,
|
||||||
|
WikiArticleVersion,
|
||||||
|
WikiCategory,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||||
from app.core.permission_registry import init_permission_registry # noqa: F401
|
|
||||||
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
|
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
|
||||||
|
|
||||||
# Import plugin models so Base.metadata.create_all includes their tables
|
# Import plugin models so Base.metadata.create_all includes their tables
|
||||||
@@ -105,6 +111,7 @@ TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
|
|||||||
|
|
||||||
# Clear settings cache so the env overrides (set at top of file) take effect
|
# Clear settings cache so the env overrides (set at top of file) take effect
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +152,7 @@ def _run_migrations():
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"alembic upgrade head failed: {result.stderr or result.stdout}"
|
f"alembic upgrade head failed: {result.stderr or result.stdout}"
|
||||||
)
|
)
|
||||||
print(f"[CONFTEST] alembic upgrade head completed successfully")
|
print("[CONFTEST] alembic upgrade head completed successfully")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
@@ -382,6 +389,86 @@ async def redis_client() -> AsyncGenerator[aioredis.Redis, None]:
|
|||||||
await r.aclose()
|
await r.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Global IMAP mocking (Block I-E) ────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The mail service layer opens real aioimaplib.IMAP4_SSL connections to the
|
||||||
|
# account's imap_host (test fixtures use imap.example.com). Those calls block
|
||||||
|
# until network timeout and cascade: one hanging test poisons the event loop
|
||||||
|
# for every following test (35 suite-wide timeouts measured).
|
||||||
|
#
|
||||||
|
# This autouse fixture replaces IMAP4_SSL with a deterministic fake client for
|
||||||
|
# EVERY test — no test can accidentally hit the network.
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeIMAPResponse:
|
||||||
|
"""Mimics aioimaplib response objects: tuple-like + .result attribute."""
|
||||||
|
|
||||||
|
def __init__(self, result="OK", lines: list | None = None):
|
||||||
|
self.result = result
|
||||||
|
self.lines = lines or []
|
||||||
|
|
||||||
|
def __getitem__(self, idx):
|
||||||
|
if idx == 0:
|
||||||
|
return self.result
|
||||||
|
return self.lines[idx - 1] if 0 < idx <= len(self.lines) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fake_imap_client():
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
client = MagicMock()
|
||||||
|
client.wait_hello_from_server = AsyncMock(return_value=None)
|
||||||
|
client.login = AsyncMock(return_value=_FakeIMAPResponse("OK", [b"Logged in"]))
|
||||||
|
client.logout = AsyncMock(return_value=_FakeIMAPResponse("OK", [b"Bye"]))
|
||||||
|
# select(folder) → OK with EXISTS count
|
||||||
|
client.select = AsyncMock(
|
||||||
|
return_value=_FakeIMAPResponse("OK", [b"0"])
|
||||||
|
)
|
||||||
|
# uid_search(...) → response whose [1][0] is a space-separated uid bytes list
|
||||||
|
client.uid_search = AsyncMock(
|
||||||
|
return_value=_FakeIMAPResponse("OK", [b""])
|
||||||
|
)
|
||||||
|
# uid('fetch', ...) → minimal RFC822 envelope; services parse defensively
|
||||||
|
fetch_resp = _FakeIMAPResponse(
|
||||||
|
"OK",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
b"1 (RFC822 {5}",
|
||||||
|
b"Subject: t\r\n\r\nbody",
|
||||||
|
),
|
||||||
|
b")",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
client.uid = AsyncMock(return_value=fetch_resp)
|
||||||
|
client.getquotaroot = AsyncMock(
|
||||||
|
return_value=_FakeIMAPResponse("OK", [b"", b"(STORAGE 0 0)"])
|
||||||
|
)
|
||||||
|
client.list = AsyncMock(
|
||||||
|
return_value=_FakeIMAPResponse("OK", [])
|
||||||
|
)
|
||||||
|
client.append = AsyncMock(return_value=_FakeIMAPResponse("OK", [b"Appended"]))
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_imap_connections(monkeypatch):
|
||||||
|
"""Replace aioimaplib.IMAP4_SSL everywhere with a deterministic fake.
|
||||||
|
|
||||||
|
Autouse for all tests: any code path touching IMAP gets an instant fake
|
||||||
|
client instead of a blocking network call to a non-existent host.
|
||||||
|
"""
|
||||||
|
fake_client = _build_fake_imap_client()
|
||||||
|
|
||||||
|
def _fake_factory(*args, **kwargs):
|
||||||
|
return fake_client
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.plugins.builtins.mail.services.aioimaplib.IMAP4_SSL",
|
||||||
|
_fake_factory,
|
||||||
|
)
|
||||||
|
yield fake_client
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session")
|
@pytest_asyncio.fixture(scope="session")
|
||||||
async def engine() -> AsyncGenerator[AsyncEngine, None]:
|
async def engine() -> AsyncGenerator[AsyncEngine, None]:
|
||||||
"""Async engine for the test database (session-scoped for speed)."""
|
"""Async engine for the test database (session-scoped for speed)."""
|
||||||
@@ -690,8 +777,8 @@ async def dms_app(engine: AsyncEngine, redis_client):
|
|||||||
container = get_container()
|
container = get_container()
|
||||||
await container.initialize()
|
await container.initialize()
|
||||||
|
|
||||||
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
|
||||||
from app.plugins.builtins.dms.plugin import DmsPlugin
|
from app.plugins.builtins.dms.plugin import DmsPlugin
|
||||||
|
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
||||||
from app.plugins.builtins.tasks.plugin import TasksPlugin
|
from app.plugins.builtins.tasks.plugin import TasksPlugin
|
||||||
registry.register_plugin(PermissionsPlugin())
|
registry.register_plugin(PermissionsPlugin())
|
||||||
registry.register_plugin(DmsPlugin())
|
registry.register_plugin(DmsPlugin())
|
||||||
@@ -805,9 +892,9 @@ async def mcp_app(engine: AsyncEngine, redis_client):
|
|||||||
container = get_container()
|
container = get_container()
|
||||||
await container.initialize()
|
await container.initialize()
|
||||||
|
|
||||||
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
|
||||||
from app.plugins.builtins.mcp_server.plugin import McpServerPlugin
|
|
||||||
from app.plugins.builtins.mcp_client.plugin import McpClientPlugin
|
from app.plugins.builtins.mcp_client.plugin import McpClientPlugin
|
||||||
|
from app.plugins.builtins.mcp_server.plugin import McpServerPlugin
|
||||||
|
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
||||||
registry.register_plugin(PermissionsPlugin())
|
registry.register_plugin(PermissionsPlugin())
|
||||||
registry.register_plugin(McpServerPlugin())
|
registry.register_plugin(McpServerPlugin())
|
||||||
registry.register_plugin(McpClientPlugin())
|
registry.register_plugin(McpClientPlugin())
|
||||||
|
|||||||
+7
-3
@@ -489,8 +489,12 @@ async def test_download_attachment(mail_authed_client, db_session):
|
|||||||
mail = await _create_mail_direct(
|
mail = await _create_mail_direct(
|
||||||
db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"])
|
db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"])
|
||||||
)
|
)
|
||||||
# Create attachment
|
# Create attachment — production stores files RELATIVE to the storage root
|
||||||
test_file_path = f"/tmp/mail_test_{uuid.uuid4()}.txt"
|
# (/data/uploads); absolute paths are correctly rejected by the
|
||||||
|
# path-traversal guard in LocalStorage._full_path.
|
||||||
|
test_file_name = f"mail_test_{uuid.uuid4()}.txt"
|
||||||
|
storage_root = os.environ.get("STORAGE_PATH", "/data/uploads")
|
||||||
|
test_file_path = os.path.join(storage_root, test_file_name)
|
||||||
with open(test_file_path, "w") as f:
|
with open(test_file_path, "w") as f:
|
||||||
f.write("attachment content")
|
f.write("attachment content")
|
||||||
att = MailAttachment(
|
att = MailAttachment(
|
||||||
@@ -499,7 +503,7 @@ async def test_download_attachment(mail_authed_client, db_session):
|
|||||||
filename="test.txt",
|
filename="test.txt",
|
||||||
mime_type="text/plain",
|
mime_type="text/plain",
|
||||||
size_bytes=17,
|
size_bytes=17,
|
||||||
storage_path=test_file_path,
|
storage_path=test_file_name,
|
||||||
)
|
)
|
||||||
db_session.add(att)
|
db_session.add(att)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user