diff --git a/NOTE.MD b/NOTE.MD
index 5fcc655c..67f7a67e 100644
--- a/NOTE.MD
+++ b/NOTE.MD
@@ -3,4 +3,13 @@
- Role/Permission Management APIs - CRUD roles
- Email Verification - Confirm email
- 2FA/MFA - Two-factor authentication
-- Social Login - Google, Facebook, etc.
\ No newline at end of file
+- Social Login - Google, Facebook, etc.
+
+
+⚠️ Vấn đề phát hiện:
+/connect/token endpoint không phản hồi - OAuth2 token endpoint bị treo khi gửi request. Có vẻ OpenIddict Server middleware không xử lý request đúng cách.
+
+Bạn muốn tôi làm gì tiếp:
+Fix OAuth2 /connect/token endpoint - Debug và sửa lỗi OpenIddict configuration
+Chỉ cần database connection - Nếu chỉ cần verify database connection thì đã hoàn thành
+Tiếp tục với task khác - Cần hướng dẫn cụ thể
\ No newline at end of file
diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs
index bbc3adf3..5f9fa9df 100644
--- a/services/iam-service-net/src/IamService.API/Program.cs
+++ b/services/iam-service-net/src/IamService.API/Program.cs
@@ -194,6 +194,23 @@ try
app.UseCors();
app.UseRouting();
+ // EN: Debug middleware for /connect/* endpoints
+ // VI: Debug middleware cho /connect/* endpoints
+ app.Use(async (context, next) =>
+ {
+ if (context.Request.Path.StartsWithSegments("/connect"))
+ {
+ Log.Information(">>> [DEBUG] Request to {Path} - Method: {Method}",
+ context.Request.Path, context.Request.Method);
+ }
+ await next();
+ if (context.Request.Path.StartsWithSegments("/connect"))
+ {
+ Log.Information("<<< [DEBUG] Response from {Path} - Status: {StatusCode}",
+ context.Request.Path, context.Response.StatusCode);
+ }
+ });
+
// EN: Authentication and Authorization / VI: Xác thực và phân quyền
app.UseAuthentication();
app.UseAuthorization();
diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs
index 3c2fd982..b52b2355 100644
--- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs
+++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs
@@ -102,27 +102,29 @@ public static class DependencyInjection
// EN: Register scopes
// VI: Đăng ký scopes
- options.RegisterScopes("openid", "profile", "email", "roles", "api");
+ options.RegisterScopes("openid", "profile", "email", "roles", "api", "offline_access");
// EN: Token lifetimes
// VI: Thời hạn token
options.SetAccessTokenLifetime(TimeSpan.FromMinutes(15))
.SetRefreshTokenLifetime(TimeSpan.FromDays(7));
- // EN: Development settings - Disable HTTPS requirement for local dev
- // VI: Cài đặt development - Tắt yêu cầu HTTPS cho dev local
+ // EN: Development settings
+ // VI: Cài đặt development
options.AddDevelopmentEncryptionCertificate()
- .AddDevelopmentSigningCertificate();
+ .AddDevelopmentSigningCertificate()
+ .DisableAccessTokenEncryption(); // EN: Disable encryption for dev / VI: Tắt mã hóa cho dev
- // EN: Accept anonymous clients (for password flow)
- // VI: Chấp nhận anonymous clients (cho password flow)
+ // EN: Accept anonymous clients (for password flow without client_id)
+ // VI: Chấp nhận anonymous clients (cho password flow không cần client_id)
options.AcceptAnonymousClients();
- // EN: Disable client authentication
- // VI: Tắt client authentication (for development)
+ // EN: Configure ASP.NET Core integration
+ // VI: Cấu hình tích hợp ASP.NET Core
options.UseAspNetCore()
.EnableTokenEndpointPassthrough()
- .EnableUserinfoEndpointPassthrough();
+ .EnableUserinfoEndpointPassthrough()
+ .DisableTransportSecurityRequirement(); // EN: Allow HTTP for dev / VI: Cho phép HTTP cho dev
})
// EN: Register the OpenIddict validation components
// VI: Đăng ký OpenIddict validation components
diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs
new file mode 100644
index 00000000..1fbd2ed2
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs
@@ -0,0 +1,179 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.AspNetCore.Identity;
+using Xunit;
+using IamService.API.Application.Commands.Auth;
+using IamService.API.Application.Common;
+using IamService.Domain.AggregatesModel.UserAggregate;
+
+namespace IamService.FunctionalTests.Controllers;
+
+///
+/// EN: Functional tests for AuthController endpoints.
+/// VI: Functional tests cho các endpoints của AuthController.
+///
+public class AuthControllerTests : IClassFixture
+{
+ private readonly HttpClient _client;
+ private readonly CustomWebApplicationFactory _factory;
+
+ public AuthControllerTests(CustomWebApplicationFactory factory)
+ {
+ _factory = factory;
+ _client = factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false
+ });
+ }
+
+ [Fact]
+ public async Task Register_WithValidData_ShouldReturn201()
+ {
+ // Arrange
+ var request = new RegisterUserCommand(
+ $"test_{Guid.NewGuid()}@example.com",
+ "Password123!",
+ "John",
+ "Doe");
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(result);
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal(request.Email, result.Data.Email);
+ }
+
+ [Fact]
+ public async Task Register_WithInvalidEmail_ShouldReturn400()
+ {
+ // Arrange
+ var request = new
+ {
+ Email = "invalid-email",
+ Password = "Password123!",
+ FirstName = "John",
+ LastName = "Doe"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Register_WithWeakPassword_ShouldReturn400()
+ {
+ // Arrange
+ var request = new
+ {
+ Email = "test@example.com",
+ Password = "weak",
+ FirstName = "John",
+ LastName = "Doe"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Register_WithDuplicateEmail_ShouldReturn409()
+ {
+ // Arrange
+ var email = $"duplicate_{Guid.NewGuid()}@example.com";
+ var request = new RegisterUserCommand(email, "Password123!", "John", "Doe");
+
+ // First registration
+ await _client.PostAsJsonAsync("/api/v1/auth/register", request);
+
+ // Act - Second registration with same email
+ var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Token_WithValidCredentials_ShouldReturnTokens()
+ {
+ // Arrange - Register user first
+ var email = $"login_{Guid.NewGuid()}@example.com";
+ var password = "Password123!";
+
+ await _client.PostAsJsonAsync("/api/v1/auth/register", new
+ {
+ Email = email,
+ Password = password,
+ FirstName = "Login",
+ LastName = "Test"
+ });
+
+ // Activate user (in real scenario, need to verify email first)
+ using var scope = _factory.Services.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+ var user = await userManager.FindByEmailAsync(email);
+ if (user != null)
+ {
+ user.Activate();
+ await userManager.UpdateAsync(user);
+ }
+
+ // Act - Login
+ var tokenRequest = new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "password",
+ ["username"] = email,
+ ["password"] = password,
+ ["scope"] = "openid profile email offline_access"
+ });
+
+ var response = await _client.PostAsync("/connect/token", tokenRequest);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(result);
+ Assert.NotEmpty(result.access_token);
+ Assert.Equal("Bearer", result.token_type);
+ }
+
+ [Fact]
+ public async Task Token_WithInvalidCredentials_ShouldReturn400()
+ {
+ // Arrange
+ var tokenRequest = new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "password",
+ ["username"] = "nonexistent@example.com",
+ ["password"] = "WrongPassword123!",
+ ["scope"] = "openid"
+ });
+
+ // Act
+ var response = await _client.PostAsync("/connect/token", tokenRequest);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ private record TokenResponse(
+ string access_token,
+ string token_type,
+ int expires_in,
+ string? refresh_token,
+ string? scope);
+}
diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs
new file mode 100644
index 00000000..da038fe5
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs
@@ -0,0 +1,169 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Net.Http.Headers;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.AspNetCore.Identity;
+using Xunit;
+using IamService.API.Application.Common;
+using IamService.Domain.AggregatesModel.UserAggregate;
+
+namespace IamService.FunctionalTests.Controllers;
+
+///
+/// EN: Functional tests for UsersController endpoints.
+/// VI: Functional tests cho các endpoints của UsersController.
+///
+public class UsersControllerTests : IClassFixture
+{
+ private readonly HttpClient _client;
+ private readonly CustomWebApplicationFactory _factory;
+
+ public UsersControllerTests(CustomWebApplicationFactory factory)
+ {
+ _factory = factory;
+ _client = factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false
+ });
+ }
+
+ private async Task GetAccessTokenAsync()
+ {
+ // Register and login a user
+ var email = $"user_{Guid.NewGuid()}@example.com";
+ var password = "Password123!";
+
+ await _client.PostAsJsonAsync("/api/v1/auth/register", new
+ {
+ Email = email,
+ Password = password,
+ FirstName = "Test",
+ LastName = "User"
+ });
+
+ // Activate user
+ using var scope = _factory.Services.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+ var user = await userManager.FindByEmailAsync(email);
+ if (user != null)
+ {
+ user.Activate();
+ await userManager.UpdateAsync(user);
+ }
+
+ // Get token
+ var tokenRequest = new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "password",
+ ["username"] = email,
+ ["password"] = password,
+ ["scope"] = "openid profile email offline_access"
+ });
+
+ var response = await _client.PostAsync("/connect/token", tokenRequest);
+ var result = await response.Content.ReadFromJsonAsync();
+ return result?.access_token ?? throw new Exception("Failed to get token");
+ }
+
+ [Fact]
+ public async Task GetUsers_WithoutAuth_ShouldReturn401()
+ {
+ // Act
+ var response = await _client.GetAsync("/api/v1/users");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetUsers_WithAuth_ShouldReturn200()
+ {
+ // Arrange
+ var token = await GetAccessTokenAsync();
+ _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ // Act
+ var response = await _client.GetAsync("/api/v1/users");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetMe_WithAuth_ShouldReturnCurrentUser()
+ {
+ // Arrange
+ var token = await GetAccessTokenAsync();
+ _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ // Act
+ var response = await _client.GetAsync("/api/v1/users/me");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(result);
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.NotEmpty(result.Data.Id);
+ }
+
+ [Fact]
+ public async Task GetMe_WithoutAuth_ShouldReturn401()
+ {
+ // Act
+ var response = await _client.GetAsync("/api/v1/users/me");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetUserById_WithValidId_ShouldReturnUser()
+ {
+ // Arrange
+ var email = $"getbyid_{Guid.NewGuid()}@example.com";
+ var password = "Password123!";
+
+ // Register user
+ var registerResponse = await _client.PostAsJsonAsync("/api/v1/auth/register", new
+ {
+ Email = email,
+ Password = password,
+ FirstName = "GetById",
+ LastName = "Test"
+ });
+ var registerResult = await registerResponse.Content.ReadFromJsonAsync>();
+ var userId = registerResult?.Data?.UserId;
+
+ // Get auth token
+ var token = await GetAccessTokenAsync();
+ _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ // Act
+ var response = await _client.GetAsync($"/api/v1/users/{userId}");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetUserById_WithInvalidId_ShouldReturn404()
+ {
+ // Arrange
+ var token = await GetAccessTokenAsync();
+ _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ var randomId = Guid.NewGuid();
+
+ // Act
+ var response = await _client.GetAsync($"/api/v1/users/{randomId}");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ private record TokenResponse(string access_token, string token_type, int expires_in);
+ private record RegisterResult(Guid UserId, string Email);
+}
diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs
index e4d801e9..194eb1d3 100644
--- a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs
+++ b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using IamService.Infrastructure;
namespace IamService.FunctionalTests;
@@ -18,31 +19,30 @@ public class CustomWebApplicationFactory : WebApplicationFactory
builder.ConfigureServices(services =>
{
- // EN: Remove the existing DbContext registration
- // VI: Xóa đăng ký DbContext hiện tại
- var descriptor = services.SingleOrDefault(
- d => d.ServiceType == typeof(DbContextOptions));
+ // EN: Remove ALL DbContext-related registrations
+ // VI: Xóa TẤT CẢ các đăng ký liên quan đến DbContext
+ var descriptorsToRemove = services.Where(
+ d => d.ServiceType == typeof(DbContextOptions) ||
+ d.ServiceType == typeof(IamServiceContext) ||
+ d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true ||
+ d.ImplementationType?.FullName?.Contains("Npgsql") == true)
+ .ToList();
- if (descriptor != null)
+ foreach (var descriptor in descriptorsToRemove)
{
services.Remove(descriptor);
}
-
- // EN: Remove DbContext service
- // VI: Xóa DbContext service
- var dbContextDescriptor = services.SingleOrDefault(
- d => d.ServiceType == typeof(IamServiceContext));
-
- if (dbContextDescriptor != null)
- {
- services.Remove(dbContextDescriptor);
- }
+
+ // EN: Remove the DbContextOptions generic
+ // VI: Xóa DbContextOptions generic
+ services.RemoveAll(typeof(DbContextOptions));
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext(options =>
{
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
+ options.EnableSensitiveDataLogging();
});
// EN: Ensure database is created with seed data
diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs
new file mode 100644
index 00000000..06fd8d43
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs
@@ -0,0 +1,90 @@
+using Xunit;
+using Moq;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using IamService.API.Application.Commands.Auth;
+using IamService.Domain.AggregatesModel.UserAggregate;
+using IamService.Domain.Exceptions;
+
+namespace IamService.UnitTests.Application.Commands;
+
+///
+/// EN: Unit tests for ChangePasswordCommandHandler.
+/// VI: Unit tests cho ChangePasswordCommandHandler.
+///
+public class ChangePasswordCommandHandlerTests
+{
+ private readonly Mock> _userManagerMock;
+ private readonly Mock> _loggerMock;
+ private readonly ChangePasswordCommandHandler _handler;
+
+ public ChangePasswordCommandHandlerTests()
+ {
+ var store = new Mock>();
+ _userManagerMock = new Mock>(
+ store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
+ _loggerMock = new Mock>();
+ _handler = new ChangePasswordCommandHandler(_userManagerMock.Object, _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_WithValidData_ShouldChangePassword()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+ var command = new ChangePasswordCommand(userId, "OldPassword123!", "NewPassword123!");
+
+ _userManagerMock
+ .Setup(x => x.FindByIdAsync(userId.ToString()))
+ .ReturnsAsync(user);
+
+ _userManagerMock
+ .Setup(x => x.ChangePasswordAsync(user, command.CurrentPassword, command.NewPassword))
+ .ReturnsAsync(IdentityResult.Success);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task Handle_WithInvalidUserId_ShouldThrowDomainException()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var command = new ChangePasswordCommand(userId, "OldPassword123!", "NewPassword123!");
+
+ _userManagerMock
+ .Setup(x => x.FindByIdAsync(userId.ToString()))
+ .ReturnsAsync((ApplicationUser?)null);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(command, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Handle_WithWrongCurrentPassword_ShouldThrowException()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+ var command = new ChangePasswordCommand(userId, "WrongPassword!", "NewPassword123!");
+
+ _userManagerMock
+ .Setup(x => x.FindByIdAsync(userId.ToString()))
+ .ReturnsAsync(user);
+
+ _userManagerMock
+ .Setup(x => x.ChangePasswordAsync(user, command.CurrentPassword, command.NewPassword))
+ .ReturnsAsync(IdentityResult.Failed(
+ new IdentityError { Code = "PasswordMismatch", Description = "Incorrect password" }));
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(command, CancellationToken.None));
+ }
+}
diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/RegisterUserCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/RegisterUserCommandHandlerTests.cs
new file mode 100644
index 00000000..1c0aca3d
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/RegisterUserCommandHandlerTests.cs
@@ -0,0 +1,100 @@
+using Xunit;
+using Moq;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using IamService.API.Application.Commands.Auth;
+using IamService.Domain.AggregatesModel.UserAggregate;
+
+namespace IamService.UnitTests.Application.Commands;
+
+///
+/// EN: Unit tests for RegisterUserCommandHandler.
+/// VI: Unit tests cho RegisterUserCommandHandler.
+///
+public class RegisterUserCommandHandlerTests
+{
+ private readonly Mock> _userManagerMock;
+ private readonly Mock> _loggerMock;
+ private readonly RegisterUserCommandHandler _handler;
+
+ public RegisterUserCommandHandlerTests()
+ {
+ var store = new Mock>();
+ _userManagerMock = new Mock>(
+ store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
+ _loggerMock = new Mock>();
+ _handler = new RegisterUserCommandHandler(_userManagerMock.Object, _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_WithValidData_ShouldCreateUser()
+ {
+ // Arrange
+ var command = new RegisterUserCommand(
+ "test@example.com",
+ "Password123!",
+ "John",
+ "Doe");
+
+ _userManagerMock
+ .Setup(x => x.FindByEmailAsync(command.Email))
+ .ReturnsAsync((ApplicationUser?)null);
+
+ _userManagerMock
+ .Setup(x => x.CreateAsync(It.IsAny(), command.Password))
+ .ReturnsAsync(IdentityResult.Success);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(command.Email, result.Email);
+ Assert.NotEqual(Guid.Empty, result.UserId);
+ }
+
+ [Fact]
+ public async Task Handle_WithExistingEmail_ShouldThrowException()
+ {
+ // Arrange
+ var command = new RegisterUserCommand(
+ "existing@example.com",
+ "Password123!",
+ "John",
+ "Doe");
+
+ var existingUser = new ApplicationUser(command.Email, "Existing", "User");
+
+ _userManagerMock
+ .Setup(x => x.FindByEmailAsync(command.Email))
+ .ReturnsAsync(existingUser);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(command, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Handle_WhenCreateFails_ShouldThrowException()
+ {
+ // Arrange
+ var command = new RegisterUserCommand(
+ "test@example.com",
+ "weak",
+ "John",
+ "Doe");
+
+ _userManagerMock
+ .Setup(x => x.FindByEmailAsync(command.Email))
+ .ReturnsAsync((ApplicationUser?)null);
+
+ _userManagerMock
+ .Setup(x => x.CreateAsync(It.IsAny(), command.Password))
+ .ReturnsAsync(IdentityResult.Failed(
+ new IdentityError { Code = "PasswordTooWeak", Description = "Password is too weak" }));
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(command, CancellationToken.None));
+ }
+}
diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs
new file mode 100644
index 00000000..d66a3a3f
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs
@@ -0,0 +1,139 @@
+using Xunit;
+using IamService.Domain.AggregatesModel.UserAggregate;
+
+namespace IamService.UnitTests.Domain;
+
+///
+/// EN: Unit tests for ApplicationUser domain entity.
+/// VI: Unit tests cho domain entity ApplicationUser.
+///
+public class ApplicationUserTests
+{
+ [Fact]
+ public void Create_WithValidData_ShouldSetProperties()
+ {
+ // Arrange
+ var email = "test@example.com";
+ var firstName = "John";
+ var lastName = "Doe";
+
+ // Act
+ var user = new ApplicationUser(email, firstName, lastName);
+
+ // Assert
+ Assert.Equal(email, user.Email);
+ Assert.Equal(firstName, user.FirstName);
+ Assert.Equal(lastName, user.LastName);
+ Assert.Equal($"{firstName} {lastName}", user.FullName);
+ Assert.Equal(UserStatus.PendingVerification, user.Status);
+ Assert.NotEqual(default, user.CreatedAt);
+ }
+
+ [Fact]
+ public void Create_WithEmptyEmail_ShouldThrowException()
+ {
+ // Arrange & Act & Assert
+ Assert.Throws(() =>
+ new ApplicationUser("", "John", "Doe"));
+ }
+
+ [Fact]
+ public void Create_WithEmptyFirstName_ShouldThrowException()
+ {
+ // Arrange & Act & Assert
+ Assert.Throws(() =>
+ new ApplicationUser("test@example.com", "", "Doe"));
+ }
+
+ [Fact]
+ public void Create_WithEmptyLastName_ShouldThrowException()
+ {
+ // Arrange & Act & Assert
+ Assert.Throws(() =>
+ new ApplicationUser("test@example.com", "John", ""));
+ }
+
+ [Fact]
+ public void Activate_WithPendingUser_ShouldSetStatusToActive()
+ {
+ // Arrange
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+
+ // Act
+ user.Activate();
+
+ // Assert
+ Assert.Equal(UserStatus.Active, user.Status);
+ }
+
+ [Fact]
+ public void Disable_ShouldSetStatusToDisabled()
+ {
+ // Arrange
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+ user.Activate();
+
+ // Act
+ user.Disable();
+
+ // Assert
+ Assert.Equal(UserStatus.Disabled, user.Status);
+ }
+
+ [Fact]
+ public void Lock_ShouldSetStatusToLocked()
+ {
+ // Arrange
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+ user.Activate();
+
+ // Act
+ user.Lock();
+
+ // Assert
+ Assert.Equal(UserStatus.Locked, user.Status);
+ }
+
+ [Fact]
+ public void UpdateProfile_WithValidData_ShouldUpdateNames()
+ {
+ // Arrange
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+ var newFirstName = "Jane";
+ var newLastName = "Smith";
+
+ // Act
+ user.UpdateProfile(newFirstName, newLastName);
+
+ // Assert
+ Assert.Equal(newFirstName, user.FirstName);
+ Assert.Equal(newLastName, user.LastName);
+ Assert.Equal($"{newFirstName} {newLastName}", user.FullName);
+ }
+
+ [Fact]
+ public void UpdateProfile_WithEmptyFirstName_ShouldThrowException()
+ {
+ // Arrange
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+
+ // Act & Assert
+ Assert.Throws(() =>
+ user.UpdateProfile("", "Smith"));
+ }
+
+ [Fact]
+ public void RecordLogin_ShouldUpdateLastLoginAt()
+ {
+ // Arrange
+ var user = new ApplicationUser("test@example.com", "John", "Doe");
+ var beforeLogin = DateTime.UtcNow;
+
+ // Act
+ user.RecordLogin();
+
+ // Assert
+ Assert.NotNull(user.LastLoginAt);
+ Assert.True(user.LastLoginAt >= beforeLogin);
+ }
+}
diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/UserStatusTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/UserStatusTests.cs
new file mode 100644
index 00000000..45447fe4
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/UserStatusTests.cs
@@ -0,0 +1,57 @@
+using Xunit;
+using IamService.Domain.AggregatesModel.UserAggregate;
+
+namespace IamService.UnitTests.Domain;
+
+///
+/// EN: Unit tests for UserStatus enumeration.
+/// VI: Unit tests cho UserStatus enumeration.
+///
+public class UserStatusTests
+{
+ [Fact]
+ public void UserStatus_Active_ShouldHaveCorrectIdAndName()
+ {
+ // Assert
+ Assert.Equal(1, UserStatus.Active.Id);
+ Assert.Equal("Active", UserStatus.Active.Name);
+ }
+
+ [Fact]
+ public void UserStatus_Locked_ShouldHaveCorrectIdAndName()
+ {
+ // Assert
+ Assert.Equal(2, UserStatus.Locked.Id);
+ Assert.Equal("Locked", UserStatus.Locked.Name);
+ }
+
+ [Fact]
+ public void UserStatus_Disabled_ShouldHaveCorrectIdAndName()
+ {
+ // Assert
+ Assert.Equal(3, UserStatus.Disabled.Id);
+ Assert.Equal("Disabled", UserStatus.Disabled.Name);
+ }
+
+ [Fact]
+ public void UserStatus_PendingVerification_ShouldHaveCorrectIdAndName()
+ {
+ // Assert
+ Assert.Equal(4, UserStatus.PendingVerification.Id);
+ Assert.Equal("PendingVerification", UserStatus.PendingVerification.Name);
+ }
+
+ [Fact]
+ public void GetAll_ShouldReturnAllStatuses()
+ {
+ // Act
+ var statuses = UserStatus.GetAll().ToList();
+
+ // Assert
+ Assert.Equal(4, statuses.Count);
+ Assert.Contains(UserStatus.Active, statuses);
+ Assert.Contains(UserStatus.Locked, statuses);
+ Assert.Contains(UserStatus.Disabled, statuses);
+ Assert.Contains(UserStatus.PendingVerification, statuses);
+ }
+}