527 lines
15 KiB
C#
527 lines
15 KiB
C#
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.Modules.Identity.Domain.Tests.Aggregates;
|
|
|
|
public sealed class UserTests
|
|
{
|
|
private readonly TenantId _tenantId = TenantId.CreateUnique();
|
|
|
|
[Fact]
|
|
public void CreateLocal_ShouldSucceed()
|
|
{
|
|
// Arrange
|
|
var email = Email.Create("test@example.com");
|
|
var fullName = FullName.Create("John Doe");
|
|
var passwordHash = "hashed_password";
|
|
|
|
// Act
|
|
var user = User.CreateLocal(_tenantId, email, passwordHash, fullName);
|
|
|
|
// Assert
|
|
user.Should().NotBeNull();
|
|
user.Id.Should().NotBe(Guid.Empty);
|
|
user.TenantId.Should().Be(_tenantId);
|
|
user.Email.Value.Should().Be("test@example.com");
|
|
user.FullName.Value.Should().Be("John Doe");
|
|
user.PasswordHash.Should().Be(passwordHash);
|
|
user.Status.Should().Be(UserStatus.Active);
|
|
user.AuthProvider.Should().Be(AuthenticationProvider.Local);
|
|
user.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateLocal_ShouldRaiseUserCreatedEvent()
|
|
{
|
|
// Arrange
|
|
var email = Email.Create("test@example.com");
|
|
var fullName = FullName.Create("John Doe");
|
|
|
|
// Act
|
|
var user = User.CreateLocal(_tenantId, email, "password", fullName);
|
|
|
|
// Assert
|
|
user.DomainEvents.Should().ContainSingle();
|
|
user.DomainEvents.Should().ContainItemsAssignableTo<UserCreatedEvent>();
|
|
var domainEvent = user.DomainEvents.First() as UserCreatedEvent;
|
|
domainEvent.Should().NotBeNull();
|
|
domainEvent!.UserId.Should().Be(user.Id);
|
|
domainEvent.Email.Should().Be("test@example.com");
|
|
domainEvent.TenantId.Should().Be(_tenantId);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateFromSso_ShouldSucceed()
|
|
{
|
|
// Arrange
|
|
var email = Email.Create("user@company.com");
|
|
var fullName = FullName.Create("Jane Smith");
|
|
var externalUserId = "google-12345";
|
|
var avatarUrl = "https://example.com/avatar.jpg";
|
|
|
|
// Act
|
|
var user = User.CreateFromSso(
|
|
_tenantId,
|
|
AuthenticationProvider.Google,
|
|
externalUserId,
|
|
email,
|
|
fullName,
|
|
avatarUrl);
|
|
|
|
// Assert
|
|
user.Should().NotBeNull();
|
|
user.TenantId.Should().Be(_tenantId);
|
|
user.AuthProvider.Should().Be(AuthenticationProvider.Google);
|
|
user.ExternalUserId.Should().Be(externalUserId);
|
|
user.ExternalEmail.Should().Be("user@company.com");
|
|
user.AvatarUrl.Should().Be(avatarUrl);
|
|
user.PasswordHash.Should().BeEmpty();
|
|
user.EmailVerifiedAt.Should().NotBeNull(); // SSO users are auto-verified
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateFromSso_ShouldRaiseUserCreatedFromSsoEvent()
|
|
{
|
|
// Arrange
|
|
var email = Email.Create("user@company.com");
|
|
var fullName = FullName.Create("Jane Smith");
|
|
|
|
// Act
|
|
var user = User.CreateFromSso(
|
|
_tenantId,
|
|
AuthenticationProvider.AzureAD,
|
|
"azure-123",
|
|
email,
|
|
fullName);
|
|
|
|
// Assert
|
|
user.DomainEvents.Should().ContainSingle();
|
|
user.DomainEvents.Should().ContainItemsAssignableTo<UserCreatedFromSsoEvent>();
|
|
var domainEvent = user.DomainEvents.First() as UserCreatedFromSsoEvent;
|
|
domainEvent.Should().NotBeNull();
|
|
domainEvent!.Provider.Should().Be(AuthenticationProvider.AzureAD);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateFromSso_ShouldThrow_ForLocalProvider()
|
|
{
|
|
// Arrange
|
|
var email = Email.Create("test@example.com");
|
|
var fullName = FullName.Create("John Doe");
|
|
|
|
// Act & Assert
|
|
var act = () => User.CreateFromSso(
|
|
_tenantId,
|
|
AuthenticationProvider.Local,
|
|
"external-id",
|
|
email,
|
|
fullName);
|
|
act.Should().Throw<ArgumentException>()
|
|
.WithMessage("Use CreateLocal for local authentication");
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdatePassword_ShouldSucceed_ForLocalUser()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"old_hash",
|
|
FullName.Create("John Doe"));
|
|
user.ClearDomainEvents();
|
|
|
|
// Act
|
|
user.UpdatePassword("new_hash");
|
|
|
|
// Assert
|
|
user.PasswordHash.Should().Be("new_hash");
|
|
user.DomainEvents.Should().ContainSingle();
|
|
user.DomainEvents.Should().ContainItemsAssignableTo<UserPasswordChangedEvent>();
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdatePassword_ShouldThrow_ForSsoUser()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateFromSso(
|
|
_tenantId,
|
|
AuthenticationProvider.Google,
|
|
"google-123",
|
|
Email.Create("test@example.com"),
|
|
FullName.Create("John Doe"));
|
|
|
|
// Act & Assert
|
|
var act = () => user.UpdatePassword("new_hash");
|
|
act.Should().Throw<InvalidOperationException>()
|
|
.WithMessage("Cannot change password for SSO users");
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateProfile_ShouldUpdateAllFields()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
|
|
// Act
|
|
user.UpdateProfile(
|
|
fullName: FullName.Create("Jane Smith"),
|
|
avatarUrl: "https://example.com/avatar.jpg",
|
|
jobTitle: "Software Engineer",
|
|
phoneNumber: "+1234567890");
|
|
|
|
// Assert
|
|
user.FullName.Value.Should().Be("Jane Smith");
|
|
user.AvatarUrl.Should().Be("https://example.com/avatar.jpg");
|
|
user.JobTitle.Should().Be("Software Engineer");
|
|
user.PhoneNumber.Should().Be("+1234567890");
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordLogin_ShouldUpdateLastLoginAt()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
|
|
// Act
|
|
user.RecordLogin();
|
|
|
|
// Assert
|
|
user.LastLoginAt.Should().NotBeNull();
|
|
user.LastLoginAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyEmail_ShouldSetEmailVerifiedAt()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
|
|
// Act
|
|
user.VerifyEmail();
|
|
|
|
// Assert
|
|
user.EmailVerifiedAt.Should().NotBeNull();
|
|
user.EmailVerifiedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
user.EmailVerificationToken.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Suspend_ShouldChangeStatus()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.ClearDomainEvents();
|
|
|
|
// Act
|
|
user.Suspend("Violation of terms");
|
|
|
|
// Assert
|
|
user.Status.Should().Be(UserStatus.Suspended);
|
|
user.DomainEvents.Should().ContainSingle();
|
|
user.DomainEvents.Should().ContainItemsAssignableTo<UserSuspendedEvent>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Reactivate_ShouldChangeStatusToActive()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.Suspend("Test");
|
|
|
|
// Act
|
|
user.Reactivate();
|
|
|
|
// Assert
|
|
user.Status.Should().Be(UserStatus.Active);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateSsoProfile_ShouldSucceed_ForSsoUser()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateFromSso(
|
|
_tenantId,
|
|
AuthenticationProvider.Google,
|
|
"google-123",
|
|
Email.Create("old@example.com"),
|
|
FullName.Create("Old Name"));
|
|
|
|
// Act
|
|
user.UpdateSsoProfile(
|
|
"google-456",
|
|
Email.Create("new@example.com"),
|
|
FullName.Create("New Name"),
|
|
"https://new-avatar.jpg");
|
|
|
|
// Assert
|
|
user.ExternalUserId.Should().Be("google-456");
|
|
user.ExternalEmail.Should().Be("new@example.com");
|
|
user.FullName.Value.Should().Be("New Name");
|
|
user.AvatarUrl.Should().Be("https://new-avatar.jpg");
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateSsoProfile_ShouldThrow_ForLocalUser()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
|
|
// Act & Assert
|
|
var act = () => user.UpdateSsoProfile(
|
|
"external-id",
|
|
Email.Create("new@example.com"),
|
|
FullName.Create("New Name"));
|
|
act.Should().Throw<InvalidOperationException>()
|
|
.WithMessage("Cannot update SSO profile for local users");
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyEmail_WhenUnverified_ShouldSetVerifiedAt()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.ClearDomainEvents();
|
|
|
|
// Act
|
|
user.VerifyEmail();
|
|
|
|
// Assert
|
|
user.IsEmailVerified.Should().BeTrue();
|
|
user.EmailVerifiedAt.Should().NotBeNull();
|
|
user.EmailVerificationToken.Should().BeNull();
|
|
user.DomainEvents.Should().ContainSingle();
|
|
user.DomainEvents.Should().ContainItemsAssignableTo<EmailVerifiedEvent>();
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyEmail_WhenAlreadyVerified_ShouldBeIdempotent()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.VerifyEmail();
|
|
var firstVerifiedAt = user.EmailVerifiedAt;
|
|
user.ClearDomainEvents();
|
|
|
|
// Act
|
|
user.VerifyEmail();
|
|
|
|
// Assert
|
|
user.EmailVerifiedAt.Should().Be(firstVerifiedAt);
|
|
user.DomainEvents.Should().BeEmpty(); // No new event for idempotent operation
|
|
}
|
|
|
|
[Fact]
|
|
public void SetEmailVerificationToken_ShouldHashToken()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
const string token = "verification_token";
|
|
|
|
// Act
|
|
user.SetEmailVerificationToken(token);
|
|
|
|
// Assert
|
|
user.EmailVerificationToken.Should().Be(token);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetPasswordResetToken_ShouldSetTokenAndExpiration()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
const string token = "reset_token";
|
|
var expiresAt = DateTime.UtcNow.AddHours(1);
|
|
|
|
// Act
|
|
user.SetPasswordResetToken(token, expiresAt);
|
|
|
|
// Assert
|
|
user.PasswordResetToken.Should().Be(token);
|
|
user.PasswordResetTokenExpiresAt.Should().Be(expiresAt);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetPasswordResetToken_ForSsoUser_ShouldThrowException()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateFromSso(
|
|
_tenantId,
|
|
AuthenticationProvider.Google,
|
|
"google-123",
|
|
Email.Create("test@example.com"),
|
|
FullName.Create("John Doe"));
|
|
|
|
// Act
|
|
var act = () => user.SetPasswordResetToken("token", DateTime.UtcNow.AddHours(1));
|
|
|
|
// Assert
|
|
act.Should().Throw<InvalidOperationException>()
|
|
.WithMessage("*Cannot set password reset token for SSO users*");
|
|
}
|
|
|
|
[Fact]
|
|
public void ClearPasswordResetToken_ShouldClearTokenAndExpiration()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.SetPasswordResetToken("token", DateTime.UtcNow.AddHours(1));
|
|
|
|
// Act
|
|
user.ClearPasswordResetToken();
|
|
|
|
// Assert
|
|
user.PasswordResetToken.Should().BeNull();
|
|
user.PasswordResetTokenExpiresAt.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordLoginWithEvent_ShouldUpdateLastLogin()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.ClearDomainEvents();
|
|
|
|
// Act
|
|
user.RecordLoginWithEvent(_tenantId, "192.168.1.1", "Mozilla/5.0");
|
|
|
|
// Assert
|
|
user.LastLoginAt.Should().NotBeNull();
|
|
user.LastLoginAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordLoginWithEvent_ShouldRaiseDomainEvent()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.ClearDomainEvents();
|
|
const string ipAddress = "192.168.1.1";
|
|
const string userAgent = "Mozilla/5.0";
|
|
|
|
// Act
|
|
user.RecordLoginWithEvent(_tenantId, ipAddress, userAgent);
|
|
|
|
// Assert
|
|
user.DomainEvents.Should().ContainSingle();
|
|
user.DomainEvents.Should().ContainItemsAssignableTo<UserLoggedInEvent>();
|
|
var domainEvent = user.DomainEvents.First() as UserLoggedInEvent;
|
|
domainEvent.Should().NotBeNull();
|
|
domainEvent!.UserId.Should().Be(user.Id);
|
|
domainEvent.TenantId.Should().Be(_tenantId);
|
|
domainEvent.IpAddress.Should().Be(ipAddress);
|
|
domainEvent.UserAgent.Should().Be(userAgent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Delete_ShouldChangeStatusToDeleted()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
|
|
// Act
|
|
user.Delete();
|
|
|
|
// Assert
|
|
user.Status.Should().Be(UserStatus.Deleted);
|
|
user.UpdatedAt.Should().NotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Suspend_WithDeletedUser_ShouldThrowException()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.Delete();
|
|
|
|
// Act
|
|
var act = () => user.Suspend("Test reason");
|
|
|
|
// Assert
|
|
act.Should().Throw<InvalidOperationException>()
|
|
.WithMessage("*Cannot suspend deleted user*");
|
|
}
|
|
|
|
[Fact]
|
|
public void Reactivate_WithDeletedUser_ShouldThrowException()
|
|
{
|
|
// Arrange
|
|
var user = User.CreateLocal(
|
|
_tenantId,
|
|
Email.Create("test@example.com"),
|
|
"hash",
|
|
FullName.Create("John Doe"));
|
|
user.Delete();
|
|
|
|
// Act
|
|
var act = () => user.Reactivate();
|
|
|
|
// Assert
|
|
act.Should().Throw<InvalidOperationException>()
|
|
.WithMessage("*Cannot reactivate deleted user*");
|
|
}
|
|
}
|