using System.Security.Claims; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server.Configuration; 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 = CreateService(); // 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 = CreateService(); 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 = CreateService(); 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 = CreateService(); Assert.Null(service.Validate(null)); } /// Verifies that an empty token returns null. [Fact] public void Validate_EmptyToken_ReturnsNull() { HubTokenService service = CreateService(); Assert.Null(service.Validate(string.Empty)); } /// Verifies that an invalid token returns null. [Fact] public void Validate_GarbageToken_ReturnsNull() { HubTokenService service = CreateService(); 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 = CreateService(); 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, 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 = CreateService(); 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)); } /// /// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are /// resolved from the caller's LDAP-group claims through Dashboard:GroupToTag at /// and rehydrated as /// claims on the principal /// reconstructs — which is the principal /// IDashboardSessionAcl reads on the hub path. /// [Fact] public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags() { HubTokenService service = CreateService(new Dictionary(StringComparer.OrdinalIgnoreCase) { ["GwViewer"] = ["team-a"], ["TeamBViewers"] = ["team-b"], }); ClaimsIdentity identity = new( [ new Claim(ClaimTypes.Name, "dana"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer), new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"), new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"), ], authenticationType: "test", nameType: ClaimTypes.Name, roleType: ClaimTypes.Role); ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity))); Assert.NotNull(result); Assert.Equal( ["team-a", "team-b"], result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType) .Select(c => c.Value) .Order(StringComparer.Ordinal)); } /// /// A caller whose groups map to nothing mints a token with no tags, and validating it yields a /// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This /// is also the shape of every token minted before the tag field existed (the payload field /// deserializes to null), so the fail-closed direction is covered for both. /// [Fact] public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant() { HubTokenService service = CreateService(new Dictionary(StringComparer.OrdinalIgnoreCase) { ["SomeOtherGroup"] = ["team-a"], }); ClaimsIdentity identity = new( [ new Claim(ClaimTypes.Name, "erin"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer), new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"), ], authenticationType: "test", nameType: ClaimTypes.Name, roleType: ClaimTypes.Role); ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity))); Assert.NotNull(result); Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)); } /// /// A token minted before the payload carried tags at all still validates, and yields an empty /// grant rather than throwing or rejecting. Distinct from the empty-grant test above, which /// exercises a Tags key that is present and empty: this one protects a hand-built /// payload with the key genuinely ABSENT, which is the shape every in-flight token has across /// the deploy that introduces the field. Deserialization leaves the field null, and the /// null-coalesce in Validate is the only thing standing between that and a crash on /// the hub's authentication path. /// [Fact] public void Validate_TokenMintedBeforeTagsFieldExisted_YieldsEmptyGrant() { EphemeralDataProtectionProvider dataProtection = new(); HubTokenService service = CreateService(dataProtection: dataProtection); // The pre-field payload shape, verbatim: no "Tags" key anywhere. const string LegacyPayload = """{"Name":"frank","NameIdentifier":"frank-id","Roles":["Viewer"]}"""; string legacyToken = dataProtection .CreateProtector(HubTokenService.ProtectorPurpose) .ToTimeLimitedDataProtector() .Protect(LegacyPayload, HubTokenService.TokenLifetime); ClaimsPrincipal? result = service.Validate(legacyToken); Assert.NotNull(result); Assert.Equal("frank", result.Identity?.Name); Assert.True(result.IsInRole(DashboardRoles.Viewer)); Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)); } private static HubTokenService CreateService( Dictionary? groupToTag = null, IDataProtectionProvider? dataProtection = null) { GatewayOptions options = new() { Dashboard = new DashboardOptions { GroupToTag = groupToTag ?? new Dictionary(StringComparer.OrdinalIgnoreCase), }, }; return new HubTokenService( dataProtection ?? new EphemeralDataProtectionProvider(), Options.Create(options)); } }