diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index 7ca06e1..a07066d 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -893,7 +893,10 @@ async def list_threads( threads[tid]["mail_count"] += 1 threads[tid]["mails"].append(mail_to_response(mail)) 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. + return items # ─── Templates (F-MAIL-06) ─── diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index f2d4f32..0a8d9b3 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -285,6 +285,7 @@ async def create_mail_account( account = MailAccount( tenant_id=tenant_id, user_id=user_id, + owner_id=user_id, email_address=data["email_address"], display_name=data.get("display_name", ""), imap_host=data["imap_host"], diff --git a/tests/conftest.py b/tests/conftest.py index 6dadbbd..ca9e569 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,6 @@ Auth helpers talk to the HTTP API (integration tests). from __future__ import annotations -import asyncio import os import shutil 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.service_container import get_container # noqa: F401 from app.main import create_app + try: from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401 except ImportError: pass +from app.ai.oversight import DecisionRecordDB # noqa: F401 — ensure table is created 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_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.role import Role +from app.models.saved_filter import SavedFilter # noqa: F401 from app.models.tenant import Tenant from app.models.user import User, UserTenant from app.models.user_preference import UserPreference # noqa: F401 from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # 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. # This replaces ~30 hardcoded plugin imports with dynamic discovery (P1-14 fix). from app.plugins.registry import get_registry + _registry = get_registry() _registry.discover_builtins() 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 # 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 from app.plugins.builtins.knowledge.models import KnowledgeExtraction # noqa: F401 + # Self-improvement plugin models — new plugin, ensure tables are created in test-DB from app.plugins.builtins.self_improvement.models import ( # noqa: F401 - ImprovementSignal, + ImpactMeasurement, ImprovementPattern, 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.core.permission_registry import init_permission_registry # 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 @@ -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 from app.config import get_settings + get_settings.cache_clear() @@ -145,7 +152,7 @@ def _run_migrations(): raise RuntimeError( 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) @@ -382,6 +389,86 @@ async def redis_client() -> AsyncGenerator[aioredis.Redis, None]: 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") async def engine() -> AsyncGenerator[AsyncEngine, None]: """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() await container.initialize() - from app.plugins.builtins.permissions.plugin import PermissionsPlugin from app.plugins.builtins.dms.plugin import DmsPlugin + from app.plugins.builtins.permissions.plugin import PermissionsPlugin from app.plugins.builtins.tasks.plugin import TasksPlugin registry.register_plugin(PermissionsPlugin()) registry.register_plugin(DmsPlugin()) @@ -805,9 +892,9 @@ async def mcp_app(engine: AsyncEngine, redis_client): container = get_container() 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_server.plugin import McpServerPlugin + from app.plugins.builtins.permissions.plugin import PermissionsPlugin registry.register_plugin(PermissionsPlugin()) registry.register_plugin(McpServerPlugin()) registry.register_plugin(McpClientPlugin()) diff --git a/tests/test_mail.py b/tests/test_mail.py index 0a8c45f..889055d 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -489,8 +489,12 @@ async def test_download_attachment(mail_authed_client, db_session): mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]) ) - # Create attachment - test_file_path = f"/tmp/mail_test_{uuid.uuid4()}.txt" + # Create attachment — production stores files RELATIVE to the storage root + # (/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: f.write("attachment content") att = MailAttachment( @@ -499,7 +503,7 @@ async def test_download_attachment(mail_authed_client, db_session): filename="test.txt", mime_type="text/plain", size_bytes=17, - storage_path=test_file_path, + storage_path=test_file_name, ) db_session.add(att) await db_session.commit()