using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.Infrastructure; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; /// /// Hosts the real sign-in, user and team member controllers on Kestrel over a SQLite /// file database, with Identity and bearer authentication registered exactly as the /// API host registers them. Email goes to an in-memory sender and every log line is /// captured. /// internal sealed class SessionTestHost : IAsyncDisposable { public static readonly string JwtSecret = new('s', 64); public const string Issuer = "issuer"; public const string Audience = "audience"; public const string Password = "Harbor-Light-42!"; private static readonly Regex ResetCode = new(@"Reset Code is: (\d{6})", RegexOptions.CultureInvariant); private readonly WebApplication _app; private readonly string _databasePath; private SessionTestHost(WebApplication app, string databasePath, HttpClient client) { _app = app; _databasePath = databasePath; Client = client; } public HttpClient Client { get; } public CapturingEmailSender Sent { get; private set; } = null!; public CapturingLoggerProvider Logged { get; private set; } = null!; public static async Task StartAsync() { var databasePath = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db"); var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString(); var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Configuration.AddInMemoryCollection(new Dictionary { ["JWT:Secret"] = JwtSecret, ["JWT:ValidIssuer"] = Issuer, ["JWT:ValidAudience"] = Audience }); var sent = new CapturingEmailSender(); var logged = new CapturingLoggerProvider(); builder.Logging.ClearProviders(); builder.Logging.SetMinimumLevel(LogLevel.Trace); builder.Logging.AddProvider(logged); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.Replace(ServiceDescriptor.Scoped(provider => new SqliteSessionDbContext(provider.GetRequiredService>()))); builder.Services.AddSeaHavenIdentity(); builder.Services.AddSingleton(sent); builder.Services.AddDataServices(); builder.Services.AddBusinessServices(builder.Configuration); builder.Services.AddSeaHavenJwtAuthentication(builder.Configuration); builder.Services.AddControllers() .AddApplicationPart(typeof(AuthenticationController).Assembly) .ConfigureApplicationPartManager(manager => { manager.FeatureProviders.Clear(); manager.FeatureProviders.Add(new SessionControllers()); }); var app = builder.Build(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); await using (var scope = app.Services.CreateAsyncScope()) await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); await app.StartAsync(); var address = app.Services.GetRequiredService().Features .Get()!.Addresses.Single(); var host = new SessionTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) }); host.Sent = sent; host.Logged = logged; return host; } public async Task AddUserAsync(string email, string? role = null) { await using var scope = _app.Services.CreateAsyncScope(); var users = scope.ServiceProvider.GetRequiredService>(); var user = new ApplicationUser { UserName = email, Email = email, FirstName = "Sam", LastName = "Lee", EmailConfirmed = true, UniqueName = "Active", CreatedDate = DateTime.UtcNow }; var created = await users.CreateAsync(user, Password); Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(error => error.Description))); if (role != null) { var roles = scope.ServiceProvider.GetRequiredService>(); if (!await roles.RoleExistsAsync(role)) await roles.CreateAsync(new IdentityRole(role)); await users.AddToRoleAsync(user, role); } return user; } public async Task SignInAsync(string email, string password = Password) { using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password }); Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); return payload.RootElement.GetProperty("token").GetString()!; } public Task SendAsync(HttpMethod method, string path, string? token, object? json = null) { var request = new HttpRequestMessage(method, path); if (token != null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); if (json != null) request.Content = JsonContent.Create(json); return Client.SendAsync(request); } /// An authorized read that only succeeds for a signed-in, accepted session. public Task ProfileAsync(string? token) => SendAsync(HttpMethod.Get, "api/User/UserProfile", token); /// Runs Forgot Password end to end: request a code, read it from the email, reset. public async Task ResetPasswordAsync(string email, string newPassword) { var before = Sent.Messages.Count; using (var requested = await SendAsync( HttpMethod.Post, $"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(email)}", null, new { email })) Assert.Equal(System.Net.HttpStatusCode.OK, requested.StatusCode); var deadline = DateTime.UtcNow.AddSeconds(10); SentEmail? message; while ((message = Sent.Messages.Skip(before).LastOrDefault(sent => sent.To == email && ResetCode.IsMatch(sent.Body))) == null) { if (DateTime.UtcNow > deadline) throw new TimeoutException("No password reset email was sent."); await Task.Delay(10); } var code = ResetCode.Match(message.Body).Groups[1].Value; using var reset = await SendAsync( HttpMethod.Post, "api/Authentication/ResetPassword", null, new { email, code, password = newPassword }); Assert.Equal(System.Net.HttpStatusCode.OK, reset.StatusCode); } public async Task StoredSecurityStampAsync(string userId) { await using var scope = _app.Services.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService() .Users.AsNoTracking().Where(user => user.Id == userId).Select(user => user.SecurityStamp).SingleOrDefaultAsync(); } /// /// Re-signs with the host's real signing key after letting /// change its claims, so only the claims differ from a token /// the API issued. /// public static string Resign(string token, Action> edit) { var original = new JwtSecurityTokenHandler().ReadJwtToken(token); var claims = original.Claims .Where(claim => claim.Type is not (JwtRegisteredClaimNames.Exp or JwtRegisteredClaimNames.Iss or JwtRegisteredClaimNames.Aud or JwtRegisteredClaimNames.Nbf or JwtRegisteredClaimNames.Iat)) .ToList(); edit(claims); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)); var resigned = new JwtSecurityToken( issuer: Issuer, audience: Audience, claims: claims, expires: original.ValidTo, signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); return new JwtSecurityTokenHandler().WriteToken(resigned); } public async ValueTask DisposeAsync() { Client.Dispose(); await _app.StopAsync(); await _app.DisposeAsync(); SqliteConnection.ClearAllPools(); foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" }) { if (File.Exists(path)) File.Delete(path); } } private sealed class SessionControllers : ControllerFeatureProvider { protected override bool IsController(System.Reflection.TypeInfo typeInfo) => typeInfo.AsType() == typeof(AuthenticationController) || typeInfo.AsType() == typeof(UserController) || typeInfo.AsType() == typeof(TeamMemberController); } private sealed class SqliteSessionDbContext : ApplicationDbContext { public SqliteSessionDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) { // SQL Server filter syntax does not carry over; a filtered unique index // without its filter would wrongly reject a second user. if (index.GetFilter() is not null) { index.SetFilter(null); index.IsUnique = false; } } foreach (var property in builder.Model.GetEntityTypes() .SelectMany(entity => entity.GetProperties()) .Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[]))) { property.ValueGenerated = ValueGenerated.Never; property.IsConcurrencyToken = false; } } } }