diff --git a/Directory.Packages.props b/Directory.Packages.props index 021c5e53..e7f71232 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,8 +9,10 @@ + + diff --git a/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs b/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs index 489f08cc..fe6bc2fc 100644 --- a/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs +++ b/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs @@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; using Yavsc.Models; +using Yavsc.Tests.Shared; namespace Yavsc.Org.Tests.Controllers; diff --git a/src/Yavsc.Org.Tests/Controllers/CommandFormsControllerTests.cs b/src/Yavsc.Org.Tests/Controllers/CommandFormsControllerTests.cs index f32893c7..953ac43c 100644 --- a/src/Yavsc.Org.Tests/Controllers/CommandFormsControllerTests.cs +++ b/src/Yavsc.Org.Tests/Controllers/CommandFormsControllerTests.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Testing; using Xunit; +using Yavsc.Tests.Shared; namespace Yavsc.Org.Tests.Controllers; diff --git a/src/Yavsc.Org.Tests/Directory.Packages.props b/src/Yavsc.Org.Tests/Directory.Packages.props index 56d90fd7..b267eafe 100644 --- a/src/Yavsc.Org.Tests/Directory.Packages.props +++ b/src/Yavsc.Org.Tests/Directory.Packages.props @@ -4,8 +4,6 @@ - - diff --git a/src/Yavsc.Org.Tests/TestUserMiddleware.cs b/src/Yavsc.Org.Tests/TestUserMiddleware.cs index c6348625..b88a75e3 100644 --- a/src/Yavsc.Org.Tests/TestUserMiddleware.cs +++ b/src/Yavsc.Org.Tests/TestUserMiddleware.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Yavsc.Tests.Shared; namespace Yavsc.Org.Tests; diff --git a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs index 49d3b663..c51f3f8e 100644 --- a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs +++ b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Yavsc.Tests.Shared; namespace Yavsc.Org.Tests; diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index 402211f5..ed0bd69d 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -1,9 +1,7 @@ - using IdentityServer8.EntityFramework.Entities; using IdentityServer8.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Identity; @@ -12,372 +10,272 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using System.Net; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; using Yavsc; using Yavsc.Extensions; using Yavsc.Interfaces; using Yavsc.Models; using Yavsc.Server.Helpers; +using Yavsc.Tests.Shared; using Client = IdentityServer8.EntityFramework.Entities.Client; using Yavsc.Org.Tests.Fakes; +namespace Yavsc.Org.Tests; -namespace Yavsc.Org.Tests - +/// +/// Specialisation of for the Yavsc.Org +/// host. Adds: +/// +/// In-memory configuration (ConnectionStrings, +/// Smtp) before ConfigureWebAppServices runs. +/// Test-only +/// (from Yavsc.Tests.Shared) swapped in for +/// . +/// Recording +/// fake for . +/// IdentityServer8 client + API scope + test user +/// seeded into the in-memory database. +/// +/// All cross-cutting Kestrel / cert / address plumbing is inherited +/// from . +/// +[CollectionDefinition("Yavsc Server")] +public sealed class WebServerFixture : WebHostFixture { + private static IConfiguration? _sharedConfiguration; + private static SiteSettings? _sharedSiteSettings; + private static ILogger? _sharedLogger; + private static string? _sharedTestClientId; + private static string? _sharedTestClientSecret; + private static string? _sharedTestingUserName; + private static string? _sharedTestingUserPassword; + private static string? _sharedTestingUserEmail; + private static RecordingSmtpClientFactory? _sharedSmtpClientFactory; - [CollectionDefinition("Yavsc Server")] - public class WebServerFixture : IDisposable + public IConfiguration? Configuration { get; private set; } + public string? TestClientId { get; private set; } + public string? TestClientSecret { get; set; } + public string? TestingUserName { get; private set; } + public string? TestingUserPassword { get; private set; } + public string? TestingUserEmail { get; set; } + public string? ProtectedTestingApiKey { get; internal set; } + public ApplicationUser? TestingUser { get; private set; } + public bool DbCreated { get; internal set; } + public SiteSettings? SiteSettings { get; set; } + public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; } + public ILogger? Logger { get; internal set; } + + protected override WebApplication BuildApp(WebApplicationBuilder builder) { - private static readonly Lazy _selfSignedCertificate = new Lazy(CreateSelfSignedCertificate); - private static readonly object _sync = new object(); - private static WebApplication? _app; - private static bool _isInitialized = false; - private static int _instanceCount = 0; - private static readonly List _sharedAddresses = new List(); - private static string? _sharedTestClientId; - private static string? _sharedTestClientSecret; - private static string? _sharedTestingUserName; - private static string? _sharedTestingUserPassword; - private static string? _sharedTestingUserEmail; - private static RecordingSmtpClientFactory? _sharedSmtpClientFactory; - private static IServiceProvider? _sharedServices; - private static IConfiguration? _sharedConfiguration; - private static SiteSettings? _sharedSiteSettings; - private static Microsoft.Extensions.Logging.ILogger? _sharedLogger; - - public List Addresses { get; private set; } = new List(); - public Microsoft.Extensions.Logging.ILogger? Logger { get; internal set; } - - private SiteSettings? siteSettings; - - public IConfiguration? Configuration { get; private set; } - - public string? TestClientId { get; private set; } - - public IServiceProvider? Services { get; private set; } - public string? TestingUserName { get; private set; } - public string? TestingUserPassword { get; private set; } - - public string? ProtectedTestingApiKey { get; internal set; } - public ApplicationUser? TestingUser { get; private set; } - public bool DbCreated { get; internal set; } - public SiteSettings? SiteSettings { get => siteSettings; set => siteSettings = value; } - public string? TestClientSecret { get; set; } - public string? TestingUserEmail { get; set; } - public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; } - public WebServerFixture() - { - lock (_sync) + // WebApplication.CreateBuilder defaults WebRootPath to + // {ContentRoot}/wwwroot. The test assembly runs from + // src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of + // its own — so point the host at the Yavsc.Org project's + // wwwroot so that ConfigurePipeline's static-assets middleware + // can resolve it. The AddConfiguration extension takes care of + // that plus the in-memory overrides below. + builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary { - _instanceCount++; - if (!_isInitialized) - { + [$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory", + // SMTP test config: UserName non-null so MailSender + // exercises the Authenticate branch — the + // RecordingSmtpClient captures it. + ["Smtp:Host"] = "smtp.test.local", + ["Smtp:Port"] = "465", + ["Smtp:UserName"] = "test-user", + ["Smtp:Password"] = "test-pass", + }); - SetupHost().Wait(); - _isInitialized = true; - } + Configuration = builder.Configuration; - CopySharedState(); - } - } + // Swap the production authorization policy provider for + // TestAuthPolicyProvider BEFORE ConfigureWebAppServices + // runs. ConfigureWebAppServices calls builder.Build() at + // the end, which freezes the service collection. Tests + // can satisfy [Authorize("AdministratorOnly")] (and any + // other policy that requires a role) by sending an + // X-Test-Role header; the production policy is replaced + // by the test one via the last-write-wins semantics of + // IServiceCollection.AddSingleton. + builder.Services.AddSingleton(); - public void Dispose() + // Replace the production ISmtpClientFactory (added later + // by ConfigureWebAppServices via TryAddSingleton) with a + // recording fake. By pre-registering here, the prod + // TryAdd becomes a no-op and tests get a single shared + // fake they can assert against. + var smtpFactory = new RecordingSmtpClientFactory(); + builder.Services.AddSingleton(smtpFactory); + _sharedSmtpClientFactory = smtpFactory; + + var app = builder.ConfigureWebAppServices(); + SiteSettings = app.Services.GetRequiredService>().Value; + + using (var migrationScope = app.Services.CreateScope()) { - lock (_sync) + var db = migrationScope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + TestingUserName = "Tester"; + TestingUserPassword = "Test123!"; + TestClientId = "testClientId"; + TestingUserEmail = "test@no-reply.com"; + TestingUser = null; + TestClientSecret = Guid.CreateVersion7().ToString(); + EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, migrationScope); + AddAuthorizedClient(migrationScope, TestClientId, TestClientSecret); + TestingUser = db.Users.FirstOrDefaultAsync(u => u.UserName == TestingUserName).Result; + + // Add test API scope if it doesn't exist + var testScope = db.ApiScopes.FirstOrDefault(s => s.Name == "test"); + if (testScope == null) { - _instanceCount--; - if (_instanceCount == 0 && _app != null) + db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope { - _app.StopAsync().Wait(); - _app = null; - _isInitialized = false; - _sharedAddresses.Clear(); - _sharedServices = null; - _sharedConfiguration = null; - _sharedSiteSettings = null; - _sharedLogger = null; - _sharedTestClientId = null; - _sharedTestClientSecret = null; - _sharedTestingUserName = null; - _sharedTestingUserPassword = null; - _sharedTestingUserEmail = null; - _sharedSmtpClientFactory = null; - } - } - } - - private void CopySharedState() - { - Addresses = new List(_sharedAddresses); - Logger = _sharedLogger; - Configuration = _sharedConfiguration; - Services = _sharedServices; - SiteSettings = _sharedSiteSettings; - TestClientId = _sharedTestClientId; - TestClientSecret = _sharedTestClientSecret; - TestingUserName = _sharedTestingUserName; - TestingUserPassword = _sharedTestingUserPassword; - TestingUserEmail = _sharedTestingUserEmail; - SmtpClientFactory = _sharedSmtpClientFactory; - } - - public async Task SetupHost() - { - var builder = WebApplication.CreateBuilder(); - - // WebApplication.CreateBuilder defaults WebRootPath to - // {ContentRoot}/wwwroot. The test assembly runs from - // src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of - // its own — so point the host at the Yavsc.Org project's - // wwwroot so that ConfigurePipeline's - - - builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary - { - [$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory", - // SMTP test config: UserName non-null so MailSender - // exercises the Authenticate branch — the - // RecordingSmtpClient captures it. - ["Smtp:Host"] = "smtp.test.local", - ["Smtp:Port"] = "465", - ["Smtp:UserName"] = "test-user", - ["Smtp:Password"] = "test-pass", + Name = "test", + Enabled = true, + DisplayName = "Test API Scope", + Description = "Scope for testing purposes", + UserClaims = new List + { + new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "role" }, + new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "email" } + } }); - // Configure Kestrel for HTTPS with self-signed certificate on a dynamic port - builder.WebHost.ConfigureKestrel(options => - { - options.Listen(IPAddress.Loopback, 0, listenOptions => + // Add a basic API resource for the test scope + var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource { - listenOptions.UseHttps(_selfSignedCertificate.Value); - }); - }); - - Configuration = builder.Configuration; - - // Swap the production authorization policy provider for - // TestAuthPolicyProvider BEFORE ConfigureWebAppServices - // runs. ConfigureWebAppServices calls builder.Build() at - // the end, which freezes the service collection. Tests - // can satisfy [Authorize("AdministratorOnly")] (and any - // other policy that requires a role) by sending an - // X-Test-Role header; the production policy is replaced - // by the test one via the last-write-wins semantics of - // IServiceCollection.AddSingleton. - builder.Services.AddSingleton(); - - // Replace the production ISmtpClientFactory (added later - // by ConfigureWebAppServices via TryAddSingleton) with a - // recording fake. By pre-registering here, the prod - // TryAdd becomes a no-op and tests get a single shared - // fake they can assert against. - var smtpFactory = new RecordingSmtpClientFactory(); - builder.Services.AddSingleton(smtpFactory); - _sharedSmtpClientFactory = smtpFactory; - - _app = builder.ConfigureWebAppServices(); - - // Note: a recording ISmtpClientFactory is registered - // BEFORE ConfigureWebAppServices() above (further up in - // this method) so the production TryAddSingleton inside - // ConfigureWebAppServices becomes a no-op. - // _sharedSmtpClientFactory is captured there. - - // The MSBuild target CopyYavscOrgStaticAssets in - // Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static - // assets manifest into the test bin directory. Call - // MapStaticAssets() with the explicit path so the test - // host resolves the manifest by file location rather - // than by {AssemblyName}.staticwebassets.* convention - // (which would look for Yavsc.Org.Tests.staticwebassets.*, - // a file we don't produce). - var testRuntimeManifest = Path.Combine(AppContext.BaseDirectory, - "Yavsc.Org.staticwebassets.runtime.json"); - - Services = _app.Services; - SiteSettings = _app.Services.GetRequiredService>().Value; - - using (var migrationScope = _app.Services.CreateScope()) - { - var db = migrationScope.ServiceProvider.GetRequiredService(); - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - TestingUserName = "Tester"; - TestingUserPassword = "Test123!"; - TestClientId = "testClientId"; - TestingUserEmail = "test@no-reply.com"; - TestingUser = null; - TestClientSecret = Guid.CreateVersion7().ToString(); - EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, migrationScope); - AddAuthorizedClient(migrationScope, TestClientId, TestClientSecret); - TestingUser = await db.Users.FirstOrDefaultAsync(u => u.UserName == TestingUserName); - - // Add test API scope if it doesn't exist - var testScope = db.ApiScopes.FirstOrDefault(s => s.Name == "test"); - if (testScope == null) - { - db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope - { - Name = "test", - Enabled = true, - DisplayName = "Test API Scope", - Description = "Scope for testing purposes", - UserClaims = new List - { - new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "role" }, - new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "email" } - } - }); - - // Add a basic API resource for the test scope - var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource - { - Name = "testapi", - DisplayName = "Test API", - Enabled = true, - Scopes = new List - { - new IdentityServer8.EntityFramework.Entities.ApiResourceScope - { - Scope = "test" - } - } - }; - db.ApiResources.Add(apiResource); - db.SaveChanges(); - } - } - - - - _app = await _app.ConfigurePipeline(testRuntimeManifest); - - await _app.StartAsync(); - - _sharedServices = _app.Services; - _sharedConfiguration = Configuration; - _sharedSiteSettings = SiteSettings; - _sharedTestClientId = TestClientId; - _sharedTestClientSecret = TestClientSecret; - _sharedTestingUserName = TestingUserName; - _sharedTestingUserPassword = TestingUserPassword; - _sharedTestingUserEmail = TestingUserEmail; - _sharedLogger = _app.Services.GetRequiredService().CreateLogger(); - Logger = _sharedLogger; - - var server = _app.Services.GetRequiredService(); - var addressFeatures = server.Features.Get(); - - if (addressFeatures?.Addresses != null) - { - _sharedAddresses.Clear(); - foreach (var address in addressFeatures.Addresses) - { - _sharedAddresses.Add(address); - Addresses.Add(address); - } - } - } - - private void AddAuthorizedClient(IServiceScope scope, string testClientId, string testClientSecret) - { - var configDb = scope.ServiceProvider.GetRequiredService(); - if (configDb == null) - throw new InvalidOperationException("ConfigurationDbContext is not available for IdentityServer client seeding."); - - Client testingClient = new Client - { - ClientId = testClientId, - AccessTokenLifetime = 3600000, - AccessTokenType = 1, - ClientName = "Testing client", - Enabled = true, - RequireClientSecret = true - }; - configDb.Set().Add(testingClient); - configDb.SaveChanges(); - - var apiScope = new IdentityServer8.EntityFramework.Entities.ApiScope - { - Name = "test", - DisplayName = "Test Scope", - Description = "Scope for testing", - Enabled = true, - Required = false, - ShowInDiscoveryDocument = true, - Emphasize = false - }; - configDb.Set().Add(apiScope); - - ClientSecret secret = new ClientSecret - { - Value = testClientSecret.Sha256(), - Type = IdentityServer8.IdentityServerConstants.SecretTypes.SharedSecret, - ClientId = testingClient.Id - }; - configDb.Set().Add(secret); - - configDb.Set().Add(new ClientGrantType - { - ClientId = testingClient.Id, - GrantType = "client_credentials" - }); - configDb.Set().Add(new ClientGrantType - { - ClientId = testingClient.Id, - GrantType = "password" - }); - configDb.Set().Add(new ClientScope - { - ClientId = testingClient.Id, - Scope = "test" - }); - - configDb.SaveChanges(); - } - - public void EnsureUser(string testingUserName, string password, string email, IServiceScope scope) - { - if (TestingUser == null) - { - - var userManager = - scope.ServiceProvider.GetRequiredService>(); - - TestingUser = new ApplicationUser - { - UserName = testingUserName, - Email = testingUserName + "@example.com", - EmailConfirmed = true + Name = "testapi", + DisplayName = "Test API", + Enabled = true, + Scopes = new List + { + new IdentityServer8.EntityFramework.Entities.ApiResourceScope { Scope = "test" } + } }; - - var result = userManager.CreateAsync(TestingUser, password).Result; - - Assert.True(result.Succeeded); - - ApplicationDbContext dbContext = - scope.ServiceProvider.GetRequiredService(); - TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName); + db.ApiResources.Add(apiResource); + db.SaveChanges(); } } - private static X509Certificate2 CreateSelfSignedCertificate() + _sharedConfiguration = Configuration; + _sharedSiteSettings = SiteSettings; + _sharedTestClientId = TestClientId; + _sharedTestClientSecret = TestClientSecret; + _sharedTestingUserName = TestingUserName; + _sharedTestingUserPassword = TestingUserPassword; + _sharedTestingUserEmail = TestingUserEmail; + _sharedLogger = app.Services.GetRequiredService().CreateLogger(); + Logger = _sharedLogger; + SmtpClientFactory = smtpFactory; + + return app; + } + + protected override async Task ConfigurePipelineAsync(WebApplication app) + { + // The MSBuild target CopyYavscOrgStaticAssets in + // Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static + // assets manifest into the test bin directory. Call + // MapStaticAssets() with the explicit path so the test + // host resolves the manifest by file location rather + // than by {AssemblyName}.staticwebassets.* convention + // (which would look for Yavsc.Org.Tests.staticwebassets.*, + // a file we don't produce). + var testRuntimeManifest = Path.Combine(AppContext.BaseDirectory, + "Yavsc.Org.staticwebassets.runtime.json"); + return await app.ConfigurePipeline(testRuntimeManifest); + } + + protected override void CopySpecialisedSharedState() + { + TestClientId = _sharedTestClientId; + TestClientSecret = _sharedTestClientSecret; + TestingUserName = _sharedTestingUserName; + TestingUserPassword = _sharedTestingUserPassword; + TestingUserEmail = _sharedTestingUserEmail; + SmtpClientFactory = _sharedSmtpClientFactory; + Configuration = _sharedConfiguration; + SiteSettings = _sharedSiteSettings; + Logger = _sharedLogger; + } + + private void AddAuthorizedClient(IServiceScope scope, string testClientId, string testClientSecret) + { + var configDb = scope.ServiceProvider.GetRequiredService(); + if (configDb == null) + throw new InvalidOperationException("ConfigurationDbContext is not available for IdentityServer client seeding."); + + Client testingClient = new Client { - var rsa = RSA.Create(2048); - var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + ClientId = testClientId, + AccessTokenLifetime = 3600000, + AccessTokenType = 1, + ClientName = "Testing client", + Enabled = true, + RequireClientSecret = true + }; + configDb.Set().Add(testingClient); + configDb.SaveChanges(); - certRequest.CertificateExtensions.Add( - new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false)); + var apiScope = new IdentityServer8.EntityFramework.Entities.ApiScope + { + Name = "test", + DisplayName = "Test Scope", + Description = "Scope for testing", + Enabled = true, + Required = false, + ShowInDiscoveryDocument = true, + Emphasize = false + }; + configDb.Set().Add(apiScope); - certRequest.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + ClientSecret secret = new ClientSecret + { + Value = testClientSecret.Sha256(), + Type = IdentityServer8.IdentityServerConstants.SecretTypes.SharedSecret, + ClientId = testingClient.Id + }; + configDb.Set().Add(secret); - var certificate = certRequest.CreateSelfSigned(new DateTimeOffset(DateTime.UtcNow.AddDays(-1)), new DateTimeOffset(DateTime.UtcNow.AddDays(3650))); - return certificate; + configDb.Set().Add(new ClientGrantType + { + ClientId = testingClient.Id, + GrantType = "client_credentials" + }); + configDb.Set().Add(new ClientGrantType + { + ClientId = testingClient.Id, + GrantType = "password" + }); + configDb.Set().Add(new ClientScope + { + ClientId = testingClient.Id, + Scope = "test" + }); + + configDb.SaveChanges(); + } + + public void EnsureUser(string testingUserName, string password, string email, IServiceScope scope) + { + if (TestingUser == null) + { + var userManager = scope.ServiceProvider.GetRequiredService>(); + + TestingUser = new ApplicationUser + { + UserName = testingUserName, + Email = testingUserName + "@example.com", + EmailConfirmed = true + }; + + var result = userManager.CreateAsync(TestingUser, password).Result; + + Assert.True(result.Succeeded); + + ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService(); + TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName); } } } diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index 68c17407..79a6bae1 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -46,6 +46,7 @@ + diff --git a/src/Yavsc.Tests.Shared/Directory.Packages.props b/src/Yavsc.Tests.Shared/Directory.Packages.props new file mode 100644 index 00000000..d2342f79 --- /dev/null +++ b/src/Yavsc.Tests.Shared/Directory.Packages.props @@ -0,0 +1,8 @@ + + + + diff --git a/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs b/src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs similarity index 62% rename from src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs rename to src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs index 1391a3a2..8fd416c6 100644 --- a/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs +++ b/src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs @@ -1,24 +1,30 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +using System.Security.Claims; -namespace Yavsc.Org.Tests; +namespace Yavsc.Tests.Shared; /// /// Authorization policy provider used by integration tests. Replaces the -/// production provider in the WebApplicationFactory so that any -/// policy-protected controller can be exercised by sending a -/// X-Test-Role: Administrator header — no login roundtrip, no -/// cookie, no database user. +/// production provider in the test host so that any policy-protected +/// controller can be exercised by sending a X-Test-Role: … +/// header — no login roundtrip, no cookie, no database user. /// -/// The role names accepted in the header are the same as the -/// production . Any -/// policy that requires one of those roles short-circuits to success -/// when the matching header is present; otherwise the production -/// policy is preserved. +/// Any policy that requires a role short-circuits to success when the +/// matching header is present; otherwise the production policy is +/// preserved. The test does not perform a real login, so we attach an +/// in-memory carrying the role claim to +/// the request before the assertion +/// fires, so claim-based requirements (e.g. RequireRole("Admin")) +/// also pass. /// public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider { + /// HTTP header read by the test bypass to learn the role. public const string HeaderName = "X-Test-Role"; + + /// Conventional admin role name; the production default. public const string AdminRole = "Administrator"; private readonly DefaultAuthorizationPolicyProvider _fallback; @@ -42,26 +48,21 @@ public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider // ASP.NET Core sets ctx.Resource to the HttpContext when // the authorization middleware invokes the policy. Use // the request headers directly to honour X-Test-Role. - var http = ctx.Resource as Microsoft.AspNetCore.Http.HttpContext; + var http = ctx.Resource as HttpContext; if (http is null) return false; var role = http.Request.Headers[HeaderName].ToString(); if (string.IsNullOrEmpty(role)) return false; - // The test does not perform a real login, so the - // authenticated user has no claims. Attach an - // in-memory identity carrying the role claim to the - // HttpContext (ctx.User is read-only) so the - // production policy's claim requirement is satisfied. if (http.User.Identity is null || !http.User.Identity.IsAuthenticated) { - var identity = new System.Security.Claims.ClaimsIdentity( + var identity = new ClaimsIdentity( new[] { - new System.Security.Claims.Claim( + new Claim( "http://schemas.microsoft.com/ws/2008/06/identity/claims/role", role), }, authenticationType: "TestAuth"); - http.User = new System.Security.Claims.ClaimsPrincipal(identity); + http.User = new ClaimsPrincipal(identity); } return true; }) diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs new file mode 100644 index 00000000..fb2e5984 --- /dev/null +++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs @@ -0,0 +1,172 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Yavsc.Tests.Shared; + +/// +/// Base class for ASP.NET Core integration test hosts. Provides the +/// cross-cutting plumbing shared by every test fixture in the +/// repository: +/// +/// +/// Kestrel with a self-signed HTTPS certificate +/// on a dynamically-allocated port (no port collisions between +/// parallel xUnit test classes). +/// A per-process single-instance host initialised +/// on first construction and torn down when the last fixture is +/// disposed — same lazy + lock + count pattern as the original Org +/// fixture, lifted out of the specialisation. +/// Address discovery via +/// . +/// +/// +/// The actual service registration, middleware pipeline and route +/// mapping are the responsibility of the subclass, through +/// . +/// +public abstract class WebHostFixture : IDisposable +{ + private static readonly Lazy _selfSignedCertificate = + new Lazy(CreateSelfSignedCertificate); + private static readonly object _sync = new object(); + private static WebApplication? _app; + private static bool _isInitialized; + private static int _instanceCount; + private static readonly List _sharedAddresses = new(); + private static IServiceProvider? _sharedServices; + + /// HTTPS listen URLs the host bound to. + public IReadOnlyList Addresses { get; private set; } = Array.Empty(); + + /// The DI service provider of the running host. Read from + /// the shared static slot so every fixture instance (xUnit creates + /// one per IClassFixture) sees the same provider after the + /// first initialisation. Throws if is + /// false. + public IServiceProvider Services => _sharedServices + ?? throw new InvalidOperationException( + "WebHostFixture has not been initialised. Call InitializeAsync first."); + + /// True once has completed + /// successfully and the host is running. + public bool IsInitialized { get; private set; } + + protected WebHostFixture() + { + lock (_sync) + { + _instanceCount++; + if (!_isInitialized) + { + InitializeAsync().GetAwaiter().GetResult(); + _isInitialized = true; + } + CopySharedState(); + CopySpecialisedSharedState(); + } + } + + private void CopySharedState() + { + Addresses = _sharedAddresses.ToArray(); + } + + /// Hook for specialisations to copy any other shared + /// state (test client credentials, user names, factories, etc.) + /// from the static slots exposed by the base class onto instance + /// properties. Called once per fixture construction, after + /// has populated the shared state + /// the first time. + protected virtual void CopySpecialisedSharedState() { } + + /// Specialisations register their services and middleware + /// here. The base class has already configured Kestrel HTTPS on a + /// dynamic port — do not bind additional listeners. + /// The + /// configured with Kestrel HTTPS on a dynamic port and the shared + /// self-signed certificate. + /// The fully built , ready + /// for ConfigurePipeline + StartAsync. + protected abstract WebApplication BuildApp(WebApplicationBuilder builder); + + /// Apply the production pipeline to . + /// Defaults to identity + routing + auth + MapStaticAssets; override + /// only if your host needs a different shape. + protected virtual async Task ConfigurePipelineAsync(WebApplication app) + { + await Task.CompletedTask; + return app; + } + + private async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.ConfigureKestrel(options => + { + options.Listen(IPAddress.Loopback, 0, listenOptions => + { + listenOptions.UseHttps(_selfSignedCertificate.Value); + }); + }); + + var app = BuildApp(builder); + app = await ConfigurePipelineAsync(app); + await app.StartAsync(); + + _app = app; + _sharedServices = app.Services; + + var server = app.Services.GetRequiredService(); + var addressFeatures = server.Features.Get(); + _sharedAddresses.Clear(); + if (addressFeatures?.Addresses is not null) + { + foreach (var address in addressFeatures.Addresses) + { + _sharedAddresses.Add(address); + } + } + Addresses = _sharedAddresses.ToArray(); + IsInitialized = true; + } + + public virtual void Dispose() + { + lock (_sync) + { + _instanceCount--; + if (_instanceCount == 0 && _app is not null) + { + _app.StopAsync().GetAwaiter().GetResult(); + _app = null; + _isInitialized = false; + _sharedAddresses.Clear(); + _sharedServices = null; + } + } + } + + private static X509Certificate2 CreateSelfSignedCertificate() + { + var rsa = RSA.Create(2048); + var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + certRequest.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false)); + + certRequest.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + + return certRequest.CreateSelfSigned( + new DateTimeOffset(DateTime.UtcNow.AddDays(-1)), + new DateTimeOffset(DateTime.UtcNow.AddDays(3650))); + } +} diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj new file mode 100644 index 00000000..b78ce03e --- /dev/null +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + Yavsc.Tests.Shared + + + + + + + +