Files
leocrm/tests/test_commands.py
Agent Zero 727d86614e 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
2026-07-25 21:03:46 +02:00

629 lines
22 KiB
Python

"""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()