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));
}
}