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 { /// /// A token whose data-protected payload has both /// Name and NameIdentifier null (the principal that /// minted the token had no identity claims) must be rejected by /// . The role claims alone are /// not enough — without a caller identity, the resulting /// would satisfy /// IsAuthenticated / IsInRole checks without an /// associated user. /// [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); } /// /// Sanity check: a token minted from a principal with a Name claim /// validates and returns a principal carrying that identity. Pins /// that the null-identity rejection above does not over-reject valid tokens. /// [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)); } /// /// 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. /// [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)); } /// Verifies that a null token returns null. [Fact] public void Validate_NullToken_ReturnsNull() { HubTokenService service = new(new EphemeralDataProtectionProvider()); Assert.Null(service.Validate(null)); } /// Verifies that an empty token returns null. [Fact] public void Validate_EmptyToken_ReturnsNull() { HubTokenService service = new(new EphemeralDataProtectionProvider()); Assert.Null(service.Validate(string.Empty)); } /// Verifies that an invalid token returns null. [Fact] public void Validate_GarbageToken_ReturnsNull() { HubTokenService service = new(new EphemeralDataProtectionProvider()); Assert.Null(service.Validate("this-is-not-a-protected-payload")); } /// /// Issue/validate round-trip: a freshly minted token (default ) /// validates and reconstructs the caller's identity and roles. /// [Fact] public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles() { HubTokenService service = new(new EphemeralDataProtectionProvider()); ClaimsIdentity identity = new( [ new Claim(ClaimTypes.Name, "bob"), new Claim(ClaimTypes.NameIdentifier, "bob-id"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer), new Claim(ClaimTypes.Role, DashboardRoles.Admin), ], authenticationType: "test", nameType: ClaimTypes.Name, roleType: ClaimTypes.Role); string token = service.Issue(new ClaimsPrincipal(identity)); ClaimsPrincipal? result = service.Validate(token); Assert.NotNull(result); Assert.Equal("bob", result.Identity?.Name); Assert.True(result.IsInRole(DashboardRoles.Viewer)); Assert.True(result.IsInRole(DashboardRoles.Admin)); } /// /// The default token lifetime is the short (5-minute) window mandated by SEC-05, not the /// former 30-minute window. Pins the value so a regression that widens the exposure window /// of an irrevocable, query-string-carried token is caught in CI. /// [Fact] public void TokenLifetime_IsFiveMinutes() { Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime); } /// /// A token whose lifetime has elapsed is rejected by . /// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already /// expired at mint time — deterministic, no wall-clock delay. /// [Fact] public void Validate_ExpiredToken_ReturnsNull() { HubTokenService service = new(new EphemeralDataProtectionProvider()); ClaimsIdentity identity = new( [new Claim(ClaimTypes.Name, "carol")], authenticationType: "test"); string expiredToken = service.Issue( new ClaimsPrincipal(identity), TimeSpan.FromMinutes(-1)); Assert.Null(service.Validate(expiredToken)); } }