From 453340e71e7fafe37236ce72a20af0134a4c30e7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 29 May 2026 07:56:15 -0400 Subject: [PATCH] test(security): add browser-vs-AJAX challenge tests for root path Adds protected MapGet("/") in the test host plus three [Fact] methods exercising the cookie scheme's challenge heuristic for the root route: browser (Accept: text/html), AJAX (X-Requested-With: XMLHttpRequest), and JSON (Accept: application/json) callers. Also adds a no-redirect HttpClient helper so the 302 + Location can be asserted directly. --- .../AuthEndpointsIntegrationTests.cs | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs index 6ab0018c..45ca5c6c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -63,7 +64,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); - app.UseEndpoints(e => e.MapOtOpcUaAuth()); + app.UseEndpoints(e => + { + e.MapOtOpcUaAuth(); + // Protected root used by AuthChallengeTests below — exercises the cookie + // scheme's challenge heuristic without depending on the full Razor host. + e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization(); + }); }); }) .Build(); @@ -81,6 +88,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime private HttpClient NewClient() => _server.CreateClient(); + /// Creates a TestServer-backed HttpClient that does NOT auto-follow redirects. + /// Used by challenge tests so we can assert on the 302 + Location directly. + private HttpClient NewClientNoRedirect() => new(_server.CreateHandler()) + { + BaseAddress = _server.BaseAddress, + }; + /// Tests that login with valid credentials returns 204 and sets cookie. [Fact] public async Task Login_with_valid_credentials_returns_204_and_sets_cookie() @@ -178,6 +192,46 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime .ShouldContain(c => c.StartsWith("OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase)); } + /// Anonymous browser GET of a protected route redirects to /login with a ReturnUrl. + [Fact] + public async Task Root_anonymous_browser_GET_redirects_to_login() + { + var client = NewClientNoRedirect(); + var req = new HttpRequestMessage(HttpMethod.Get, "/"); + req.Headers.Accept.ParseAdd("text/html"); + var resp = await client.SendAsync(req, Ct); + + resp.StatusCode.ShouldBe(HttpStatusCode.Found); + resp.Headers.Location.ShouldNotBeNull(); + resp.Headers.Location!.OriginalString.ShouldContain("/login"); + resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl"); + } + + /// Anonymous AJAX GET of a protected route returns 401 with no Location. + [Fact] + public async Task Root_anonymous_ajax_GET_returns_401() + { + var client = NewClientNoRedirect(); + var req = new HttpRequestMessage(HttpMethod.Get, "/"); + req.Headers.Add("X-Requested-With", "XMLHttpRequest"); + var resp = await client.SendAsync(req, Ct); + + resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + resp.Headers.Location.ShouldBeNull(); + } + + /// Anonymous JSON GET of a protected route returns 401. + [Fact] + public async Task Root_anonymous_json_GET_returns_401() + { + var client = NewClientNoRedirect(); + var req = new HttpRequestMessage(HttpMethod.Get, "/"); + req.Headers.Accept.ParseAdd("application/json"); + var resp = await client.SendAsync(req, Ct); + + resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior) { if (!prior.Headers.TryGetValues("Set-Cookie", out var setCookies)) return;