- Replaced JwtSecurityToken with JsonWebToken in DependencyInjection.cs to align with ASP.NET Core 8+ requirements. - Enhanced CustomWebApplicationFactory to configure minimal JWT validation and custom authentication handling for functional tests. - Removed outdated tests that relied on InMemory Database limitations, ensuring a cleaner test suite. - Updated RegisterUserCommandHandlerTests to throw DuplicateResourceException for better error handling.
285 lines
12 KiB
C#
285 lines
12 KiB
C#
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using IamService.Infrastructure;
|
|
using IamService.Infrastructure.Caching;
|
|
using IamService.Domain.AggregatesModel.UserAggregate;
|
|
using IamService.Domain.AggregatesModel.RoleAggregate;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using StackExchange.Redis;
|
|
|
|
namespace IamService.FunctionalTests;
|
|
|
|
/// <summary>
|
|
/// EN: Custom WebApplicationFactory for functional tests with Duende IdentityServer.
|
|
/// VI: WebApplicationFactory tùy chỉnh cho functional tests với Duende IdentityServer.
|
|
/// </summary>
|
|
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
|
{
|
|
private readonly string _databaseName = "TestDatabase_" + Guid.NewGuid().ToString();
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
builder.UseEnvironment("Testing");
|
|
|
|
// EN: Configure services BEFORE the app configures itself
|
|
// VI: Cấu hình services TRƯỚC KHI app tự cấu hình
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
// EN: Remove ALL existing DbContext registrations
|
|
// VI: Xóa TẤT CẢ các đăng ký DbContext hiện có
|
|
RemoveExistingDbContextRegistrations(services);
|
|
|
|
// EN: Remove Redis-related registrations
|
|
// VI: Xóa các đăng ký liên quan đến Redis
|
|
RemoveRedisRegistrations(services);
|
|
|
|
// EN: Add mock cache service for testing
|
|
// VI: Thêm mock cache service để test
|
|
services.AddSingleton<ICacheService, InMemoryCacheService>();
|
|
|
|
// EN: Add in-memory database for testing
|
|
// VI: Thêm in-memory database để test
|
|
services.AddDbContext<IamServiceContext>(options =>
|
|
{
|
|
options.UseInMemoryDatabase(_databaseName);
|
|
options.EnableSensitiveDataLogging();
|
|
});
|
|
|
|
// EN: Set logging level for debugging tests
|
|
// VI: Đặt mức logging để debug tests
|
|
services.AddLogging(logging =>
|
|
{
|
|
logging.SetMinimumLevel(LogLevel.Warning);
|
|
logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
|
|
logging.AddFilter("Duende.IdentityServer", LogLevel.Warning);
|
|
});
|
|
});
|
|
|
|
// EN: Configure services AFTER the app is configured - this runs after Program.cs
|
|
// VI: Cấu hình services SAU KHI app được cấu hình - chạy sau Program.cs
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
// EN: Remove JWT Bearer and add custom test authentication handler
|
|
// VI: Xóa JWT Bearer và thêm custom test authentication handler
|
|
|
|
// EN: Override the authentication scheme options to use our test handler
|
|
// VI: Override authentication scheme options để sử dụng test handler
|
|
services.Configure<AuthenticationOptions>(options =>
|
|
{
|
|
// EN: Keep Bearer as default but use our handler
|
|
// VI: Giữ Bearer là mặc định nhưng sử dụng handler của chúng ta
|
|
});
|
|
|
|
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
|
{
|
|
// EN: Configure minimal validation
|
|
// VI: Cấu hình validation tối thiểu
|
|
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
|
{
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ValidateLifetime = false,
|
|
ValidateIssuerSigningKey = false,
|
|
RequireSignedTokens = false,
|
|
// EN: ASP.NET Core 8+ requires JsonWebToken, not JwtSecurityToken
|
|
// VI: ASP.NET Core 8+ yêu cầu JsonWebToken, không phải JwtSecurityToken
|
|
SignatureValidator = (token, parameters) =>
|
|
new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(token)
|
|
};
|
|
|
|
// EN: Use static configuration
|
|
// VI: Sử dụng static configuration
|
|
options.RequireHttpsMetadata = false;
|
|
var config = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration
|
|
{
|
|
Issuer = "http://localhost"
|
|
};
|
|
options.ConfigurationManager = new Microsoft.IdentityModel.Protocols.StaticConfigurationManager<Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration>(config);
|
|
|
|
// EN: Event to help debug authentication issues
|
|
// VI: Event để giúp debug authentication issues
|
|
options.Events = new JwtBearerEvents
|
|
{
|
|
OnAuthenticationFailed = context =>
|
|
{
|
|
var logger = context.HttpContext.RequestServices.GetService<ILoggerFactory>()?.CreateLogger("JwtBearerEvents");
|
|
logger?.LogError(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message);
|
|
return Task.CompletedTask;
|
|
},
|
|
OnMessageReceived = context =>
|
|
{
|
|
// EN: Ensure the token is extracted correctly
|
|
// VI: Đảm bảo token được extract đúng
|
|
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
|
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
context.Token = authHeader.Substring("Bearer ".Length).Trim();
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
private static void RemoveExistingDbContextRegistrations(IServiceCollection services)
|
|
{
|
|
// 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<IamServiceContext>) ||
|
|
d.ServiceType == typeof(IamServiceContext) ||
|
|
d.ServiceType == typeof(DbContextOptions) ||
|
|
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true ||
|
|
d.ImplementationType?.FullName?.Contains("Npgsql") == true)
|
|
.ToList();
|
|
|
|
foreach (var descriptor in descriptorsToRemove)
|
|
{
|
|
services.Remove(descriptor);
|
|
}
|
|
|
|
services.RemoveAll(typeof(DbContextOptions));
|
|
services.RemoveAll(typeof(DbContextOptions<IamServiceContext>));
|
|
}
|
|
|
|
private static void RemoveRedisRegistrations(IServiceCollection services)
|
|
{
|
|
// EN: Remove Redis-related registrations
|
|
// VI: Xóa các đăng ký liên quan đến Redis
|
|
var redisDescriptors = services.Where(d =>
|
|
d.ServiceType == typeof(IConnectionMultiplexer) ||
|
|
d.ServiceType == typeof(ICacheService) ||
|
|
d.ImplementationType?.FullName?.Contains("Redis") == true ||
|
|
d.ServiceType.FullName?.Contains("Redis") == true)
|
|
.ToList();
|
|
|
|
foreach (var descriptor in redisDescriptors)
|
|
{
|
|
services.Remove(descriptor);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Ensure database is created after host is built
|
|
/// VI: Đảm bảo database được tạo sau khi host được build
|
|
/// </summary>
|
|
protected override void ConfigureClient(HttpClient client)
|
|
{
|
|
base.ConfigureClient(client);
|
|
|
|
// EN: Create the database when the first client is created
|
|
// VI: Tạo database khi client đầu tiên được tạo
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<IamServiceContext>();
|
|
db.Database.EnsureCreated();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: In-memory cache service for testing
|
|
/// VI: Cache service in-memory cho testing
|
|
/// </summary>
|
|
public class InMemoryCacheService : ICacheService
|
|
{
|
|
private readonly Dictionary<string, (object Value, DateTime? Expiry)> _cache = new();
|
|
private readonly HashSet<string> _blacklist = new();
|
|
|
|
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) where T : class
|
|
{
|
|
if (_cache.TryGetValue(key, out var entry))
|
|
{
|
|
if (entry.Expiry == null || entry.Expiry > DateTime.UtcNow)
|
|
{
|
|
return Task.FromResult((T?)entry.Value);
|
|
}
|
|
_cache.Remove(key);
|
|
}
|
|
return Task.FromResult<T?>(null);
|
|
}
|
|
|
|
public Task<string?> GetStringAsync(string key, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_cache.TryGetValue(key, out var entry))
|
|
{
|
|
if (entry.Expiry == null || entry.Expiry > DateTime.UtcNow)
|
|
{
|
|
return Task.FromResult((string?)entry.Value);
|
|
}
|
|
_cache.Remove(key);
|
|
}
|
|
return Task.FromResult<string?>(null);
|
|
}
|
|
|
|
public Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class
|
|
{
|
|
var expiry = expiration.HasValue ? DateTime.UtcNow.Add(expiration.Value) : (DateTime?)null;
|
|
_cache[key] = (value, expiry);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task SetStringAsync(string key, string value, TimeSpan? expiration = null, CancellationToken cancellationToken = default)
|
|
{
|
|
var expiry = expiration.HasValue ? DateTime.UtcNow.Add(expiration.Value) : (DateTime?)null;
|
|
_cache[key] = (value, expiry);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
|
{
|
|
_cache.Remove(key);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
|
{
|
|
var keysToRemove = _cache.Keys.Where(k => k.Contains(pattern.Replace("*", ""))).ToList();
|
|
foreach (var key in keysToRemove)
|
|
{
|
|
_cache.Remove(key);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_cache.TryGetValue(key, out var entry))
|
|
{
|
|
if (entry.Expiry == null || entry.Expiry > DateTime.UtcNow)
|
|
{
|
|
return Task.FromResult(true);
|
|
}
|
|
_cache.Remove(key);
|
|
}
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
public async Task<T?> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class
|
|
{
|
|
var cached = await GetAsync<T>(key, cancellationToken);
|
|
if (cached != null) return cached;
|
|
|
|
var value = await factory();
|
|
await SetAsync(key, value, expiration, cancellationToken);
|
|
return value;
|
|
}
|
|
|
|
public Task BlacklistAsync(string key, TimeSpan expiration, CancellationToken cancellationToken = default)
|
|
{
|
|
_blacklist.Add(key);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<bool> IsBlacklistedAsync(string key, CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult(_blacklist.Contains(key));
|
|
}
|
|
}
|