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