Files
jobtrackingapp/JobTrackerApi.Tests/SessionContextMiddlewareTests.cs
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

110 lines
4.4 KiB
C#

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<Claim> { 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;
}
}