2026-06-21 21:14:20 +01:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Hosting;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
|
using Microsoft.AspNetCore.TestHost;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
2026-07-06 21:33:57 +01:00
|
|
|
using Yavsc.Tests.Shared;
|
2026-06-21 21:14:20 +01:00
|
|
|
|
|
|
|
|
namespace Yavsc.Org.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// WebApplicationFactory-based fixture for integration tests that need
|
|
|
|
|
/// to override services registered by the production <c>Program</c>.
|
|
|
|
|
/// Uses the in-memory <see cref="TestServer"/> so tests can hit real
|
|
|
|
|
/// HTTP endpoints without sockets or self-signed certificates.
|
|
|
|
|
///
|
|
|
|
|
/// Currently overrides <see cref="TestAuthPolicyProvider"/> so that
|
|
|
|
|
/// <c>[Authorize("AdministratorOnly")]</c> (and any other policy
|
|
|
|
|
/// requiring a role) is satisfied by sending an
|
|
|
|
|
/// <c>X-Test-Role: Administrator</c> header, without a real login.
|
2026-06-21 21:24:44 +01:00
|
|
|
/// Also adds <see cref="TestUserStartupFilter"/> which installs
|
|
|
|
|
/// <see cref="TestUserMiddleware"/> so that <c>User.GetUserId()</c>
|
|
|
|
|
/// in user code sees a logged-in identity derived from the same
|
|
|
|
|
/// header.
|
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
|
|
|
///
|
|
|
|
|
/// Each instance gets its own in-memory database, identified by a
|
|
|
|
|
/// GUID generated in the constructor. The connection string
|
|
|
|
|
/// (<c>ConnectionStrings:YavscConnection</c>) is set as an
|
|
|
|
|
/// environment variable (<c>ConnectionStrings__YavscConnection</c>)
|
|
|
|
|
/// in the constructor and unset in <see cref="Dispose"/>, so the
|
|
|
|
|
/// production <c>AddIdentityDBAndStores</c> registers <c>DbContext</c>
|
|
|
|
|
/// instances against this fixture's own store. Without this, the
|
|
|
|
|
/// <c>"InMemory"</c> connection string from
|
|
|
|
|
/// <c>appsettings-org.Testing.json</c> would route every
|
|
|
|
|
/// <see cref="TestWebApplicationFactory"/> instance — and any
|
|
|
|
|
/// <see cref="WebServerFixture"/> running in the same process — to
|
|
|
|
|
/// the same backing store, leaking state between fixtures.
|
|
|
|
|
///
|
|
|
|
|
/// Env vars are used (rather than <c>ConfigureAppConfiguration</c> or
|
|
|
|
|
/// <c>UseSetting</c>) because <c>WebApplicationFactory</c> applies
|
|
|
|
|
/// those too late: <c>Program.Main</c> has already captured the
|
|
|
|
|
/// connection string in <c>AddIdentityDBAndStores</c> by the time
|
|
|
|
|
/// the test host's overrides take effect. Env vars are the last
|
|
|
|
|
/// provider added in <c>AddConfiguration</c> (see
|
|
|
|
|
/// <c>Yavsc.Server/Helpers/ConfigHelpers.cs</c>), so they win.
|
2026-06-21 21:14:20 +01:00
|
|
|
/// </summary>
|
|
|
|
|
public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|
|
|
|
{
|
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
|
|
|
private readonly string _fixtureId = Guid.NewGuid().ToString("N");
|
|
|
|
|
|
|
|
|
|
// ASP.NET Core's environment-variable configuration provider uses
|
|
|
|
|
// the key ConnectionStrings__YavscConnection (double underscore
|
|
|
|
|
// for the section separator). Set it before the host starts so
|
|
|
|
|
// the per-fixture connection string wins over
|
|
|
|
|
// appsettings-org.Testing.json. We do NOT touch the appsettings
|
|
|
|
|
// file; env vars take precedence in the configuration pipeline
|
|
|
|
|
// (see AddConfiguration in Yavsc.Server/Helpers/ConfigHelpers.cs,
|
|
|
|
|
// which adds AddEnvironmentVariables last).
|
|
|
|
|
private static readonly object _envLock = new();
|
|
|
|
|
private bool _envSet;
|
|
|
|
|
|
|
|
|
|
public TestWebApplicationFactory()
|
|
|
|
|
{
|
|
|
|
|
lock (_envLock)
|
|
|
|
|
{
|
|
|
|
|
Environment.SetEnvironmentVariable(
|
|
|
|
|
"ConnectionStrings__YavscConnection",
|
|
|
|
|
InMemoryDatabaseName.For(_fixtureId));
|
|
|
|
|
_envSet = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 21:14:20 +01:00
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
|
|
|
{
|
2026-07-11 20:50:39 +01:00
|
|
|
// UseEnvironment("Testing") puts the host in a dedicated
|
|
|
|
|
// configuration environment so AddConfiguration("org") in
|
|
|
|
|
// Program.Main loads the optional appsettings-org.Testing.json
|
|
|
|
|
// file (which overrides the connection string and SMTP section
|
|
|
|
|
// for the test host). See that file for the values.
|
|
|
|
|
// We don't use "Development" because that environment is also
|
|
|
|
|
// used by the dev launcher and would change the signing
|
|
|
|
|
// credential path in IdentityServer; "Testing" is unambiguous.
|
|
|
|
|
builder.UseEnvironment("Testing");
|
2026-06-21 21:14:20 +01:00
|
|
|
|
|
|
|
|
builder.ConfigureTestServices(services =>
|
|
|
|
|
{
|
|
|
|
|
// Replace the production IAuthorizationPolicyProvider with
|
|
|
|
|
// the test one. The default registered by AddAuthorization
|
|
|
|
|
// becomes irrelevant: any GetPolicyAsync call is routed here.
|
|
|
|
|
services.AddSingleton<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
|
2026-06-21 21:23:36 +01:00
|
|
|
|
2026-06-21 21:24:44 +01:00
|
|
|
// Register the test middleware and its startup filter.
|
|
|
|
|
// The startup filter wraps the production pipeline so
|
|
|
|
|
// TestUserMiddleware runs after UseAuthentication/Authorization.
|
|
|
|
|
services.AddTransient<TestUserMiddleware>();
|
|
|
|
|
services.AddTransient<IStartupFilter, TestUserStartupFilter>();
|
2026-06-21 21:23:36 +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
|
|
|
|
|
|
|
|
protected override void Dispose(bool disposing)
|
|
|
|
|
{
|
|
|
|
|
if (disposing && _envSet)
|
|
|
|
|
{
|
|
|
|
|
lock (_envLock)
|
|
|
|
|
{
|
|
|
|
|
Environment.SetEnvironmentVariable(
|
|
|
|
|
"ConnectionStrings__YavscConnection", null);
|
|
|
|
|
_envSet = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
base.Dispose(disposing);
|
|
|
|
|
}
|
2026-06-21 21:14:20 +01:00
|
|
|
}
|