using System.Security.Claims; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Xunit; namespace JobTrackerApi.Tests; public sealed class SessionContextMiddlewareTests { [Theory] [InlineData("POST")] [InlineData("PUT")] [InlineData("PATCH")] [InlineData("DELETE")] public async Task Missing_old_and_forged_contexts_never_reach_an_authenticated_mutation(string method) { foreach (var supplied in new[] { null, "", "session-a", "user-a", "session-b,session-a" }) { var context = Context(method, supplied); var invoked = false; await new SessionContextMiddleware(_ => { invoked = true; return Task.CompletedTask; }).InvokeAsync(context); Assert.False(invoked); Assert.Equal(string.IsNullOrEmpty(supplied) ? 428 : 409, context.Response.StatusCode); context.Response.Body.Position = 0; var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); Assert.DoesNotContain("user-a", body); Assert.DoesNotContain("session-b", body); } } [Theory] [InlineData("GET")] [InlineData("POST")] [InlineData("PUT")] [InlineData("PATCH")] [InlineData("DELETE")] public async Task Matching_context_preserves_the_authenticated_owner_for_downstream_code(string method) { var context = Context(method, "session-b"); // No cookie is required for the guard: supplying a bearer token cannot bypass it. Assert.Empty(context.Request.Cookies); var invoked = false; await new SessionContextMiddleware(http => { invoked = true; Assert.Equal("user-b", http.User.FindFirstValue(ClaimTypes.NameIdentifier)); return Task.CompletedTask; }).InvokeAsync(context); Assert.True(invoked); } [Fact] public async Task Stale_context_also_rejects_reads_but_unbound_bootstrap_is_allowed() { var calls = 0; var middleware = new SessionContextMiddleware(_ => { calls++; return Task.CompletedTask; }); var stale = Context("GET", "session-a"); await middleware.InvokeAsync(stale); Assert.Equal(409, stale.Response.StatusCode); Assert.Equal(0, calls); await middleware.InvokeAsync(Context("GET", null)); Assert.Equal(1, calls); } [Fact] public async Task Admin_and_missing_sid_cannot_bypass_the_precondition() { foreach (var sid in new[] { "session-b", null }) { var context = Context("PUT", "session-a", sid); ((ClaimsIdentity)context.User.Identity!).AddClaim(new Claim(ClaimTypes.Role, "Admin")); var invoked = false; await new SessionContextMiddleware(_ => { invoked = true; return Task.CompletedTask; }).InvokeAsync(context); Assert.False(invoked); Assert.Equal(409, context.Response.StatusCode); } } [Fact] public async Task Multiple_headers_fail_closed() { var context = Context("PUT", "session-b"); context.Request.Headers.Append(SessionContextMiddleware.HeaderName, "session-a"); await new SessionContextMiddleware(_ => throw new InvalidOperationException("Unexpected mutation")).InvokeAsync(context); Assert.Equal(409, context.Response.StatusCode); } [Fact] public async Task Anonymous_authentication_flows_remain_available() { var context = Context("POST", null); context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(new AllowAnonymousAttribute()), "sign-in")); var invoked = false; await new SessionContextMiddleware(_ => { invoked = true; return Task.CompletedTask; }).InvokeAsync(context); Assert.True(invoked); } private static DefaultHttpContext Context(string method, string? expected, string? sid = "session-b") { var claims = new List { new(ClaimTypes.NameIdentifier, "user-b") }; if (sid is not null) claims.Add(new Claim("sid", sid)); var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(claims, "local")) }; context.Request.Method = method; if (expected is not null) context.Request.Headers[SessionContextMiddleware.HeaderName] = expected; context.Response.Body = new MemoryStream(); return context; } }