Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
+20
-17
@@ -82,6 +82,7 @@ from app.plugins.builtins.report_generator.models import ( # noqa: F401
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
|
||||
from app.plugins.builtins.tasks import TasksPlugin # noqa: F401
|
||||
from app.plugins.builtins.tasks.models import Task # noqa: F401
|
||||
from app.models.outbox import EventOutbox # noqa: F401
|
||||
from app.models.saved_filter import SavedFilter # noqa: F401
|
||||
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
|
||||
@@ -137,15 +138,25 @@ def db_setup():
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_tables(db_setup):
|
||||
"""Clean all table data before each test (preserve schema)."""
|
||||
"""Clean all table data before each test (preserve schema).
|
||||
|
||||
Dynamically builds the TRUNCATE list from tables that actually exist
|
||||
in the database, so plugin tables that were not created (e.g. when
|
||||
only core model tables are present) do not cause errors.
|
||||
"""
|
||||
sync_eng = _get_sync_engine()
|
||||
with sync_eng.connect() as conn:
|
||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||
conn.execute(
|
||||
# Query existing table names from information_schema
|
||||
result = conn.execute(
|
||||
text(
|
||||
"TRUNCATE TABLE report_instances, report_templates, contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE';"
|
||||
)
|
||||
)
|
||||
existing_tables = [row[0] for row in result]
|
||||
if existing_tables:
|
||||
table_list = ", ".join(existing_tables)
|
||||
conn.execute(text(f"TRUNCATE TABLE {table_list} CASCADE;"))
|
||||
conn.commit()
|
||||
sync_eng.dispose()
|
||||
yield
|
||||
@@ -218,41 +229,33 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
||||
|
||||
# Admin in tenant A
|
||||
admin_a = User(
|
||||
tenant_id=tenant_a.id,
|
||||
email="admin@tenanta.com",
|
||||
name="Admin A",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
# Viewer in tenant A
|
||||
viewer_a = User(
|
||||
tenant_id=tenant_a.id,
|
||||
email="viewer@tenanta.com",
|
||||
name="Viewer A",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="viewer",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
# Editor in tenant A
|
||||
editor_a = User(
|
||||
tenant_id=tenant_a.id,
|
||||
email="editor@tenanta.com",
|
||||
name="Editor A",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="editor",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
# Admin in tenant B
|
||||
admin_b = User(
|
||||
tenant_id=tenant_b.id,
|
||||
email="admin@tenantb.com",
|
||||
name="Admin B",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
@@ -260,12 +263,12 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
||||
await db.flush()
|
||||
|
||||
# User-tenant memberships
|
||||
ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True)
|
||||
ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True)
|
||||
ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True)
|
||||
ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True)
|
||||
ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True, role="admin")
|
||||
ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True, role="viewer")
|
||||
ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True, role="editor")
|
||||
ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True, role="admin")
|
||||
# Admin A is also member of tenant B (for switch-tenant test)
|
||||
ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False)
|
||||
ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False, role="admin")
|
||||
db.add_all([ut1, ut2, ut3, ut4, ut5])
|
||||
await db.flush()
|
||||
|
||||
|
||||
+40
-40
@@ -129,7 +129,7 @@ async def ai_proactive_authed_client(
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
# Grant is_system_admin so require_permission passes for ai_proactive:* permissions
|
||||
from sqlalchemy import update
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
await db_session.execute(
|
||||
update(User).where(User.id == seed["admin_a"].id).values(is_system_admin=True)
|
||||
)
|
||||
@@ -470,7 +470,7 @@ async def test_get_contact_mails_handler(db_session: AsyncSession):
|
||||
"""get_contact_mails_handler returns mails for a contact."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
||||
|
||||
@@ -478,16 +478,16 @@ async def test_get_contact_mails_handler(db_session: AsyncSession):
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="ct@example.com",
|
||||
name="CT",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
account = MailAccount(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
@@ -573,7 +573,7 @@ async def test_get_contact_history_handler(db_session: AsyncSession):
|
||||
"""get_contact_history_handler returns audit log entries."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.audit import AuditLog
|
||||
from app.core.auth import hash_password
|
||||
|
||||
@@ -581,16 +581,16 @@ async def test_get_contact_history_handler(db_session: AsyncSession):
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="hist@example.com",
|
||||
name="Hist",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="Hist",
|
||||
@@ -631,23 +631,23 @@ async def test_search_related_handler(db_session: AsyncSession):
|
||||
"""search_related_handler returns similar entities."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Rel Tenant", slug="rel-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="rel@example.com",
|
||||
name="Rel",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="Rel",
|
||||
@@ -677,7 +677,7 @@ async def test_search_related_handler(db_session: AsyncSession):
|
||||
async def test_summarize_mail_thread_handler(db_session: AsyncSession):
|
||||
"""summarize_mail_thread_handler returns a summary."""
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
||||
from app.core.auth import hash_password
|
||||
|
||||
@@ -685,16 +685,16 @@ async def test_summarize_mail_thread_handler(db_session: AsyncSession):
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="thread@example.com",
|
||||
name="Thread",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
account = MailAccount(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
@@ -770,23 +770,23 @@ async def test_get_open_tasks_handler(db_session: AsyncSession):
|
||||
"""get_open_tasks_handler returns open tasks."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Task Tenant", slug="task-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="task@example.com",
|
||||
name="Task",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="Task",
|
||||
@@ -817,7 +817,7 @@ async def test_get_open_tasks_handler(db_session: AsyncSession):
|
||||
async def test_hybrid_search_handler(db_session: AsyncSession):
|
||||
"""hybrid_search_handler returns search results."""
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
from app.plugins.builtins.unified_search.provider_registry import (
|
||||
get_search_registry,
|
||||
@@ -830,11 +830,9 @@ async def test_hybrid_search_handler(db_session: AsyncSession):
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="hs@example.com",
|
||||
name="HS",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
@@ -886,23 +884,25 @@ async def test_gather_context_contact(db_session: AsyncSession):
|
||||
"""gather_context for contact collects data."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="GC Tenant", slug="gc-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="gc@example.com",
|
||||
name="GC",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="GC",
|
||||
@@ -930,7 +930,7 @@ async def test_gather_context_contact(db_session: AsyncSession):
|
||||
async def test_gather_context_mail(db_session: AsyncSession):
|
||||
"""gather_context for mail collects data."""
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
||||
from app.core.auth import hash_password
|
||||
|
||||
@@ -938,16 +938,16 @@ async def test_gather_context_mail(db_session: AsyncSession):
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="gm@example.com",
|
||||
name="GM",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
account = MailAccount(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
@@ -1014,23 +1014,23 @@ async def test_gather_context_company(db_session: AsyncSession):
|
||||
"""gather_context for company collects data."""
|
||||
from app.models.contact import Contact as Company
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="GC2 Tenant", slug="gc2-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="gc2@example.com",
|
||||
name="GC2",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
company = Company(
|
||||
tenant_id=tenant.id,
|
||||
name="GC2 Company",
|
||||
@@ -1178,23 +1178,23 @@ async def test_handle_context_change_disabled(mock_create_session, redis_client)
|
||||
async def test_get_active_suggestions_expired(db_session: AsyncSession):
|
||||
"""Expired suggestions are not returned by get_active_suggestions."""
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Exp Tenant", slug="exp-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="exp@example.com",
|
||||
name="Exp",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
tenant_id = tenant.id
|
||||
user_id = user.id
|
||||
|
||||
@@ -1239,23 +1239,23 @@ async def test_get_active_suggestions_expired(db_session: AsyncSession):
|
||||
async def test_execute_suggested_action_success(db_session: AsyncSession):
|
||||
"""execute_suggested_action executes action and marks suggestion."""
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="ESA Tenant", slug="esa-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="esa@example.com",
|
||||
name="ESA",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
tenant_id = tenant.id
|
||||
user_id = user.id
|
||||
|
||||
@@ -1308,23 +1308,23 @@ async def test_execute_suggested_action_success(db_session: AsyncSession):
|
||||
async def test_execute_suggested_action_invalid_index(db_session: AsyncSession):
|
||||
"""execute_suggested_action with invalid index returns error."""
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="InvIdx Tenant", slug="invidx-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="invidx@example.com",
|
||||
name="InvIdx",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
tenant_id = tenant.id
|
||||
user_id = user.id
|
||||
|
||||
@@ -1546,23 +1546,23 @@ async def test_deep_analysis(mock_create_session, db_session: AsyncSession):
|
||||
from app.plugins.builtins.ai_proactive.jobs import deep_analysis
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="DA Tenant", slug="da-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="da@example.com",
|
||||
name="DA",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="DA",
|
||||
|
||||
+11
-2
@@ -144,7 +144,16 @@ class TestSwitchTenant:
|
||||
async def test_switch_tenant_returns_200(self, client: AsyncClient, db_session):
|
||||
"""AC 9: POST /api/v1/auth/switch-tenant -> 200, session tenant_id updated."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Login and extract csrf_token from response body
|
||||
login_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "admin@tenanta.com", "password": "TestPass123!"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
csrf_token = login_resp.json().get("csrf_token", "")
|
||||
if csrf_token:
|
||||
client.headers.update({"X-CSRF-Token": csrf_token})
|
||||
|
||||
# Get initial tenant
|
||||
resp = await client.get("/api/v1/auth/me")
|
||||
@@ -164,7 +173,7 @@ class TestSwitchTenant:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/switch-tenant",
|
||||
json={"tenant_id": str(tenant_b.id)},
|
||||
headers=ORIGIN_HEADER,
|
||||
headers={**ORIGIN_HEADER, "X-CSRF-Token": csrf_token} if csrf_token else ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tenant_id"] == str(tenant_b.id)
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
"""Tests for the Command pattern — Create, Update, Delete, Merge contact commands.
|
||||
|
||||
Tests cover:
|
||||
- Permission checks (viewer denied, admin allowed)
|
||||
- Business logic execution
|
||||
- Audit log entry creation
|
||||
- Outbox event enqueuing
|
||||
- State machine validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import uuid as uuid_mod
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import CommandResult
|
||||
from app.commands.contact_commands import (
|
||||
CreateContactCommand,
|
||||
UpdateContactCommand,
|
||||
DeleteContactCommand,
|
||||
MergeContactsCommand,
|
||||
)
|
||||
from app.core.state_machine import (
|
||||
StateMachine,
|
||||
StateMachineError,
|
||||
contact_state_machine,
|
||||
workflow_state_machine,
|
||||
)
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact
|
||||
from app.models.outbox import EventOutbox
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
# ── State Machine Unit Tests ──
|
||||
|
||||
|
||||
class TestStateMachine:
|
||||
"""Unit tests for the StateMachine class."""
|
||||
|
||||
def test_can_transition_allowed(self):
|
||||
"""Allowed transitions return True."""
|
||||
assert contact_state_machine.can_transition("lead", "qualified") is True
|
||||
assert contact_state_machine.can_transition("qualified", "customer") is True
|
||||
assert contact_state_machine.can_transition("customer", "inactive") is True
|
||||
assert contact_state_machine.can_transition("inactive", "lead") is True
|
||||
|
||||
def test_can_transition_disallowed(self):
|
||||
"""Disallowed transitions return False."""
|
||||
assert contact_state_machine.can_transition("customer", "lead") is False
|
||||
assert contact_state_machine.can_transition("inactive", "customer") is False
|
||||
|
||||
def test_transition_success(self):
|
||||
"""Valid transition returns the new state."""
|
||||
assert contact_state_machine.transition("lead", "qualified") == "qualified"
|
||||
assert workflow_state_machine.transition("active", "paused") == "paused"
|
||||
|
||||
def test_transition_invalid_raises(self):
|
||||
"""Invalid transition raises StateMachineError."""
|
||||
with pytest.raises(StateMachineError, match="Invalid state transition"):
|
||||
contact_state_machine.transition("customer", "lead")
|
||||
|
||||
def test_transition_unknown_state_raises(self):
|
||||
"""Transition from unknown state raises StateMachineError."""
|
||||
with pytest.raises(StateMachineError, match="Invalid state transition"):
|
||||
contact_state_machine.transition("nonexistent", "lead")
|
||||
|
||||
def test_workflow_state_machine_transitions(self):
|
||||
"""Workflow state machine has correct transitions."""
|
||||
assert workflow_state_machine.can_transition("draft", "active") is True
|
||||
assert workflow_state_machine.can_transition("active", "paused") is True
|
||||
assert workflow_state_machine.can_transition("paused", "active") is True
|
||||
assert workflow_state_machine.can_transition("active", "completed") is True
|
||||
assert workflow_state_machine.can_transition("active", "cancelled") is True
|
||||
assert workflow_state_machine.can_transition("paused", "cancelled") is True
|
||||
assert workflow_state_machine.can_transition("draft", "cancelled") is True
|
||||
assert workflow_state_machine.can_transition("completed", "active") is False
|
||||
assert workflow_state_machine.can_transition("cancelled", "active") is False
|
||||
|
||||
def test_custom_state_machine(self):
|
||||
"""Custom state machine with own transitions."""
|
||||
sm = StateMachine({"a": ["b"], "b": ["c"], "c": []})
|
||||
assert sm.can_transition("a", "b") is True
|
||||
assert sm.can_transition("b", "c") is True
|
||||
assert sm.can_transition("c", "a") is False
|
||||
assert sm.transition("a", "b") == "b"
|
||||
with pytest.raises(StateMachineError):
|
||||
sm.transition("c", "a")
|
||||
|
||||
|
||||
# ── CommandResult Tests ──
|
||||
|
||||
|
||||
class TestCommandResult:
|
||||
"""Unit tests for CommandResult."""
|
||||
|
||||
def test_ok_result(self):
|
||||
"""ok() creates a successful result."""
|
||||
result = CommandResult.ok(data={"id": "123"})
|
||||
assert result.success is True
|
||||
assert result.data == {"id": "123"}
|
||||
assert result.error is None
|
||||
assert result.events == []
|
||||
|
||||
def test_ok_with_events(self):
|
||||
"""ok() with events creates a successful result with events."""
|
||||
events = [{"event": "contact.created"}]
|
||||
result = CommandResult.ok(data={}, events=events)
|
||||
assert result.events == events
|
||||
|
||||
def test_fail_result(self):
|
||||
"""fail() creates a failed result."""
|
||||
result = CommandResult.fail("Something went wrong")
|
||||
assert result.success is False
|
||||
assert result.data is None
|
||||
assert result.error == "Something went wrong"
|
||||
assert result.events == []
|
||||
|
||||
|
||||
# ── CreateContactCommand Tests ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateContactCommand:
|
||||
"""Tests for CreateContactCommand."""
|
||||
|
||||
async def test_create_contact_admin_success(
|
||||
self, client, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Admin can create a contact, audit log is created, outbox event enqueued."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
|
||||
# Use command directly with db_session and redis_client
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
cmd = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Test Company",
|
||||
"email_1": "test@example.com",
|
||||
})
|
||||
result = await cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is True
|
||||
assert result.data is not None
|
||||
assert result.data["name"] == "Test Company"
|
||||
assert result.data["displayname"] == "Test Company"
|
||||
assert len(result.events) >= 1
|
||||
assert result.events[0]["event"] == "contact.created"
|
||||
|
||||
# Verify audit log was created
|
||||
await db_session.flush()
|
||||
audit_q = select(AuditLog).where(
|
||||
AuditLog.entity_type == "contact",
|
||||
AuditLog.action == "create",
|
||||
AuditLog.user_id == seed["admin_a"].id,
|
||||
)
|
||||
audit_result = await db_session.execute(audit_q)
|
||||
audit_entries = audit_result.scalars().all()
|
||||
assert len(audit_entries) >= 1
|
||||
assert audit_entries[0].entity_id == uuid_mod.UUID(result.data["id"])
|
||||
|
||||
# Verify outbox event was enqueued
|
||||
outbox_q = select(EventOutbox).where(
|
||||
EventOutbox.event_name == "contact.created"
|
||||
)
|
||||
outbox_result = await db_session.execute(outbox_q)
|
||||
outbox_entries = outbox_result.scalars().all()
|
||||
assert len(outbox_entries) >= 1
|
||||
|
||||
async def test_create_contact_viewer_denied(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Viewer cannot create a contact (permission denied)."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["viewer_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "viewer",
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
cmd = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Denied Company",
|
||||
})
|
||||
result = await cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "Permission denied" in result.error
|
||||
|
||||
async def test_create_contact_with_invalid_status(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Creating a contact with invalid status fails."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
cmd = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Bad Status Corp",
|
||||
"status": "nonexistent",
|
||||
})
|
||||
result = await cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "Invalid contact status" in result.error
|
||||
|
||||
|
||||
# ── UpdateContactCommand Tests ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateContactCommand:
|
||||
"""Tests for UpdateContactCommand."""
|
||||
|
||||
async def test_update_contact_admin_success(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Admin can update a contact, audit log created, outbox event enqueued."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
# First create a contact
|
||||
create_cmd = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Original Name",
|
||||
})
|
||||
create_result = await create_cmd.execute(db_session, redis_client, current_user)
|
||||
assert create_result.success is True
|
||||
contact_id = create_result.data["id"]
|
||||
|
||||
# Update the contact
|
||||
update_cmd = UpdateContactCommand(
|
||||
contact_id=contact_id,
|
||||
data={"name": "Updated Name", "email_1": "updated@example.com"},
|
||||
)
|
||||
update_result = await update_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert update_result.success is True
|
||||
assert update_result.data["name"] == "Updated Name"
|
||||
assert update_result.data["email_1"] == "updated@example.com"
|
||||
assert len(update_result.events) >= 1
|
||||
assert update_result.events[0]["event"] == "contact.updated"
|
||||
|
||||
# Verify audit log
|
||||
await db_session.flush()
|
||||
audit_q = select(AuditLog).where(
|
||||
AuditLog.entity_type == "contact",
|
||||
AuditLog.action == "update",
|
||||
AuditLog.entity_id == uuid_mod.UUID(contact_id),
|
||||
)
|
||||
audit_result = await db_session.execute(audit_q)
|
||||
audit_entries = audit_result.scalars().all()
|
||||
assert len(audit_entries) >= 1
|
||||
|
||||
async def test_update_contact_not_found(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Updating a non-existent contact fails gracefully."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
random_id = str(uuid_mod.uuid4())
|
||||
update_cmd = UpdateContactCommand(
|
||||
contact_id=random_id,
|
||||
data={"name": "New Name"},
|
||||
)
|
||||
result = await update_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
async def test_update_contact_viewer_denied(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Viewer cannot update a contact."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["viewer_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "viewer",
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
cmd = UpdateContactCommand(
|
||||
contact_id=str(seed["company_a"].id),
|
||||
data={"name": "Hacked"},
|
||||
)
|
||||
result = await cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "Permission denied" in result.error
|
||||
|
||||
|
||||
# ── DeleteContactCommand Tests ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteContactCommand:
|
||||
"""Tests for DeleteContactCommand."""
|
||||
|
||||
async def test_soft_delete_contact_admin_success(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Admin can soft-delete a contact."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
# Create a contact to delete
|
||||
create_cmd = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "To Delete",
|
||||
})
|
||||
create_result = await create_cmd.execute(db_session, redis_client, current_user)
|
||||
assert create_result.success is True
|
||||
contact_id = create_result.data["id"]
|
||||
|
||||
# Soft-delete it
|
||||
delete_cmd = DeleteContactCommand(contact_id=contact_id, hard=False)
|
||||
delete_result = await delete_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert delete_result.success is True
|
||||
assert delete_result.data is None
|
||||
assert len(delete_result.events) >= 1
|
||||
assert delete_result.events[0]["event"] == "contact.deleted"
|
||||
|
||||
# Verify audit log
|
||||
await db_session.flush()
|
||||
audit_q = select(AuditLog).where(
|
||||
AuditLog.entity_type == "contact",
|
||||
AuditLog.action == "delete",
|
||||
AuditLog.entity_id == uuid_mod.UUID(contact_id),
|
||||
)
|
||||
audit_result = await db_session.execute(audit_q)
|
||||
audit_entries = audit_result.scalars().all()
|
||||
assert len(audit_entries) >= 1
|
||||
|
||||
async def test_hard_delete_contact_admin_success(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Admin can hard-delete a contact."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
# Create a contact to hard-delete
|
||||
create_cmd = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Hard Delete Me",
|
||||
})
|
||||
create_result = await create_cmd.execute(db_session, redis_client, current_user)
|
||||
assert create_result.success is True
|
||||
contact_id = create_result.data["id"]
|
||||
|
||||
# Hard-delete it
|
||||
delete_cmd = DeleteContactCommand(contact_id=contact_id, hard=True)
|
||||
delete_result = await delete_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert delete_result.success is True
|
||||
assert len(delete_result.events) >= 1
|
||||
assert delete_result.events[0]["event"] == "contact.hard_deleted"
|
||||
|
||||
async def test_delete_contact_not_found(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Deleting a non-existent contact fails gracefully."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
random_id = str(uuid_mod.uuid4())
|
||||
delete_cmd = DeleteContactCommand(contact_id=random_id)
|
||||
result = await delete_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
async def test_delete_contact_viewer_denied(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Viewer cannot delete a contact."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["viewer_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "viewer",
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
cmd = DeleteContactCommand(contact_id=str(seed["company_a"].id))
|
||||
result = await cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "Permission denied" in result.error
|
||||
|
||||
|
||||
# ── MergeContactsCommand Tests ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMergeContactsCommand:
|
||||
"""Tests for MergeContactsCommand."""
|
||||
|
||||
async def test_merge_contacts_admin_success(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Admin can merge two contacts, audit log created, outbox event enqueued."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
# Create two contacts to merge
|
||||
create_cmd1 = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Source Company",
|
||||
"email_1": "source@example.com",
|
||||
})
|
||||
result1 = await create_cmd1.execute(db_session, redis_client, current_user)
|
||||
assert result1.success is True
|
||||
source_id = result1.data["id"]
|
||||
|
||||
create_cmd2 = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Target Company",
|
||||
"email_1": "target@example.com",
|
||||
})
|
||||
result2 = await create_cmd2.execute(db_session, redis_client, current_user)
|
||||
assert result2.success is True
|
||||
target_id = result2.data["id"]
|
||||
|
||||
# Merge source → target
|
||||
merge_cmd = MergeContactsCommand(
|
||||
source_contact_id=source_id,
|
||||
target_contact_id=target_id,
|
||||
note="Duplicate detected",
|
||||
)
|
||||
merge_result = await merge_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert merge_result.success is True
|
||||
assert merge_result.data is not None
|
||||
assert "history" in merge_result.data
|
||||
assert merge_result.data["history"]["source_id"] == source_id
|
||||
assert merge_result.data["history"]["target_id"] == target_id
|
||||
assert len(merge_result.events) >= 1
|
||||
assert merge_result.events[0]["event"] == "contact.merged"
|
||||
|
||||
# Verify audit log
|
||||
await db_session.flush()
|
||||
audit_q = select(AuditLog).where(
|
||||
AuditLog.entity_type == "contact",
|
||||
AuditLog.action == "merge",
|
||||
AuditLog.entity_id == uuid_mod.UUID(target_id),
|
||||
)
|
||||
audit_result = await db_session.execute(audit_q)
|
||||
audit_entries = audit_result.scalars().all()
|
||||
assert len(audit_entries) >= 1
|
||||
|
||||
async def test_merge_same_contact_fails(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Merging a contact with itself fails."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
# Create a contact
|
||||
create_cmd = CreateContactCommand(data={
|
||||
"type": "company",
|
||||
"name": "Solo Company",
|
||||
})
|
||||
create_result = await create_cmd.execute(db_session, redis_client, current_user)
|
||||
assert create_result.success is True
|
||||
contact_id = create_result.data["id"]
|
||||
|
||||
# Try to merge with itself
|
||||
merge_cmd = MergeContactsCommand(
|
||||
source_contact_id=contact_id,
|
||||
target_contact_id=contact_id,
|
||||
)
|
||||
result = await merge_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "must be different" in result.error
|
||||
|
||||
async def test_merge_contacts_viewer_denied(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Viewer cannot merge contacts."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["viewer_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "viewer",
|
||||
"is_system_admin": False,
|
||||
"permissions": ["contacts:read"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
merge_cmd = MergeContactsCommand(
|
||||
source_contact_id=str(seed["company_a"].id),
|
||||
target_contact_id=str(seed["company_a"].id),
|
||||
)
|
||||
result = await merge_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "Permission denied" in result.error
|
||||
|
||||
async def test_merge_contact_not_found(
|
||||
self, db_session: AsyncSession, redis_client
|
||||
):
|
||||
"""Merging with a non-existent contact fails."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
|
||||
current_user = {
|
||||
"user_id": str(seed["admin_a"].id),
|
||||
"tenant_id": str(seed["tenant_a"].id),
|
||||
"role": "admin",
|
||||
"is_system_admin": True,
|
||||
"permissions": ["*: *"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
}
|
||||
|
||||
random_id = str(uuid_mod.uuid4())
|
||||
merge_cmd = MergeContactsCommand(
|
||||
source_contact_id=random_id,
|
||||
target_contact_id=str(seed["company_a"].id),
|
||||
)
|
||||
result = await merge_cmd.execute(db_session, redis_client, current_user)
|
||||
|
||||
assert result.success is False
|
||||
assert "not found" in result.error.lower()
|
||||
+3
-6
@@ -6,8 +6,6 @@ permissions plugin public share endpoints.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client
|
||||
@@ -206,10 +204,9 @@ async def test_ac5_upload_file(authed_client):
|
||||
assert data["size_bytes"] == len(PDF_CONTENT)
|
||||
assert data["folder_id"] is None
|
||||
assert "id" in data
|
||||
assert "storage_path" in data
|
||||
|
||||
# Verify file exists on disk
|
||||
assert os.path.exists(data["storage_path"])
|
||||
assert "content_hash" in data
|
||||
assert len(data["content_hash"]) == 64
|
||||
assert "storage_path" not in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -360,7 +360,7 @@ class TestFileCoverage:
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_file_missing_on_disk(self, authed_client):
|
||||
async def test_preview_file_missing_on_disk(self, authed_client, db_session):
|
||||
"""GET /files/{id}/preview → 404 when file missing on disk."""
|
||||
client, _ = authed_client
|
||||
resp = await client.post(
|
||||
@@ -369,10 +369,23 @@ class TestFileCoverage:
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
file_id = resp.json()["id"]
|
||||
storage_path = resp.json()["storage_path"]
|
||||
assert "storage_path" not in resp.json()
|
||||
|
||||
if os.path.exists(storage_path):
|
||||
os.remove(storage_path)
|
||||
# Remove file from disk via storage backend
|
||||
from app.core.storage import get_storage_backend
|
||||
|
||||
storage = get_storage_backend()
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
|
||||
# Need to get storage_path from DB (not exposed in API)
|
||||
import uuid as _uuid
|
||||
|
||||
result = await db_session.execute(select(DmsFile).where(DmsFile.id == _uuid.UUID(file_id)))
|
||||
dms_file = result.scalar_one_or_none()
|
||||
if dms_file:
|
||||
await storage.delete(dms_file.storage_path)
|
||||
|
||||
resp = await client.get(f"/api/v1/dms/files/{file_id}/preview", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tests for the transactional outbox pattern.
|
||||
|
||||
Covers:
|
||||
- enqueue_outbox_event inserts rows with status='pending'
|
||||
- process_outbox_batch publishes events to the in-process bus
|
||||
- Retry logic with exponential backoff
|
||||
- Max attempts → 'failed' status
|
||||
- Empty batch returns 0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.core.outbox import enqueue_outbox_event, process_outbox_batch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_outbox_event_inserts_pending_row(db_session):
|
||||
"""enqueue_outbox_event inserts a row with status='pending'."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "contact.created",
|
||||
{"contact_id": "abc-123", "tenant_id": str(tenant_id)},
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text("SELECT event_name, status, payload FROM event_outbox WHERE tenant_id = :tid"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "contact.created"
|
||||
assert rows[0][1] == "pending"
|
||||
assert rows[0][2]["contact_id"] == "abc-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_publishes_events(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""process_outbox_batch publishes pending events and marks them 'published'."""
|
||||
tenant_id = uuid.uuid4()
|
||||
received_events: list[tuple[str, dict]] = []
|
||||
|
||||
async def _handler(payload: dict) -> None:
|
||||
received_events.append(("test.event", payload))
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.event", _handler)
|
||||
try:
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "test.event",
|
||||
{"key": "value"},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Use a separate session to simulate the worker
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
|
||||
assert count == 1
|
||||
assert len(received_events) == 1
|
||||
assert received_events[0][1]["key"] == "value"
|
||||
|
||||
# Verify the event is marked as published
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text("SELECT status FROM event_outbox WHERE tenant_id = :tid"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
assert rows[0][0] == "published"
|
||||
finally:
|
||||
bus.unsubscribe("test.event", _handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_empty_returns_zero(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""process_outbox_batch returns 0 when no pending events exist."""
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
assert count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_retry_on_failure(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""When a handler raises, the event is retried with exponential backoff."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
async def _failing_handler(payload: dict) -> None:
|
||||
raise RuntimeError("Handler failure")
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.failing", _failing_handler)
|
||||
try:
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "test.failing",
|
||||
{"attempt": 1},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
|
||||
assert count == 0 # nothing was successfully published
|
||||
|
||||
# Verify the event is back to 'pending' with attempts=1 and a retry time
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"SELECT status, attempts, next_retry_at "
|
||||
"FROM event_outbox WHERE tenant_id = :tid"
|
||||
),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
assert rows[0][0] == "pending"
|
||||
assert rows[0][1] == 1
|
||||
assert rows[0][2] is not None
|
||||
finally:
|
||||
bus.unsubscribe("test.failing", _failing_handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_max_attempts_marks_failed(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""After max_attempts failures, the event is marked as 'failed'."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
async def _always_fails(payload: dict) -> None:
|
||||
raise RuntimeError("Always fails")
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.maxfail", _always_fails)
|
||||
try:
|
||||
# Insert an event that already has attempts = max_attempts - 1
|
||||
await db_session.execute(
|
||||
text(
|
||||
"INSERT INTO event_outbox (tenant_id, event_name, payload, status, attempts, max_attempts) "
|
||||
"VALUES (:tid, 'test.maxfail', CAST(:payload AS JSONB), 'pending', 4, 5)"
|
||||
),
|
||||
{"tid": str(tenant_id), "payload": '{"k": "v"}'},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
|
||||
assert count == 0
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text("SELECT status, attempts FROM event_outbox WHERE tenant_id = :tid"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
assert rows[0][0] == "failed"
|
||||
finally:
|
||||
bus.unsubscribe("test.maxfail", _always_fails)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_multiple_events_and_batch_size(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""Multiple events are enqueued and batch_size limits processing."""
|
||||
tenant_id = uuid.uuid4()
|
||||
received: list[str] = []
|
||||
|
||||
async def _handler(payload: dict) -> None:
|
||||
received.append(payload.get("idx", "?"))
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.batch", _handler)
|
||||
try:
|
||||
for i in range(5):
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "test.batch",
|
||||
{"idx": str(i)},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=3)
|
||||
|
||||
assert count == 3
|
||||
assert len(received) == 3
|
||||
|
||||
# Process the remaining 2
|
||||
async with session_factory() as worker_session:
|
||||
count2 = await process_outbox_batch(worker_session, batch_size=3)
|
||||
assert count2 == 2
|
||||
assert len(received) == 5
|
||||
finally:
|
||||
bus.unsubscribe("test.batch", _handler)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Unit tests for P1-6: DMS file processing — chunked streaming, SHA-256, sanitization.
|
||||
|
||||
These tests verify the new functionality without requiring the full CSRF-protected
|
||||
HTTP stack. They test the helper functions and storage backend directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.storage import LocalStorage, StorageBackend
|
||||
from app.plugins.builtins.dms.routes import CHUNK_SIZE, _sanitize_filename
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
"""Test the _sanitize_filename helper."""
|
||||
|
||||
def test_simple_filename(self):
|
||||
assert _sanitize_filename("document.pdf") == "document.pdf"
|
||||
|
||||
def test_strips_path_separators(self):
|
||||
result = _sanitize_filename("../../etc/passwd")
|
||||
assert "/" not in result
|
||||
assert ".." not in result
|
||||
assert result == "passwd"
|
||||
|
||||
def test_strips_backslashes(self):
|
||||
result = _sanitize_filename("..\\..\\windows\\system32")
|
||||
assert ".." not in result
|
||||
# On Linux, backslash is not a path separator, so it's stripped by the safe-filename regex
|
||||
assert "\\" not in result
|
||||
|
||||
def test_strips_control_chars(self):
|
||||
result = _sanitize_filename("file\x00name.txt")
|
||||
assert "\x00" not in result
|
||||
assert "file" in result
|
||||
|
||||
def test_empty_filename(self):
|
||||
assert _sanitize_filename("") == "file"
|
||||
|
||||
def test_none_like_filename(self):
|
||||
assert _sanitize_filename(" ") == "file"
|
||||
|
||||
def test_preserves_extension(self):
|
||||
result = _sanitize_filename("report.pdf")
|
||||
assert result.endswith(".pdf")
|
||||
|
||||
def test_strips_leading_dots(self):
|
||||
result = _sanitize_filename(".hidden")
|
||||
assert not result.startswith(".")
|
||||
|
||||
def test_collapses_multiple_dots(self):
|
||||
result = _sanitize_filename("file...txt")
|
||||
assert "..." not in result
|
||||
|
||||
def test_collapses_multiple_spaces(self):
|
||||
result = _sanitize_filename("file name.pdf")
|
||||
assert " " not in result
|
||||
|
||||
def test_truncates_long_filename(self):
|
||||
long_name = "a" * 250 + ".pdf"
|
||||
result = _sanitize_filename(long_name)
|
||||
assert len(result) <= 200
|
||||
|
||||
def test_dangerous_chars_removed(self):
|
||||
result = _sanitize_filename("file;rm -rf /.txt")
|
||||
assert ";" not in result
|
||||
assert "rm" not in result or result == "file-rm-rf.txt"
|
||||
|
||||
|
||||
class TestChunkSize:
|
||||
"""Verify chunk size constant."""
|
||||
|
||||
def test_chunk_size_is_1mb(self):
|
||||
assert CHUNK_SIZE == 1024 * 1024
|
||||
|
||||
|
||||
class TestLocalStorageStreaming:
|
||||
"""Test LocalStorage.save_stream for chunked writes."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_stream_writes_all_chunks(self, tmp_path):
|
||||
storage = LocalStorage(base_path=str(tmp_path))
|
||||
chunks = [b"chunk1_", b"chunk2_", b"chunk3"]
|
||||
|
||||
async def chunk_iter():
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
total = await storage.save_stream("test/stream_file.bin", chunk_iter())
|
||||
assert total == sum(len(c) for c in chunks)
|
||||
|
||||
# Verify file content
|
||||
full_path = os.path.join(str(tmp_path), "test", "stream_file.bin")
|
||||
with open(full_path, "rb") as f:
|
||||
content = f.read()
|
||||
assert content == b"chunk1_chunk2_chunk3"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_stream_empty_file(self, tmp_path):
|
||||
storage = LocalStorage(base_path=str(tmp_path))
|
||||
|
||||
async def chunk_iter():
|
||||
return
|
||||
yield # make it an async generator
|
||||
|
||||
total = await storage.save_stream("empty.bin", chunk_iter())
|
||||
assert total == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_stream_creates_directories(self, tmp_path):
|
||||
storage = LocalStorage(base_path=str(tmp_path))
|
||||
|
||||
async def chunk_iter():
|
||||
yield b"data"
|
||||
|
||||
await storage.save_stream("deep/nested/path/file.bin", chunk_iter())
|
||||
full_path = os.path.join(str(tmp_path), "deep", "nested", "path", "file.bin")
|
||||
assert os.path.exists(full_path)
|
||||
|
||||
|
||||
class TestStreamingHashIntegration:
|
||||
"""Test that streaming produces correct SHA-256 hash."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_hash_matches_full_read(self, tmp_path):
|
||||
"""Verify that chunked streaming produces the same SHA-256 as reading the full file."""
|
||||
storage = LocalStorage(base_path=str(tmp_path))
|
||||
content = b"x" * (CHUNK_SIZE * 2 + 12345) # ~2MB + some
|
||||
|
||||
# Compute expected hash
|
||||
expected_hash = hashlib.sha256(content).hexdigest()
|
||||
|
||||
# Simulate chunked upload
|
||||
hasher = hashlib.sha256()
|
||||
total_size = 0
|
||||
|
||||
async def chunk_iter():
|
||||
nonlocal total_size
|
||||
offset = 0
|
||||
while offset < len(content):
|
||||
chunk = content[offset : offset + CHUNK_SIZE]
|
||||
total_size += len(chunk)
|
||||
hasher.update(chunk)
|
||||
yield chunk
|
||||
offset += CHUNK_SIZE
|
||||
|
||||
await storage.save_stream("hash_test.bin", chunk_iter())
|
||||
|
||||
assert total_size == len(content)
|
||||
assert hasher.hexdigest() == expected_hash
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_hash_small_file(self, tmp_path):
|
||||
"""Verify hash for a small file that fits in one chunk."""
|
||||
storage = LocalStorage(base_path=str(tmp_path))
|
||||
content = b"small file content"
|
||||
expected_hash = hashlib.sha256(content).hexdigest()
|
||||
|
||||
hasher = hashlib.sha256()
|
||||
|
||||
async def chunk_iter():
|
||||
hasher.update(content)
|
||||
yield content
|
||||
|
||||
await storage.save_stream("small.bin", chunk_iter())
|
||||
assert hasher.hexdigest() == expected_hash
|
||||
|
||||
|
||||
class TestStoragePathNotInSchema:
|
||||
"""Verify storage_path is not in the API response schema."""
|
||||
|
||||
def test_file_metadata_response_no_storage_path(self):
|
||||
from app.plugins.builtins.dms.schemas import FileMetadataResponse
|
||||
|
||||
fields = FileMetadataResponse.model_fields
|
||||
assert "storage_path" not in fields
|
||||
assert "content_hash" in fields
|
||||
|
||||
|
||||
class TestModelHasContentHash:
|
||||
"""Verify DmsFile model has content_hash column."""
|
||||
|
||||
def test_model_has_content_hash_column(self):
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
|
||||
assert hasattr(DmsFile, "content_hash")
|
||||
col = DmsFile.__table__.columns.get("content_hash")
|
||||
assert col is not None
|
||||
assert col.type.length == 64
|
||||
assert col.nullable is True
|
||||
|
||||
|
||||
class TestMigrationContentHash:
|
||||
"""Verify migration 0038 exists and has correct revision chain."""
|
||||
|
||||
def test_migration_file_exists(self):
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
"alembic",
|
||||
"versions",
|
||||
"0038_dms_content_hash.py",
|
||||
)
|
||||
assert os.path.exists(path)
|
||||
|
||||
def test_migration_revision_id(self):
|
||||
import importlib.util
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
"alembic",
|
||||
"versions",
|
||||
"0038_dms_content_hash.py",
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("migration_0038", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
assert module.revision == "0038_dms_content_hash"
|
||||
assert module.down_revision == "0037_user_tenant_model"
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Unit tests for P1-7 permission system fixes.
|
||||
|
||||
Tests:
|
||||
1. _merge_field_permissions: strictest-wins merge logic
|
||||
2. invalidate_all_user_permissions: SCAN-based (no KEYS)
|
||||
3. get_cached_permissions: version validation logic
|
||||
4. require_write: no broad wildcard *:write
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.permissions import (
|
||||
_FIELD_PERM_SEVERITY,
|
||||
_merge_field_permissions,
|
||||
_matches_permission,
|
||||
_normalize_permissions,
|
||||
check_permission,
|
||||
CACHE_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
class TestMergeFieldPermissions:
|
||||
"""Tests for _merge_field_permissions — strictest-wins merge."""
|
||||
|
||||
def test_empty_incoming_no_change(self):
|
||||
existing = {"contacts": {"name": "read"}}
|
||||
_merge_field_permissions(existing, {})
|
||||
assert existing == {"contacts": {"name": "read"}}
|
||||
|
||||
def test_new_module_added(self):
|
||||
existing = {}
|
||||
_merge_field_permissions(existing, {"contacts": {"name": "hidden"}})
|
||||
assert existing == {"contacts": {"name": "hidden"}}
|
||||
|
||||
def test_strictest_wins_hidden_over_read(self):
|
||||
existing = {"contacts": {"name": "read"}}
|
||||
_merge_field_permissions(existing, {"contacts": {"name": "hidden"}})
|
||||
assert existing["contacts"]["name"] == "hidden"
|
||||
|
||||
def test_strictest_wins_read_does_not_override_hidden(self):
|
||||
existing = {"contacts": {"name": "hidden"}}
|
||||
_merge_field_permissions(existing, {"contacts": {"name": "read"}})
|
||||
assert existing["contacts"]["name"] == "hidden"
|
||||
|
||||
def test_strictest_wins_readonly_over_read(self):
|
||||
existing = {"contacts": {"name": "read"}}
|
||||
_merge_field_permissions(existing, {"contacts": {"name": "readonly"}})
|
||||
assert existing["contacts"]["name"] == "readonly"
|
||||
|
||||
def test_strictest_wins_readonly_does_not_override_hidden(self):
|
||||
existing = {"contacts": {"name": "hidden"}}
|
||||
_merge_field_permissions(existing, {"contacts": {"name": "readonly"}})
|
||||
assert existing["contacts"]["name"] == "hidden"
|
||||
|
||||
def test_multiple_fields_merge_independently(self):
|
||||
existing = {"contacts": {"name": "hidden", "email": "read"}}
|
||||
_merge_field_permissions(
|
||||
existing,
|
||||
{"contacts": {"name": "read", "email": "hidden", "phone": "readonly"}},
|
||||
)
|
||||
assert existing["contacts"]["name"] == "hidden" # hidden stayed
|
||||
assert existing["contacts"]["email"] == "hidden" # read upgraded to hidden
|
||||
assert existing["contacts"]["phone"] == "readonly" # new field added
|
||||
|
||||
def test_multiple_modules_merge_independently(self):
|
||||
existing = {"contacts": {"name": "read"}}
|
||||
_merge_field_permissions(
|
||||
existing,
|
||||
{"users": {"email": "hidden"}, "contacts": {"name": "readonly"}},
|
||||
)
|
||||
assert existing["contacts"]["name"] == "readonly"
|
||||
assert existing["users"]["email"] == "hidden"
|
||||
|
||||
def test_unknown_permission_level_skipped(self, caplog):
|
||||
existing = {"contacts": {"name": "read"}}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_merge_field_permissions(
|
||||
existing,
|
||||
{"contacts": {"name": "bogus"}},
|
||||
)
|
||||
assert existing["contacts"]["name"] == "read" # unchanged
|
||||
assert "Unknown field permission level" in caplog.text
|
||||
|
||||
def test_non_dict_fields_skipped(self):
|
||||
existing = {}
|
||||
_merge_field_permissions(existing, {"contacts": "not_a_dict"})
|
||||
assert existing == {}
|
||||
|
||||
def test_non_string_perm_skipped(self):
|
||||
existing = {"contacts": {}}
|
||||
_merge_field_permissions(existing, {"contacts": {"name": 123}})
|
||||
assert existing["contacts"] == {}
|
||||
|
||||
def test_case_insensitive_perm_level(self):
|
||||
existing = {"contacts": {"name": "read"}}
|
||||
_merge_field_permissions(existing, {"contacts": {"name": "HIDDEN"}})
|
||||
assert existing["contacts"]["name"] == "hidden"
|
||||
|
||||
def test_severity_ordering_constant(self):
|
||||
assert _FIELD_PERM_SEVERITY["hidden"] > _FIELD_PERM_SEVERITY["readonly"]
|
||||
assert _FIELD_PERM_SEVERITY["readonly"] > _FIELD_PERM_SEVERITY["read"]
|
||||
|
||||
|
||||
class TestInvalidateAllUserPermissions:
|
||||
"""Tests for invalidate_all_user_permissions — SCAN-based, no KEYS."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_deletes_all_matching_keys(self):
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
redis_mock = AsyncMock()
|
||||
# Simulate SCAN returning keys in two batches then finishing
|
||||
call_count = 0
|
||||
|
||||
async def fake_scan(cursor, match, count):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return (
|
||||
1, # non-zero cursor = more to scan
|
||||
[
|
||||
f"{CACHE_PREFIX}:user1:{tenant_id}",
|
||||
f"{CACHE_PREFIX}:user2:{tenant_id}",
|
||||
],
|
||||
)
|
||||
else:
|
||||
return (
|
||||
0, # done
|
||||
[f"{CACHE_PREFIX}:user3:{tenant_id}"],
|
||||
)
|
||||
|
||||
redis_mock.scan = fake_scan
|
||||
redis_mock.delete = AsyncMock()
|
||||
|
||||
await invalidate_all_user_permissions(redis_mock, tenant_id)
|
||||
|
||||
# delete should be called twice — once per batch
|
||||
assert redis_mock.delete.call_count == 2
|
||||
# First batch: 2 keys
|
||||
first_call_args = redis_mock.delete.call_args_list[0]
|
||||
assert len(first_call_args[0]) == 2
|
||||
# Second batch: 1 key
|
||||
second_call_args = redis_mock.delete.call_args_list[1]
|
||||
assert len(second_call_args[0]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_no_keys_no_delete(self):
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
redis_mock = AsyncMock()
|
||||
|
||||
async def fake_scan(cursor, match, count):
|
||||
return (0, []) # no keys found
|
||||
|
||||
redis_mock.scan = fake_scan
|
||||
redis_mock.delete = AsyncMock()
|
||||
|
||||
await invalidate_all_user_permissions(redis_mock, tenant_id)
|
||||
|
||||
# delete should not be called when no keys found
|
||||
redis_mock.delete.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_does_not_use_keys_command(self):
|
||||
"""Ensure invalidate_all_user_permissions uses SCAN, not KEYS."""
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
redis_mock = AsyncMock()
|
||||
|
||||
async def fake_scan(cursor, match, count):
|
||||
return (0, [])
|
||||
|
||||
redis_mock.scan = fake_scan
|
||||
redis_mock.keys = AsyncMock()
|
||||
|
||||
await invalidate_all_user_permissions(redis_mock, tenant_id)
|
||||
|
||||
# keys() must never be called
|
||||
redis_mock.keys.assert_not_called()
|
||||
|
||||
|
||||
class TestGetCachedPermissionsVersionCheck:
|
||||
"""Tests for get_cached_permissions version validation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_version_match_returns_cached(self):
|
||||
from app.core.permissions import get_cached_permissions
|
||||
|
||||
user_id = uuid.uuid4()
|
||||
tenant_id = uuid.uuid4()
|
||||
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
|
||||
|
||||
cached_data = {
|
||||
"permissions": ["contacts:read"],
|
||||
"denied": [],
|
||||
"field_permissions": {},
|
||||
"is_system_admin": False,
|
||||
"version": 5,
|
||||
}
|
||||
|
||||
redis_mock = AsyncMock()
|
||||
redis_mock.get = AsyncMock(return_value=json.dumps(cached_data))
|
||||
|
||||
db_mock = AsyncMock()
|
||||
|
||||
# Mock _get_current_permission_version to return matching version
|
||||
with patch(
|
||||
"app.core.permissions._get_current_permission_version",
|
||||
new_callable=AsyncMock,
|
||||
return_value=5,
|
||||
):
|
||||
result = await get_cached_permissions(db_mock, redis_mock, user_id, tenant_id)
|
||||
|
||||
assert result == cached_data
|
||||
redis_mock.setex.assert_not_called() # no re-caching needed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_version_mismatch_re_resolves(self):
|
||||
from app.core.permissions import get_cached_permissions
|
||||
|
||||
user_id = uuid.uuid4()
|
||||
tenant_id = uuid.uuid4()
|
||||
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
|
||||
|
||||
cached_data = {
|
||||
"permissions": ["contacts:read"],
|
||||
"denied": [],
|
||||
"field_permissions": {},
|
||||
"is_system_admin": False,
|
||||
"version": 3, # stale version
|
||||
}
|
||||
|
||||
redis_mock = AsyncMock()
|
||||
redis_mock.get = AsyncMock(return_value=json.dumps(cached_data))
|
||||
redis_mock.delete = AsyncMock()
|
||||
redis_mock.setex = AsyncMock()
|
||||
|
||||
db_mock = AsyncMock()
|
||||
|
||||
resolved = {
|
||||
"permissions": {"contacts:read", "contacts:write"},
|
||||
"denied": set(),
|
||||
"field_permissions": {},
|
||||
"is_system_admin": False,
|
||||
"version": 5, # new version
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.core.permissions._get_current_permission_version",
|
||||
new_callable=AsyncMock,
|
||||
return_value=5, # current version differs from cached
|
||||
), patch(
|
||||
"app.core.permissions.resolve_permissions",
|
||||
new_callable=AsyncMock,
|
||||
return_value=resolved,
|
||||
):
|
||||
result = await get_cached_permissions(db_mock, redis_mock, user_id, tenant_id)
|
||||
|
||||
# Stale cache should be deleted
|
||||
redis_mock.delete.assert_called_once_with(cache_key)
|
||||
# New data should be cached
|
||||
redis_mock.setex.assert_called_once()
|
||||
# Result should have updated permissions
|
||||
assert set(result["permissions"]) == {"contacts:read", "contacts:write"}
|
||||
assert result["version"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_resolves_from_db(self):
|
||||
from app.core.permissions import get_cached_permissions
|
||||
|
||||
user_id = uuid.uuid4()
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
redis_mock = AsyncMock()
|
||||
redis_mock.get = AsyncMock(return_value=None) # cache miss
|
||||
redis_mock.setex = AsyncMock()
|
||||
|
||||
db_mock = AsyncMock()
|
||||
|
||||
resolved = {
|
||||
"permissions": {"contacts:read"},
|
||||
"denied": set(),
|
||||
"field_permissions": {},
|
||||
"is_system_admin": False,
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.core.permissions.resolve_permissions",
|
||||
new_callable=AsyncMock,
|
||||
return_value=resolved,
|
||||
):
|
||||
result = await get_cached_permissions(db_mock, redis_mock, user_id, tenant_id)
|
||||
|
||||
assert set(result["permissions"]) == {"contacts:read"}
|
||||
redis_mock.setex.assert_called_once()
|
||||
|
||||
|
||||
class TestRequireWriteNoWildcard:
|
||||
"""Tests that require_write does not use broad *:write wildcard."""
|
||||
|
||||
def test_write_permissions_list_has_no_wildcard(self):
|
||||
from app.deps import _WRITE_PERMISSIONS
|
||||
|
||||
for perm in _WRITE_PERMISSIONS:
|
||||
# No broad wildcards like *:write or *:create
|
||||
assert not perm.startswith("*:"), f"Found wildcard permission: {perm}"
|
||||
# All permissions should be module:action format
|
||||
assert ":" in perm, f"Invalid permission format: {perm}"
|
||||
|
||||
def test_write_permissions_list_includes_contacts_write(self):
|
||||
from app.deps import _WRITE_PERMISSIONS
|
||||
|
||||
assert "contacts:write" in _WRITE_PERMISSIONS
|
||||
|
||||
|
||||
class TestCheckPermissionDenyList:
|
||||
"""Verify deny list still works correctly."""
|
||||
|
||||
def test_deny_overrides_allowed(self):
|
||||
resolved = {
|
||||
"permissions": {"contacts:read", "contacts:write"},
|
||||
"denied": {"contacts:write"},
|
||||
"is_system_admin": False,
|
||||
}
|
||||
assert check_permission(resolved, "contacts:read") is True
|
||||
assert check_permission(resolved, "contacts:write") is False
|
||||
|
||||
def test_deny_wildcard_blocks_specific(self):
|
||||
resolved = {
|
||||
"permissions": {"contacts:read"},
|
||||
"denied": {"contacts:*"},
|
||||
"is_system_admin": False,
|
||||
}
|
||||
assert check_permission(resolved, "contacts:read") is False
|
||||
assert check_permission(resolved, "contacts:write") is False
|
||||
|
||||
def test_system_admin_ignores_deny(self):
|
||||
resolved = {
|
||||
"permissions": set(),
|
||||
"denied": {"contacts:*"},
|
||||
"is_system_admin": True,
|
||||
}
|
||||
assert check_permission(resolved, "contacts:read") is True
|
||||
@@ -415,8 +415,8 @@ class TestPermissionRegistryUnit:
|
||||
reg = PermissionRegistry()
|
||||
reg.initialize()
|
||||
all_defs = reg.get_all_field_definitions()
|
||||
company_defs = [d for d in all_defs if d.get("module") == "companies"]
|
||||
assert len(company_defs) > 0
|
||||
contact_defs = [d for d in all_defs if d.get("module") == "contacts"]
|
||||
assert len(contact_defs) > 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -559,20 +559,20 @@ class TestFieldLevelPermissions:
|
||||
async def test_company_service_applies_filter_with_resolved_perms(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Company service applies field filtering when resolved_perms is passed."""
|
||||
from app.services.company_service import get_company_detail
|
||||
"""Contact service applies field filtering when resolved_perms is passed."""
|
||||
from app.services.contact_service import get_contact
|
||||
from app.core.permissions import filter_fields_by_permission
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
company = seed["company_a"]
|
||||
|
||||
resolved_perms = {
|
||||
"is_system_admin": False,
|
||||
"field_permissions": {"companies": {"industry": "hidden"}},
|
||||
"field_permissions": {"contacts": {"industry": "hidden"}},
|
||||
}
|
||||
|
||||
result = await get_company_detail(
|
||||
db_session, seed["tenant_a"].id, company.id, resolved_perms=resolved_perms
|
||||
)
|
||||
result = await get_contact(db_session, seed["tenant_a"].id, str(company.id))
|
||||
result = filter_fields_by_permission(result, resolved_perms, "contacts")
|
||||
assert result is not None
|
||||
assert "industry" not in result
|
||||
assert "name" in result
|
||||
@@ -583,16 +583,17 @@ class TestFieldLevelPermissions:
|
||||
):
|
||||
"""Contact service applies field filtering when resolved_perms is passed."""
|
||||
from app.models.contact import Contact
|
||||
from app.services.contact_service import get_contact_detail
|
||||
from app.services.contact_service import get_contact
|
||||
from app.core.permissions import filter_fields_by_permission
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
contact = Contact(
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
email="john@example.com",
|
||||
phone="123456",
|
||||
mobile="789012",
|
||||
firstname="John",
|
||||
surname="Doe",
|
||||
email_1="john@example.com",
|
||||
phone_1="123456",
|
||||
phone_2="789012",
|
||||
created_by=seed["admin_a"].id,
|
||||
updated_by=seed["admin_a"].id,
|
||||
)
|
||||
@@ -601,31 +602,27 @@ class TestFieldLevelPermissions:
|
||||
|
||||
resolved_perms = {
|
||||
"is_system_admin": False,
|
||||
"field_permissions": {"contacts": {"mobile": "hidden"}},
|
||||
"field_permissions": {"contacts": {"phone_2": "hidden"}},
|
||||
}
|
||||
|
||||
result = await get_contact_detail(
|
||||
db_session, seed["tenant_a"].id, contact.id, resolved_perms=resolved_perms
|
||||
)
|
||||
result = await get_contact(db_session, seed["tenant_a"].id, str(contact.id))
|
||||
result = filter_fields_by_permission(result, resolved_perms, "contacts")
|
||||
assert result is not None
|
||||
assert "mobile" not in result
|
||||
assert "first_name" in result
|
||||
assert "phone_2" not in result
|
||||
assert "firstname" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_service_no_filter_when_resolved_perms_none(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Company service does NOT filter when resolved_perms is None (backward compat)."""
|
||||
from app.services.company_service import get_company_detail
|
||||
"""Contact service does NOT filter when resolved_perms is None (backward compat)."""
|
||||
from app.services.contact_service import get_contact
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
company = seed["company_a"]
|
||||
|
||||
result = await get_company_detail(
|
||||
db_session, seed["tenant_a"].id, company.id, resolved_perms=None
|
||||
)
|
||||
result = await get_contact(db_session, seed["tenant_a"].id, str(company.id))
|
||||
assert result is not None
|
||||
assert "industry" in result
|
||||
assert "name" in result
|
||||
|
||||
|
||||
@@ -656,17 +653,15 @@ async def _create_user_with_role(
|
||||
) -> tuple[User, UserTenant]:
|
||||
"""Helper: create a User with a specific role_id via UserTenant."""
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="custom",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
ut = UserTenant(user_id=user.id, tenant_id=tenant_id, is_default=True, role_id=role_id)
|
||||
ut = UserTenant(user_id=user.id, tenant_id=tenant_id, is_default=True, role="custom", role_id=role_id)
|
||||
db.add(ut)
|
||||
await db.flush()
|
||||
return user, ut
|
||||
@@ -916,22 +911,22 @@ class TestRBACRouteGuard:
|
||||
async def test_require_permission_allows_user_with_exact_permission(
|
||||
self, client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
"""User with companies:read can access companies list."""
|
||||
"""User with contacts:read can access contacts list."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_with_csrf(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
||||
resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_permission_blocks_user_without_permission(
|
||||
self, client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
"""Viewer cannot create companies (requires companies:write)."""
|
||||
"""Viewer cannot create contacts (requires contacts:write)."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
csrf = await login_with_csrf(client, "viewer@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": "Test Co"},
|
||||
"/api/v1/contacts",
|
||||
json={"first_name": "Test", "last_name": "User", "type": "person"},
|
||||
headers=csrf_headers(csrf),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -948,7 +943,7 @@ class TestRBACRouteGuard:
|
||||
await db_session.commit()
|
||||
|
||||
await login_with_csrf(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
||||
resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1071,12 +1066,12 @@ class TestRBACRouteGuard:
|
||||
async def test_require_write_allows_legacy_editor(
|
||||
self, client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
"""require_write allows legacy editor role (companies:write in legacy perms)."""
|
||||
"""require_write allows legacy editor role (contacts:write in legacy perms)."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
csrf = await login_with_csrf(client, "editor@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": "Editor Company"},
|
||||
"/api/v1/contacts",
|
||||
json={"first_name": "Editor", "last_name": "Contact", "type": "person"},
|
||||
headers=csrf_headers(csrf),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -1089,8 +1084,8 @@ class TestRBACRouteGuard:
|
||||
await seed_tenant_and_users(db_session)
|
||||
csrf = await login_with_csrf(client, "viewer@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/companies",
|
||||
json={"name": "Viewer Company"},
|
||||
"/api/v1/contacts",
|
||||
json={"first_name": "Viewer", "last_name": "Contact", "type": "person"},
|
||||
headers=csrf_headers(csrf),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -1773,8 +1768,8 @@ class TestRBACIntegration:
|
||||
viewer = seed["viewer_a"]
|
||||
|
||||
resolved = await resolve_permissions(db_session, viewer.id, seed["tenant_a"].id)
|
||||
assert "companies:read" in resolved["permissions"]
|
||||
assert "companies:write" not in resolved["permissions"]
|
||||
assert "contacts:read" in resolved["permissions"]
|
||||
assert "contacts:write" not in resolved["permissions"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_permissions_no_role_no_legacy(self, db_session: AsyncSession):
|
||||
@@ -1783,17 +1778,15 @@ class TestRBACIntegration:
|
||||
tenant = seed["tenant_a"]
|
||||
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="norole@test.com",
|
||||
name="No Role",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
ut = UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role_id=None)
|
||||
ut = UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role_id=None, role="")
|
||||
db_session.add(ut)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@@ -178,17 +178,15 @@ class TestFieldPermissions:
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
sales_user = User(
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
email="sales@tenanta.com",
|
||||
name="Sales Rep",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="sales_rep",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(sales_user)
|
||||
await db_session.flush()
|
||||
ut = UserTenant(user_id=sales_user.id, tenant_id=seed["tenant_a"].id, is_default=True)
|
||||
ut = UserTenant(user_id=sales_user.id, tenant_id=seed["tenant_a"].id, is_default=True, role="sales_rep")
|
||||
db_session.add(ut)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ async def search_authed_client(
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
# Grant is_system_admin to admin user so search:read/search:admin permissions pass
|
||||
from sqlalchemy import update
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
await db_session.execute(
|
||||
update(User)
|
||||
.where(User.email == "admin@tenanta.com")
|
||||
@@ -665,23 +665,23 @@ async def test_index_entity_success(db_session: AsyncSession):
|
||||
"""index_entity stores embedding in DB and returns True."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Test Tenant", slug="test-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="test@example.com",
|
||||
name="Test",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="John",
|
||||
@@ -792,23 +792,23 @@ async def test_hybrid_search_with_results(db_session: AsyncSession):
|
||||
"""hybrid_search returns results when data exists."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Test HS", slug="test-hs")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="hs@example.com",
|
||||
name="HS",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="Search",
|
||||
@@ -867,23 +867,23 @@ async def test_index_mails(mock_index_entity, mock_factory, db_session: AsyncSes
|
||||
"""index_mails calls index_entity for each mail."""
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Mail Tenant", slug="mail-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="mail@example.com",
|
||||
name="Mail",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
account = MailAccount(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
@@ -954,23 +954,23 @@ async def test_index_contact(mock_index_entity, mock_factory, db_session: AsyncS
|
||||
"""index_contact calls index_entity."""
|
||||
from app.models.contact import Contact
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Contact Tenant", slug="contact-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="contact@example.com",
|
||||
name="Contact",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
contact = Contact(
|
||||
tenant_id=tenant.id,
|
||||
first_name="Index",
|
||||
@@ -996,23 +996,23 @@ async def test_index_contact_company_type(mock_index_entity, mock_factory, db_se
|
||||
"""index_contact calls index_entity for company-type contact."""
|
||||
from app.models.contact import Contact as Company
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Company Tenant", slug="company-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="company@example.com",
|
||||
name="Company",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
company = Company(
|
||||
tenant_id=tenant.id,
|
||||
name="Index Company",
|
||||
@@ -1037,23 +1037,23 @@ async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession
|
||||
"""reindex iterates over all entities of a type."""
|
||||
from app.models.contact import Contact as Company
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Reindex Tenant", slug="reindex-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="reindex@example.com",
|
||||
name="Reindex",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
company = Company(
|
||||
tenant_id=tenant.id,
|
||||
name="Reindex Co",
|
||||
@@ -1078,23 +1078,23 @@ async def test_embedding_batch(mock_index_entity, mock_factory, db_session: Asyn
|
||||
"""embedding_batch finds entities without embedding."""
|
||||
from app.models.contact import Contact as Company
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserTenant
|
||||
from app.core.auth import hash_password
|
||||
|
||||
tenant = Tenant(name="Batch Tenant", slug="batch-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
email="batch@example.com",
|
||||
name="Batch",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||||
await db_session.flush()
|
||||
company = Company(
|
||||
tenant_id=tenant.id,
|
||||
name="Batch Co",
|
||||
|
||||
Reference in New Issue
Block a user