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;
}
}
}
}