c0a0e7aa81
ServerAuthenticationStateProvider throws InvalidOperationException from GetAuthenticationStateAsync in any scope where no circuit set its state (minimal-API requests, hosted-service scopes). The accessor now treats that as "no circuit identity" and falls back to the HTTP principal. Pinned with the real provider on both sides (unset -> HTTP fallback; set -> circuit principal wins). Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
117 lines
5.1 KiB
C#
117 lines
5.1 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Audit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit;
|
|
|
|
/// <summary>
|
|
/// R03:2.5 — inside a Blazor circuit there is no <c>HttpContext</c> after the first render, so an
|
|
/// HTTP-only accessor stamps every audit row from an interactive page <c>"system"</c>. The circuit's
|
|
/// <see cref="AuthenticationStateProvider"/> is consulted first; the HTTP context is the fallback.
|
|
/// </summary>
|
|
public sealed class AuthenticationStateAuditActorAccessorTests
|
|
{
|
|
[Fact]
|
|
public void Circuit_principal_wins_when_there_is_no_http_context()
|
|
{
|
|
var accessor = new AuthenticationStateAuditActorAccessor(
|
|
new HttpContextAccessorStub(null),
|
|
new FixedStateProvider(Authenticated(new Claim(ZbClaimTypes.Username, "alice"))));
|
|
|
|
accessor.CurrentActor.ShouldBe("alice");
|
|
}
|
|
|
|
[Fact]
|
|
public void Http_principal_is_the_fallback_outside_a_circuit()
|
|
{
|
|
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "bob")) };
|
|
var accessor = new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), authenticationState: null);
|
|
|
|
accessor.CurrentActor.ShouldBe("bob");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unauthenticated_circuit_falls_through_to_http_then_null()
|
|
{
|
|
var anonymousCircuit = new FixedStateProvider(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
|
|
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(null), anonymousCircuit)
|
|
.CurrentActor.ShouldBeNull();
|
|
|
|
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Name, "carol")) };
|
|
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), anonymousCircuit)
|
|
.CurrentActor.ShouldBe("carol");
|
|
}
|
|
|
|
/// <summary>An incomplete state task is "no circuit identity", never a block.</summary>
|
|
[Fact]
|
|
public void Pending_authentication_state_is_not_awaited()
|
|
{
|
|
var accessor = new AuthenticationStateAuditActorAccessor(
|
|
new HttpContextAccessorStub(null),
|
|
new PendingStateProvider());
|
|
|
|
accessor.CurrentActor.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round 1 / I5 — the REAL server-side provider, resolved in a non-circuit scope (a minimal-API
|
|
/// request, a background service), throws <see cref="InvalidOperationException"/> from
|
|
/// <c>GetAuthenticationStateAsync</c> because nothing called <c>SetAuthenticationState</c>. That used
|
|
/// to escape <c>CurrentActor</c>; it must fall back to the HTTP principal instead.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Real_server_provider_outside_a_circuit_falls_back_to_the_http_principal()
|
|
{
|
|
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "dave")) };
|
|
var accessor = new AuthenticationStateAuditActorAccessor(
|
|
new HttpContextAccessorStub(context),
|
|
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider());
|
|
|
|
accessor.CurrentActor.ShouldBe("dave");
|
|
|
|
// No HTTP principal either ⇒ null, still no throw.
|
|
new AuthenticationStateAuditActorAccessor(
|
|
new HttpContextAccessorStub(null),
|
|
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider())
|
|
.CurrentActor.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Positive control on the real provider: once a circuit has set its state, that principal wins.</summary>
|
|
[Fact]
|
|
public void Real_server_provider_inside_a_circuit_serves_the_circuit_principal()
|
|
{
|
|
var provider = new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider();
|
|
provider.SetAuthenticationState(Task.FromResult(
|
|
new AuthenticationState(Authenticated(new Claim(ZbClaimTypes.Username, "erin")))));
|
|
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "http-user")) };
|
|
|
|
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), provider)
|
|
.CurrentActor.ShouldBe("erin");
|
|
}
|
|
|
|
private static ClaimsPrincipal Authenticated(params Claim[] claims) =>
|
|
new(new ClaimsIdentity(claims, "TestScheme", ZbClaimTypes.Name, ZbClaimTypes.Role));
|
|
|
|
private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor
|
|
{
|
|
public HttpContext? HttpContext { get; set; } = context;
|
|
}
|
|
|
|
private sealed class FixedStateProvider(ClaimsPrincipal user) : AuthenticationStateProvider
|
|
{
|
|
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
|
Task.FromResult(new AuthenticationState(user));
|
|
}
|
|
|
|
private sealed class PendingStateProvider : AuthenticationStateProvider
|
|
{
|
|
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
|
new TaskCompletionSource<AuthenticationState>().Task;
|
|
}
|
|
}
|