0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
110 lines
4.4 KiB
C#
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;
|
|
}
|
|
}
|