dify/api/tests/unit_tests/models/test_account_models.py
2026-07-15 06:48:28 +00:00

732 lines
24 KiB
Python

"""
Comprehensive unit tests for Account model.
This test suite covers:
- Account model validation
- Password hashing/verification
- Account status transitions
- Tenant relationship integrity
- Email uniqueness constraints
"""
import base64
import secrets
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from libs.password import compare_password, hash_password, valid_password
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
class TestAccountModelValidation:
"""Test suite for Account model validation and basic operations."""
def test_account_creation_with_required_fields(self):
"""Test creating an account with all required fields."""
# Arrange & Act
account = Account(
name="Test User",
email="test@example.com",
password="hashed_password",
password_salt="salt_value",
)
# Assert
assert account.name == "Test User"
assert account.email == "test@example.com"
assert account.password == "hashed_password"
assert account.password_salt == "salt_value"
assert account.status == "active" # Default value
def test_account_creation_with_optional_fields(self):
"""Test creating an account with optional fields."""
# Arrange & Act
account = Account(
name="Test User",
email="test@example.com",
avatar="https://example.com/avatar.png",
interface_language="en-US",
interface_theme="dark",
timezone="America/New_York",
)
# Assert
assert account.avatar == "https://example.com/avatar.png"
assert account.interface_language == "en-US"
assert account.interface_theme == "dark"
assert account.timezone == "America/New_York"
def test_account_creation_without_password(self):
"""Test creating an account without password (for invite-based registration)."""
# Arrange & Act
account = Account(
name="Invited User",
email="invited@example.com",
)
# Assert
assert account.password is None
assert account.password_salt is None
assert not account.is_password_set
def test_account_is_password_set_property(self):
"""Test the is_password_set property."""
# Arrange
account_with_password = Account(
name="User With Password",
email="withpass@example.com",
password="hashed_password",
)
account_without_password = Account(
name="User Without Password",
email="nopass@example.com",
)
# Assert
assert account_with_password.is_password_set
assert not account_without_password.is_password_set
def test_account_default_status(self):
"""Test that account has default status of 'active'."""
# Arrange & Act
account = Account(
name="Test User",
email="test@example.com",
)
# Assert
assert account.status == AccountStatus.ACTIVE
def test_account_get_status_method(self):
"""Test the get_status method returns AccountStatus enum."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
status=AccountStatus.PENDING,
)
# Act
status = account.get_status()
# Assert
assert status == AccountStatus.PENDING
assert isinstance(status, AccountStatus)
class TestPasswordHashingAndVerification:
"""Test suite for password hashing and verification functionality."""
def test_password_hashing_produces_consistent_result(self):
"""Test that hashing the same password with the same salt produces the same result."""
# Arrange
password = "TestPassword123"
salt = secrets.token_bytes(16)
# Act
hash1 = hash_password(password, salt)
hash2 = hash_password(password, salt)
# Assert
assert hash1 == hash2
def test_password_hashing_different_salts_produce_different_hashes(self):
"""Test that different salts produce different hashes for the same password."""
# Arrange
password = "TestPassword123"
salt1 = secrets.token_bytes(16)
salt2 = secrets.token_bytes(16)
# Act
hash1 = hash_password(password, salt1)
hash2 = hash_password(password, salt2)
# Assert
assert hash1 != hash2
def test_password_comparison_success(self):
"""Test successful password comparison."""
# Arrange
password = "TestPassword123"
salt = secrets.token_bytes(16)
password_hashed = hash_password(password, salt)
# Encode to base64 as done in the application
base64_salt = base64.b64encode(salt).decode()
base64_password_hashed = base64.b64encode(password_hashed).decode()
# Act
result = compare_password(password, base64_password_hashed, base64_salt)
# Assert
assert result is True
def test_password_comparison_failure(self):
"""Test password comparison with wrong password."""
# Arrange
correct_password = "TestPassword123"
wrong_password = "WrongPassword456"
salt = secrets.token_bytes(16)
password_hashed = hash_password(correct_password, salt)
# Encode to base64
base64_salt = base64.b64encode(salt).decode()
base64_password_hashed = base64.b64encode(password_hashed).decode()
# Act
result = compare_password(wrong_password, base64_password_hashed, base64_salt)
# Assert
assert result is False
def test_valid_password_with_correct_format(self):
"""Test password validation with correct format."""
# Arrange
valid_passwords = [
"Password123",
"Test1234",
"MySecure1Pass",
"abcdefgh1",
]
# Act & Assert
for password in valid_passwords:
result = valid_password(password)
assert result == password
def test_valid_password_with_incorrect_format(self):
"""Test password validation with incorrect format."""
# Arrange
invalid_passwords = [
"short1", # Too short
"NoNumbers", # No numbers
"12345678", # No letters
"Pass1", # Too short
]
# Act & Assert
for password in invalid_passwords:
with pytest.raises(ValueError, match="Password must contain letters and numbers"):
valid_password(password)
def test_password_hashing_integration_with_account(self):
"""Test password hashing integration with Account model."""
# Arrange
password = "SecurePass123"
salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode()
password_hashed = hash_password(password, salt)
base64_password_hashed = base64.b64encode(password_hashed).decode()
# Act
account = Account(
name="Test User",
email="test@example.com",
password=base64_password_hashed,
password_salt=base64_salt,
)
# Assert
assert account.is_password_set
assert compare_password(password, account.password, account.password_salt)
class TestAccountStatusTransitions:
"""Test suite for account status transitions."""
def test_account_status_enum_values(self):
"""Test that AccountStatus enum has all expected values."""
# Assert
assert AccountStatus.PENDING == "pending"
assert AccountStatus.UNINITIALIZED == "uninitialized"
assert AccountStatus.ACTIVE == "active"
assert AccountStatus.BANNED == "banned"
assert AccountStatus.CLOSED == "closed"
def test_account_status_transition_pending_to_active(self):
"""Test transitioning account status from pending to active."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
status=AccountStatus.PENDING,
)
# Act
account.status = AccountStatus.ACTIVE
account.initialized_at = datetime.now(UTC)
# Assert
assert account.get_status() == AccountStatus.ACTIVE
assert account.initialized_at is not None
def test_account_status_transition_active_to_banned(self):
"""Test transitioning account status from active to banned."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
status=AccountStatus.ACTIVE,
)
# Act
account.status = AccountStatus.BANNED
# Assert
assert account.get_status() == AccountStatus.BANNED
def test_account_status_transition_active_to_closed(self):
"""Test transitioning account status from active to closed."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
status=AccountStatus.ACTIVE,
)
# Act
account.status = AccountStatus.CLOSED
# Assert
assert account.get_status() == AccountStatus.CLOSED
def test_account_status_uninitialized(self):
"""Test account with uninitialized status."""
# Arrange & Act
account = Account(
name="Test User",
email="test@example.com",
status=AccountStatus.UNINITIALIZED,
)
# Assert
assert account.get_status() == AccountStatus.UNINITIALIZED
assert account.initialized_at is None
class TestTenantRelationshipIntegrity:
"""Test suite for tenant relationship integrity."""
def test_account_current_tenant_id_property(self):
"""Test the current_tenant_id property."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
)
tenant = Tenant(name="Test Tenant")
tenant.id = str(uuid4())
# Act - with tenant
account._current_tenant = tenant
tenant_id = account.current_tenant_id
# Assert
assert tenant_id == tenant.id
# Act - without tenant
account._current_tenant = None
tenant_id_none = account.current_tenant_id
# Assert
assert tenant_id_none is None
def test_set_current_tenant_with_session_uses_caller_session(self):
account = Account(name="Test User", email="test@example.com")
account.id = str(uuid4())
tenant = Tenant(name="Test Tenant")
tenant.id = str(uuid4())
join = MagicMock(role=TenantAccountRole.OWNER)
session = MagicMock(spec=Session)
session.scalar.return_value = join
session.scalars.return_value.one.return_value = tenant
with patch("models.account.Session") as session_class:
account.set_current_tenant_with_session(tenant, session=session)
session_class.assert_not_called()
assert account.current_tenant is tenant
assert account.role == TenantAccountRole.OWNER
def test_set_tenant_id_with_session_uses_caller_session(self):
account = Account(name="Test User", email="test@example.com")
account.id = str(uuid4())
tenant = Tenant(name="Test Tenant")
tenant.id = str(uuid4())
join = MagicMock(role=TenantAccountRole.ADMIN)
session = MagicMock(spec=Session)
session.execute.return_value.first.return_value = (tenant, join)
with patch("models.account.Session") as session_class:
account.set_tenant_id_with_session(tenant.id, session=session)
session_class.assert_not_called()
assert account.current_tenant is tenant
assert account.role == TenantAccountRole.ADMIN
class TestAccountRolePermissions:
"""Test suite for account role permissions."""
def test_is_admin_or_owner_with_admin_role(self):
"""Test is_admin_or_owner property with admin role."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
)
account.role = TenantAccountRole.ADMIN
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.is_admin_or_owner
def test_is_admin_or_owner_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_admin_or_owner
def test_is_admin_or_owner_with_owner_role(self):
"""Test is_admin_or_owner property with owner role."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
)
account.role = TenantAccountRole.OWNER
# Act & Assert
assert account.is_admin_or_owner
def test_is_admin_or_owner_with_normal_role(self):
"""Test is_admin_or_owner property with normal role."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
)
account.role = TenantAccountRole.NORMAL
# Act & Assert
assert not account.is_admin_or_owner
def test_is_admin_property(self):
"""Test is_admin property."""
# Arrange
admin_account = Account(name="Admin", email="admin@example.com")
admin_account.role = TenantAccountRole.ADMIN
owner_account = Account(name="Owner", email="owner@example.com")
owner_account.role = TenantAccountRole.OWNER
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert admin_account.is_admin
assert not owner_account.is_admin
def test_is_admin_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_admin
def test_has_edit_permission_with_editing_roles(self):
"""Test has_edit_permission property with roles that have edit permission."""
# Arrange
roles_with_edit = [
TenantAccountRole.OWNER,
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
]
for role in roles_with_edit:
account = Account(name="Test User", email=f"test_{role}@example.com")
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.has_edit_permission, f"Role {role} should have edit permission"
def test_has_edit_permission_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.has_edit_permission
def test_has_edit_permission_without_editing_roles(self):
"""Test has_edit_permission property with roles that don't have edit permission."""
# Arrange
roles_without_edit = [
TenantAccountRole.NORMAL,
TenantAccountRole.DATASET_OPERATOR,
]
for role in roles_without_edit:
account = Account(name="Test User", email=f"test_{role}@example.com")
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert not account.has_edit_permission, f"Role {role} should not have edit permission"
def test_is_dataset_editor_property(self):
"""Test is_dataset_editor property."""
# Arrange
dataset_roles = [
TenantAccountRole.OWNER,
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.DATASET_OPERATOR,
]
for role in dataset_roles:
account = Account(name="Test User", email=f"test_{role}@example.com")
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.is_dataset_editor, f"Role {role} should have dataset edit permission"
# Test normal role doesn't have dataset edit permission
normal_account = Account(name="Normal User", email="normal@example.com")
normal_account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert not normal_account.is_dataset_editor
def test_is_dataset_editor_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_dataset_editor
def test_is_dataset_operator_property(self):
"""Test is_dataset_operator property."""
# Arrange
dataset_operator = Account(name="Dataset Operator", email="operator@example.com")
dataset_operator.role = TenantAccountRole.DATASET_OPERATOR
normal_account = Account(name="Normal User", email="normal@example.com")
normal_account.role = TenantAccountRole.NORMAL
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert dataset_operator.is_dataset_operator
assert not normal_account.is_dataset_operator
def test_is_dataset_operator_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_dataset_operator
def test_current_role_property(self):
"""Test current_role property."""
# Arrange
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.EDITOR
# Act
current_role = account.current_role
# Assert
assert current_role == TenantAccountRole.EDITOR
class TestTenantAccountJoinModel:
"""Test suite for TenantAccountJoin model."""
def test_tenant_account_join_creation(self):
"""Test creating a TenantAccountJoin record."""
# Arrange
tenant_id = str(uuid4())
account_id = str(uuid4())
# Act
join = TenantAccountJoin(
tenant_id=tenant_id,
account_id=account_id,
role=TenantAccountRole.NORMAL,
current=True,
)
# Assert
assert join.tenant_id == tenant_id
assert join.account_id == account_id
assert join.role == TenantAccountRole.NORMAL
assert join.current is True
def test_tenant_account_join_default_values(self):
"""Test default values for TenantAccountJoin."""
# Arrange
tenant_id = str(uuid4())
account_id = str(uuid4())
# Act
join = TenantAccountJoin(
tenant_id=tenant_id,
account_id=account_id,
)
# Assert
assert join.current is False # Default value
assert join.role == "normal" # Default value
assert join.invited_by is None # Default value
def test_tenant_account_join_with_invited_by(self):
"""Test TenantAccountJoin with invited_by field."""
# Arrange
tenant_id = str(uuid4())
account_id = str(uuid4())
inviter_id = str(uuid4())
# Act
join = TenantAccountJoin(
tenant_id=tenant_id,
account_id=account_id,
role=TenantAccountRole.EDITOR,
invited_by=inviter_id,
)
# Assert
assert join.invited_by == inviter_id
class TestTenantModel:
"""Test suite for Tenant model."""
def test_tenant_creation(self):
"""Test creating a Tenant."""
# Arrange & Act
tenant = Tenant(name="Test Workspace")
# Assert
assert tenant.name == "Test Workspace"
assert tenant.status == "normal" # Default value
assert tenant.plan == "basic" # Default value
def test_tenant_custom_config_dict_property(self):
"""Test custom_config_dict property getter."""
# Arrange
tenant = Tenant(name="Test Workspace")
config = {"feature1": True, "feature2": "value"}
tenant.custom_config = '{"feature1": true, "feature2": "value"}'
# Act
result = tenant.custom_config_dict
# Assert
assert result["feature1"] is True
assert result["feature2"] == "value"
def test_tenant_custom_config_dict_property_empty(self):
"""Test custom_config_dict property with empty config."""
# Arrange
tenant = Tenant(name="Test Workspace")
tenant.custom_config = None
# Act
result = tenant.custom_config_dict
# Assert
assert result == {}
def test_tenant_custom_config_dict_setter(self):
"""Test custom_config_dict property setter."""
# Arrange
tenant = Tenant(name="Test Workspace")
config = {"feature1": True, "feature2": "value"}
# Act
tenant.custom_config_dict = config
# Assert
assert tenant.custom_config == '{"feature1": true, "feature2": "value"}'
class TestTenantStatusEnum:
"""Test suite for TenantStatus enum."""
def test_tenant_status_enum_values(self):
"""Test TenantStatus enum values."""
# Arrange & Act
from models.account import TenantStatus
# Assert
assert TenantStatus.NORMAL == "normal"
assert TenantStatus.ARCHIVE == "archive"
class TestAccountIntegration:
"""Integration tests for Account model with related models."""
def test_account_with_multiple_tenants(self):
"""Test account associated with multiple tenants."""
# Arrange
account = Account(name="Multi-Tenant User", email="multi@example.com")
account.id = str(uuid4())
tenant1_id = str(uuid4())
tenant2_id = str(uuid4())
join1 = TenantAccountJoin(
tenant_id=tenant1_id,
account_id=account.id,
role=TenantAccountRole.OWNER,
current=True,
)
join2 = TenantAccountJoin(
tenant_id=tenant2_id,
account_id=account.id,
role=TenantAccountRole.NORMAL,
current=False,
)
# Assert - verify the joins are created correctly
assert join1.account_id == account.id
assert join2.account_id == account.id
assert join1.current is True
assert join2.current is False
def test_account_last_login_tracking(self):
"""Test account last login tracking."""
# Arrange
account = Account(name="Test User", email="test@example.com")
login_time = datetime.now(UTC)
login_ip = "192.168.1.1"
# Act
account.last_login_at = login_time
account.last_login_ip = login_ip
# Assert
assert account.last_login_at == login_time
assert account.last_login_ip == login_ip
def test_account_initialization_tracking(self):
"""Test account initialization tracking."""
# Arrange
account = Account(
name="Test User",
email="test@example.com",
status=AccountStatus.PENDING,
)
# Act - simulate initialization
account.status = AccountStatus.ACTIVE
account.initialized_at = datetime.now(UTC)
# Assert
assert account.get_status() == AccountStatus.ACTIVE
assert account.initialized_at is not None