From afd02ab5aad7eb39a3b5079444c574bbad8af0d5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 21 Jun 2026 21:24:44 +0100 Subject: [PATCH] test(client): refactor InjectTestUser as a real IMiddleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the X-Test-Role-to-User promotion out of an inline RequestDelegate and into a proper IMiddleware implementation, wired through IStartupFilter so it lands after the production UseAuthentication/UseAuthorization in the request pipeline. The previous app.Use(...) injection ran before the production auth middleware, so any identity we set on HttpContext.User was being overwritten by the next middleware. Wrapping the production pipeline in TestUserStartupFilter.Configure (replaying it first, then adding TestUserMiddleware via UseMiddleware<>) puts the test identity downstream of auth, where controllers actually read it. WIP: this commit alone doesn't move the test needle — the AddRedirectUri_POST test still hits a developer exception page because MapStaticAssets() default lookup can't find Yavsc.Org.Tests.staticwebassets.endpoints.json in the test bin. A follow-up commit will either land the MSBuild rename target or drop the WebApplicationFactory approach in favour of the WebServerFixture that gets the manifest path via a runtime parameter. --- src/Yavsc.Org.Tests/TestUserMiddleware.cs | 46 ++++++++++++++++++ src/Yavsc.Org.Tests/TestUserStartupFilter.cs | 47 ++++++++++++++++++ .../TestWebApplicationFactory.cs | 48 ++++--------------- 3 files changed, 102 insertions(+), 39 deletions(-) create mode 100644 src/Yavsc.Org.Tests/TestUserMiddleware.cs create mode 100644 src/Yavsc.Org.Tests/TestUserStartupFilter.cs diff --git a/src/Yavsc.Org.Tests/TestUserMiddleware.cs b/src/Yavsc.Org.Tests/TestUserMiddleware.cs new file mode 100644 index 00000000..c6348625 --- /dev/null +++ b/src/Yavsc.Org.Tests/TestUserMiddleware.cs @@ -0,0 +1,46 @@ +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Yavsc.Org.Tests; + +/// +/// Middleware that promotes the X-Test-Role request header to +/// an authenticated on +/// . Lets downstream code (controllers, +/// services) call User.GetUserId() and other claim-based +/// helpers as if a real login had happened, while +/// independently short-circuits +/// [Authorize(...)] policy checks. +/// +/// This middleware is added to the pipeline by +/// so it runs after the +/// production authentication middleware; setting User before +/// UseAuthentication would have it overwritten on the next +/// middleware. +/// +public class TestUserMiddleware : IMiddleware +{ + public const string UserId = "test-user"; + + public Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var role = context.Request.Headers[TestAuthPolicyProvider.HeaderName].ToString(); + if (!string.IsNullOrEmpty(role) && + (context.User.Identity is null || !context.User.Identity.IsAuthenticated)) + { + var identity = new ClaimsIdentity( + new[] + { + new Claim( + "http://schemas.microsoft.com/ws/2008/06/identity/claims/role", + role), + new Claim(ClaimTypes.NameIdentifier, UserId), + }, + authenticationType: "TestAuth"); + context.User = new ClaimsPrincipal(identity); + } + return next(context); + } +} diff --git a/src/Yavsc.Org.Tests/TestUserStartupFilter.cs b/src/Yavsc.Org.Tests/TestUserStartupFilter.cs new file mode 100644 index 00000000..53439ed9 --- /dev/null +++ b/src/Yavsc.Org.Tests/TestUserStartupFilter.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace Yavsc.Org.Tests; + +/// +/// Startup filter that injects after +/// the authentication and authorization middleware. The +/// contract wraps the existing +/// pipeline: the next delegate is the rest of the app's +/// pipeline, so we run our middleware before it but +/// after anything that was registered as a startup filter +/// earlier in the chain. +/// +/// In practice this puts TestUserMiddleware ahead of +/// UseAuthentication (registered inside ConfigurePipeline) +/// because the production code path runs UseAuthentication +/// synchronously inside Configure, after all startup filters +/// have wrapped it. We want the opposite: the test identity must be +/// visible to authorization and the controller, so we register +/// TestUserMiddleware via +/// inside the filter such that it runs late in the chain. The +/// simplest way to achieve that is to register the middleware +/// after the production authorization pipeline: we wrap with our +/// middleware inside the filter, so our delegate sits between the +/// framework middleware (set up by Configure) and the rest of the +/// pipeline — meaning requests flow: +/// framework authN/authZ → TestUserMiddleware → next pipeline. +/// +public class TestUserStartupFilter : IStartupFilter +{ + public Action Configure(Action next) + { + return app => + { + // Replay the production pipeline first (this is what + // Program.Main + ConfigurePipeline set up, including + // UseAuthentication and UseAuthorization). + next(app); + // Then add our middleware on top. UseMiddleware wires + // it through the same IMiddlewareActivator the framework + // uses, so the dependency on TestUserMiddleware is + // resolved from the request scope. + app.UseMiddleware(); + }; + } +} diff --git a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs index c62f7f8a..49d3b663 100644 --- a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs +++ b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs @@ -1,11 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using System.Security.Claims; -using System.Threading.Tasks; namespace Yavsc.Org.Tests; @@ -19,10 +16,10 @@ namespace Yavsc.Org.Tests; /// [Authorize("AdministratorOnly")] (and any other policy /// requiring a role) is satisfied by sending an /// X-Test-Role: Administrator header, without a real login. -/// Also injects a middleware that promotes the same header into a -/// real on HttpContext.User so -/// that user code reading User.GetUserId() sees a logged-in -/// identity. +/// Also adds which installs +/// so that User.GetUserId() +/// in user code sees a logged-in identity derived from the same +/// header. /// public class TestWebApplicationFactory : WebApplicationFactory { @@ -39,39 +36,12 @@ public class TestWebApplicationFactory : WebApplicationFactory // the test one. The default registered by AddAuthorization // becomes irrelevant: any GetPolicyAsync call is routed here. services.AddSingleton(); - }); - // Promote the X-Test-Role header to an authenticated identity - // on the request, so anything that reads User.GetUserId() (or - // any other claim-based helper) downstream sees a logged-in - // user. The policy provider above only short-circuits - // [Authorize(...)] checks; it does not touch HttpContext.User. - builder.Configure(app => - { - app.Use(InjectTestUser); + // Register the test middleware and its startup filter. + // The startup filter wraps the production pipeline so + // TestUserMiddleware runs after UseAuthentication/Authorization. + services.AddTransient(); + services.AddTransient(); }); } - - private static RequestDelegate InjectTestUser(RequestDelegate next) - { - return async ctx => - { - var role = ctx.Request.Headers[TestAuthPolicyProvider.HeaderName].ToString(); - if (!string.IsNullOrEmpty(role) && - (ctx.User.Identity is null || !ctx.User.Identity.IsAuthenticated)) - { - var identity = new ClaimsIdentity( - new[] - { - new Claim( - "http://schemas.microsoft.com/ws/2008/06/identity/claims/role", - role), - new Claim(ClaimTypes.NameIdentifier, "test-user"), - }, - authenticationType: "TestAuth"); - ctx.User = new ClaimsPrincipal(identity); - } - await next(ctx); - }; - } }