Resolve Server-031..032 (re-triaged) + Server-038..043
Server-031: re-triaged. The recommended gateway-side "skip-while-command-in-flight" guard is already in place at WorkerClient.HeartbeatLoopAsync via WorkerClientOptions.HeartbeatStuckCeiling (default 75s = 5× HeartbeatGrace). Two regression tests pin the behaviour. Recommendation #1 (decouple worker-side _writeLock) is a Worker-module concern (Worker-017 / Worker-023) and out of scope here. Server-032: re-triaged. Recommendation #2 (rich diagnostic) is already in EnqueueWorkerEventAsync, with #3 (overflow grace) absorbed by the TryWrite → WriteAsync-with-timeout fall-through. Test EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic pins the diagnostic string. Recommendation #1 (prose contract in gateway.md / docs) is deferred — outside this pass's edit scope. Server-038 (Security): EventsHub.SubscribeSession's missing per-session ACL is documented with a TODO(per-session-acl) and a <remarks> block explaining the v1 acceptance (any dashboard role can subscribe to any session — non-secret metadata, redacted value logging). The per-session ACL design lands in a follow-up once a session-scoped role exists. Server-039 (Error handling): HubTokenService.Validate now rejects a deserialized payload where both Name and NameIdentifier are null/empty. New test file HubTokenServiceTests.cs covers the regression and five sanity cases. TDD confirmed. Server-040 (Conventions): MapGroupsToRoles gains a precedence comment explaining "full literal match first, leading-RDN fallback; OrdinalIgnoreCase via DashboardOptions.GroupToRole". Documentation-only. Server-041 (Design adherence): EventStreamService.ProduceEventsAsync wraps the broadcaster.Publish call in try/catch (Exception). The producer loop and gRPC stream are no longer at the mercy of the broadcaster's never-throw discipline. New regression test StreamEventsAsync_WhenDashboardBroadcasterThrows_StillYieldsEventsAndDoesNotFaultSession. Server-042 (Performance): DashboardSnapshotPublisher.ExecuteAsync now mirrors AlarmsHubPublisher's reconnect loop — wraps the await foreach in a while-not-cancelled, catches general exceptions, and Task.Delays 5s before retrying. An internal ctor accepts a shorter delay for the test. New test file DashboardSnapshotPublisherTests.cs covers the throw-then-yield reconnect path and the normal-completion case. Server-043 (Documentation): HubTokenService class XML doc gains a <remarks> describing the singleton lifetime, the two consumer scopes (DashboardHubConnectionFactory scoped, HubTokenAuthenticationHandler transient), and the thread-safety contract. Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean (0 warnings / 0 errors); src/ZB.MOM.WW.MxGateway.Tests 486/486 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class HubTokenServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-039: a token whose data-protected payload has both
|
||||
/// <c>Name</c> and <c>NameIdentifier</c> null (the principal that
|
||||
/// minted the token had no identity claims) must be rejected by
|
||||
/// <see cref="HubTokenService.Validate"/>. The role claims alone are
|
||||
/// not enough — without a caller identity, the resulting
|
||||
/// <see cref="ClaimsPrincipal"/> would satisfy
|
||||
/// <c>IsAuthenticated</c> / <c>IsInRole</c> checks without an
|
||||
/// associated user.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
|
||||
// Issue from a principal with NO Name claim and NO NameIdentifier
|
||||
// claim. The Issue method's payload will then carry
|
||||
// (Name = null, NameIdentifier = null, Roles = ["Viewer"]).
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
|
||||
authenticationType: "test");
|
||||
ClaimsPrincipal principal = new(identity);
|
||||
string token = service.Issue(principal);
|
||||
|
||||
ClaimsPrincipal? result = service.Validate(token);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanity check: a token minted from a principal with a Name claim
|
||||
/// validates and returns a principal carrying that identity. Pins
|
||||
/// that the Server-039 fix does not over-reject valid tokens.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "alice"),
|
||||
new Claim(ClaimTypes.NameIdentifier, "alice-id"),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
||||
],
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role);
|
||||
ClaimsPrincipal principal = new(identity);
|
||||
string token = service.Issue(principal);
|
||||
|
||||
ClaimsPrincipal? result = service.Validate(token);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("alice", result.Identity?.Name);
|
||||
Assert.True(result.IsInRole(DashboardRoles.Admin));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanity check: a token minted with only a NameIdentifier (no Name)
|
||||
/// still validates — a non-null caller identity is the contract,
|
||||
/// either field is sufficient.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, "alice-id"),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
||||
],
|
||||
authenticationType: "test");
|
||||
ClaimsPrincipal principal = new(identity);
|
||||
string token = service.Issue(principal);
|
||||
|
||||
ClaimsPrincipal? result = service.Validate(token);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
|
||||
Assert.Null(service.Validate(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
|
||||
Assert.Null(service.Validate(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GarbageToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
|
||||
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user