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:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+20 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+628
View File
@@ -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
View File
@@ -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
+17 -4
View File
@@ -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
+214
View File
@@ -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)
+224
View File
@@ -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"
+355
View File
@@ -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
+38 -45
View File
@@ -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()
+1 -3
View File
@@ -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()
+22 -22
View File
@@ -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",