yavsc/src/Yavsc.Org.Tests/WebServerFixture.cs

313 lines
13 KiB
C#
Raw Normal View History

2026-04-19 16:02:50 +01:00
using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.Models;
using Microsoft.AspNetCore.Authorization;
2025-07-14 18:58:04 +01:00
using Microsoft.AspNetCore.Builder;
2025-07-13 18:13:04 +01:00
using Microsoft.AspNetCore.Identity;
2026-04-19 16:02:50 +01:00
using Microsoft.EntityFrameworkCore;
2025-07-13 18:13:04 +01:00
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
2026-07-12 06:01:42 +01:00
using System.Net;
using System.Net.Sockets;
2026-04-19 16:02:50 +01:00
using Yavsc.Extensions;
using Yavsc.Interfaces;
2026-04-19 16:02:50 +01:00
using Yavsc.Models;
2026-06-14 23:04:24 +01:00
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
2026-03-09 02:07:09 +00:00
using Client = IdentityServer8.EntityFramework.Entities.Client;
using Yavsc.Org.Tests.Fakes;
2025-07-13 18:13:04 +01:00
namespace Yavsc.Org.Tests;
/// <summary>
/// Specialisation of <see cref="WebHostFixture"/> for the Yavsc.Org
/// host. Adds:
/// <list type="bullet">
/// <item><description>In-memory configuration (<c>ConnectionStrings</c>,
/// <c>Smtp</c>) before <c>ConfigureWebAppServices</c> runs.</description></item>
/// <item><description>Test-only <see cref="TestAuthPolicyProvider"/>
/// (from <c>Yavsc.Tests.Shared</c>) swapped in for
/// <see cref="IAuthorizationPolicyProvider"/>.</description></item>
/// <item><description>Recording <see cref="RecordingSmtpClientFactory"/>
/// fake for <see cref="ISmtpClientFactory"/>.</description></item>
/// <item><description>IdentityServer8 client + API scope + test user
/// seeded into the in-memory database.</description></item>
/// </list>
/// All cross-cutting Kestrel / cert / address plumbing is inherited
/// from <see cref="WebHostFixture"/>.
/// </summary>
[CollectionDefinition("Yavsc Server")]
public sealed class WebServerFixture : WebHostFixture
2025-07-13 18:13:04 +01:00
{
2026-07-12 06:01:42 +01:00
private static readonly int _httpsPort = GetAvailableLoopbackPort();
test(org): isolate in-memory store per fixture TestWebApplicationFactory instances shared the same in-memory database because EF Core's UseInMemoryDatabase("InMemory") returns the same backing store to every DbContext that asks for it under the same connection string, in the same process. Whichever fixture started first defined the state, and every subsequent fixture inherited it, making tests silently order-dependent and flaky. Fix: - Yavsc.Tests.Shared/InMemoryDatabaseName: helper that suffixes the in-memory connection string with a per-fixture GUID. - TestWebApplicationFactory: instance GUID + ConnectionStrings__ YavscConnection set as an environment variable in the constructor and cleared in Dispose, so each factory gets its own backing store. Env var is needed because IdentityServer8.EntityFramework exposes ConfigureDbContext as Action<DbContextOptionsBuilder> with no service-provider access, so the connection string is captured at registration time. AddEnvironmentVariables is the last provider in the config pipeline and wins regardless. - WebServerFixture: process-static GUID (WebHostFixture is a per-process singleton by design, so the test collection shares one store; the GUID still isolates from TestWebApplicationFactory). - AddIdentityDBAndStores: read the connection string at DbContext construction time via the (sp, options) overload of AddDbContext, so test fixtures can override it via the host's IConfiguration. IdentityServer stores cannot do the same without subclassing the framework's DbContexts; the env var path is the documented escape hatch in HostingExtensions.AddIdentityServer. - UsesInMemoryProvider: StartsWith instead of equality, so 'InMemory-{guid}' is still recognised as an in-memory connection string. Regression sentinel in Controllers/TestWebApplicationFactoryIsolationTests: two factories seed a marker client in the first, the second must not see it. Suite: 45/45 over 3 stable runs, 13-15s each.
2026-08-22 04:36:52 +01:00
// One in-memory database name for the whole process: WebHostFixture
// is a per-process singleton (see _app, _isInitialized, _sharedServices
// in the base class), so every WebServerFixture instance shares the
// same backing store. That is intentional — the "Yavsc Server" test
// collection groups tests that should see the same seeded state, and
// re-initialising the store per fixture would just regress the
// order-dependence we are trying to eliminate. The GUID still matters
// because TestWebApplicationFactory and WebServerFixture must not
// collide in the in-memory store; see InMemoryDatabaseName.
private static readonly string _fixtureId = Guid.NewGuid().ToString("N");
2026-07-12 06:01:42 +01:00
protected override int HttpsPort => _httpsPort;
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;
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)
2025-07-13 18:13:04 +01:00
{
2026-07-12 06:01:42 +01:00
var authority = $"https://localhost:{_httpsPort}";
// 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<string, string?>
2026-08-20 20:50:52 +01:00
{
test(org): isolate in-memory store per fixture TestWebApplicationFactory instances shared the same in-memory database because EF Core's UseInMemoryDatabase("InMemory") returns the same backing store to every DbContext that asks for it under the same connection string, in the same process. Whichever fixture started first defined the state, and every subsequent fixture inherited it, making tests silently order-dependent and flaky. Fix: - Yavsc.Tests.Shared/InMemoryDatabaseName: helper that suffixes the in-memory connection string with a per-fixture GUID. - TestWebApplicationFactory: instance GUID + ConnectionStrings__ YavscConnection set as an environment variable in the constructor and cleared in Dispose, so each factory gets its own backing store. Env var is needed because IdentityServer8.EntityFramework exposes ConfigureDbContext as Action<DbContextOptionsBuilder> with no service-provider access, so the connection string is captured at registration time. AddEnvironmentVariables is the last provider in the config pipeline and wins regardless. - WebServerFixture: process-static GUID (WebHostFixture is a per-process singleton by design, so the test collection shares one store; the GUID still isolates from TestWebApplicationFactory). - AddIdentityDBAndStores: read the connection string at DbContext construction time via the (sp, options) overload of AddDbContext, so test fixtures can override it via the host's IConfiguration. IdentityServer stores cannot do the same without subclassing the framework's DbContexts; the env var path is the documented escape hatch in HostingExtensions.AddIdentityServer. - UsesInMemoryProvider: StartsWith instead of equality, so 'InMemory-{guid}' is still recognised as an in-memory connection string. Regression sentinel in Controllers/TestWebApplicationFactoryIsolationTests: two factories seed a marker client in the first, the second must not see it. Suite: 45/45 over 3 stable runs, 13-15s each.
2026-08-22 04:36:52 +01:00
[$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = InMemoryDatabaseName.For(_fixtureId),
// 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",
2026-07-12 06:01:42 +01:00
["Site:Authority"] = authority
});
2025-07-13 18:13:04 +01:00
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<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
// 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<ISmtpClientFactory>(smtpFactory);
_sharedSmtpClientFactory = smtpFactory;
var app = builder.ConfigureWebAppServices();
SiteSettings = app.Services.GetRequiredService<IOptions<SiteSettings>>().Value;
using (var migrationScope = app.Services.CreateScope())
2025-07-13 18:13:04 +01:00
{
var db = migrationScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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)
2026-04-19 16:18:37 +01:00
{
db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
2026-05-28 22:18:26 +01:00
{
Name = "test",
Enabled = true,
DisplayName = "Test API Scope",
Description = "Scope for testing purposes",
UserClaims = new List<IdentityServer8.EntityFramework.Entities.ApiScopeClaim>
{
new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "role" },
new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "email" }
}
2026-05-28 22:18:26 +01:00
});
2026-04-19 16:02:50 +01:00
// Add a basic API resource for the test scope
var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource
2026-04-19 16:18:37 +01:00
{
Name = "testapi",
DisplayName = "Test API",
Enabled = true,
Scopes = new List<IdentityServer8.EntityFramework.Entities.ApiResourceScope>
{
new IdentityServer8.EntityFramework.Entities.ApiResourceScope { Scope = "test" }
}
};
db.ApiResources.Add(apiResource);
db.SaveChanges();
2026-04-19 18:10:00 +01:00
}
}
2026-04-19 18:10:00 +01:00
_sharedConfiguration = Configuration;
_sharedSiteSettings = SiteSettings;
_sharedTestClientId = TestClientId;
_sharedTestClientSecret = TestClientSecret;
_sharedTestingUserName = TestingUserName;
_sharedTestingUserPassword = TestingUserPassword;
_sharedTestingUserEmail = TestingUserEmail;
_sharedLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<WebServerFixture>();
Logger = _sharedLogger;
SmtpClientFactory = smtpFactory;
return app;
}
2026-06-14 23:04:24 +01:00
protected override async Task<WebApplication> 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<IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext>();
if (configDb == null)
throw new InvalidOperationException("ConfigurationDbContext is not available for IdentityServer client seeding.");
2025-07-13 18:13:04 +01:00
Client testingClient = new Client
{
ClientId = testClientId,
AccessTokenLifetime = 3600000,
AccessTokenType = 1,
ClientName = "Testing client",
Enabled = true,
RequireClientSecret = true
};
configDb.Set<Client>().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<IdentityServer8.EntityFramework.Entities.ApiScope>().Add(apiScope);
ClientSecret secret = new ClientSecret
{
Value = testClientSecret.Sha256(),
Type = IdentityServer8.IdentityServerConstants.SecretTypes.SharedSecret,
ClientId = testingClient.Id
};
configDb.Set<ClientSecret>().Add(secret);
2026-03-09 02:07:09 +00:00
configDb.Set<ClientGrantType>().Add(new ClientGrantType
{
ClientId = testingClient.Id,
GrantType = "client_credentials"
});
configDb.Set<ClientGrantType>().Add(new ClientGrantType
{
ClientId = testingClient.Id,
GrantType = "password"
});
configDb.Set<ClientScope>().Add(new ClientScope
2026-03-09 02:07:09 +00:00
{
ClientId = testingClient.Id,
Scope = "test"
});
2026-04-19 17:15:10 +01:00
configDb.SaveChanges();
}
public void EnsureUser(string testingUserName, string password, string email, IServiceScope scope)
{
if (TestingUser == null)
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
2026-04-19 20:36:03 +01:00
TestingUser = new ApplicationUser
2026-04-19 17:15:10 +01:00
{
UserName = testingUserName,
Email = testingUserName + "@example.com",
EmailConfirmed = true
2026-04-19 17:15:10 +01:00
};
2026-04-19 16:18:37 +01:00
var result = userManager.CreateAsync(TestingUser, password).Result;
2026-04-19 16:18:37 +01:00
Assert.True(result.Succeeded);
2026-04-19 16:18:37 +01:00
ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName);
2026-04-19 16:18:37 +01:00
}
2025-07-13 18:13:04 +01:00
}
2026-07-12 06:01:42 +01:00
private static int GetAvailableLoopbackPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
2025-07-13 18:13:04 +01:00
}