Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs
T
Joseph Doherty f40409a688
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
docs: strip task-tracking IDs from code comments and fill missing XML docs
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00

295 lines
11 KiB
C#

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
{
/// <summary>
/// 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 = 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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <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 = 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));
}
/// <summary>Verifies that a null token returns null.</summary>
[Fact]
public void Validate_NullToken_ReturnsNull()
{
HubTokenService service = CreateService();
Assert.Null(service.Validate(null));
}
/// <summary>Verifies that an empty token returns null.</summary>
[Fact]
public void Validate_EmptyToken_ReturnsNull()
{
HubTokenService service = CreateService();
Assert.Null(service.Validate(string.Empty));
}
/// <summary>Verifies that an invalid token returns null.</summary>
[Fact]
public void Validate_GarbageToken_ReturnsNull()
{
HubTokenService service = CreateService();
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
}
/// <summary>
/// Issue/validate round-trip: a freshly minted token (default <see cref="HubTokenService.TokenLifetime"/>)
/// validates and reconstructs the caller's identity and roles.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void TokenLifetime_IsFiveMinutes()
{
Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime);
}
/// <summary>
/// A token whose lifetime has elapsed is rejected by <see cref="HubTokenService.Validate"/>.
/// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already
/// expired at mint time — deterministic, no wall-clock delay.
/// </summary>
[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));
}
/// <summary>
/// The dashboard visibility grant survives the mint/validate round-trip: tags are
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
/// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
/// <c>IDashboardSessionAcl</c> reads on the hub path.
/// </summary>
[Fact]
public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
{
HubTokenService service = CreateService(new Dictionary<string, string[]>(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));
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
{
HubTokenService service = CreateService(new Dictionary<string, string[]>(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));
}
/// <summary>
/// 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 <c>Tags</c> 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 <c>Validate</c> is the only thing standing between that and a crash on
/// the hub's authentication path.
/// </summary>
[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<string, string[]>? groupToTag = null,
IDataProtectionProvider? dataProtection = null)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
},
};
return new HubTokenService(
dataProtection ?? new EphemeralDataProtectionProvider(),
Options.Create(options));
}
}