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.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user