Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs
T
Joseph Doherty f72af76e99 fix(security): validate the API-key pepper at start, finish JWT bearer, circuit-aware audit actor
- ApiKeyOptionsValidator (ValidateOnStart on any driver/admin node): a blank or placeholder
  Security:ApiKeys:Pepper fails host start instead of rejecting every key after a green boot (R03:2.3).
- JWT is finished and opt-in by key (R03:2.4): with Security:Jwt:SigningKey set, a Bearer scheme
  (JwtBearerAuthenticationHandler over JwtTokenService.TryValidate / BuildValidationParameters, fail
  closed) accepts the tokens /auth/token mints; the default scheme is a cookie-or-bearer policy scheme
  and the fallback policy names it, so /api/* and every RequireAuthorization endpoint take a browser
  session OR a machine token. JwtOptions are validated at start (JwtOptionsValidator). Without a key,
  /auth/token is not mapped (404, not the former constructor 500) and no bearer scheme exists.
  A direct AddJwtBearer needs Microsoft.AspNetCore.Authentication.JwtBearer in
  Directory.Packages.props (not this lane's file) — the handler is a drop-in to swap.
- AuthenticationStateAuditActorAccessor is the registered IAuditActorAccessor: the Blazor circuit's
  principal first, HttpContext as the fallback (R03:2.5). HttpAuditActorAccessor shares its resolver.
- CookieAuthenticationStateProvider (unregistered, fire-and-forget ping loop) is deleted (R03:1.5/2.5).
- /auth/login logs the LDAP exception at Warning before answering 503 (R03:2.7).
- Admin operations reply with a fixed message + correlation id instead of ex.Message (R03:4.1).

Refs #733

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:23:14 -04:00

81 lines
3.2 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();
}
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;
}
}