using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.Ldap; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; #region WP-6: LdapAuthService Tests (re-aimed at the shared ZB.MOM.WW.Auth.Ldap service) // Task 1.2 cutover: ScadaBridge's bespoke LdapAuthService was replaced by the shared // ZB.MOM.WW.Auth.Ldap.LdapAuthService (ScadaBridge was the donor for its hardened // bind-then-search / RFC-4514+4515 escaping / fail-closed / service-account-bind- // distinction / per-op-timeout semantics). The deep hygiene parity now lives in the // LIBRARY's own test suite (ZB.MOM.WW.Auth.Ldap.Tests: LdapAuthServiceTests, // LdapAuthServiceFailureTests, LdapEscapingTests), which exercises those paths through // the library's internal ILdapConnection seam. Re-aiming them here would require that // internal seam, which ScadaBridge cannot reach. The tests below instead pin the // LIBRARY service's public, network-free surface and ScadaBridge's adopter wiring. public class LdapAuthServiceTests { private static LdapOptions CreateOptions(LdapTransport transport = LdapTransport.Ldaps, bool allowInsecure = false) => new() { Enabled = true, Server = "ldap.example.com", Port = 636, Transport = transport, AllowInsecure = allowInsecure, SearchBase = "dc=example,dc=com", ServiceAccountDn = "cn=admin,dc=example,dc=com", }; [Fact] public async Task AuthenticateAsync_Disabled_FailsWithDisabled_NoNetwork() { var options = CreateOptions() with { Enabled = false }; var service = new LdapAuthService(options); var result = await service.AuthenticateAsync("user", "password", CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(LdapAuthFailure.Disabled, result.Failure); } [Theory] [InlineData("")] [InlineData(" ")] public async Task AuthenticateAsync_EmptyUsername_FailsBadCredentials_NoNetwork(string username) { // An empty/whitespace username is rejected before any connection is attempted, // mapped to the enumeration-safe BadCredentials bucket (no "user is required" // string leak). This is the donor's empty-username guard, preserved. var service = new LdapAuthService(CreateOptions()); var result = await service.AuthenticateAsync(username, "password", CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure); } [Fact] public async Task AuthenticateAsync_ConnectionFailure_FailsClosed_NeverThrows() { // Point at a non-existent server: the library fails closed (never throws) and // maps the unreachable directory to the system-side ServiceAccountBindFailed // bucket — preserving the donor's "directory unavailable ⇒ login fails" rule. var options = CreateOptions(LdapTransport.None, allowInsecure: true) with { Server = "nonexistent.invalid", Port = 9999, ConnectionTimeoutMs = 2_000, }; var service = new LdapAuthService(options); var result = await service.AuthenticateAsync("user", "password", CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure); } } #endregion #region WP-7: JwtTokenService Tests public class JwtTokenServiceTests { private static SecurityOptions CreateOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; private static JwtTokenService CreateService(SecurityOptions? options = null) { return new JwtTokenService( Options.Create(options ?? CreateOptions()), NullLogger.Instance); } [Fact] public void GenerateToken_ContainsCorrectClaims() { var service = CreateService(); var token = service.GenerateToken( "John Doe", "johnd", new[] { "Administrator", "Designer" }, new[] { "1", "2" }); var principal = service.ValidateToken(token); Assert.NotNull(principal); Assert.Equal("John Doe", principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value); Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Contains("Administrator", roles); Assert.Contains("Designer", roles); var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList(); Assert.Contains("1", siteIds); Assert.Contains("2", siteIds); Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType)); } [Fact] public void GenerateToken_NullSiteIds_NoSiteIdClaims() { var service = CreateService(); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.NotNull(principal); Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType)); } [Fact] public void ValidateToken_InvalidToken_ReturnsNull() { var service = CreateService(); var result = service.ValidateToken("invalid.token.here"); Assert.Null(result); } [Fact] public void ValidateToken_WrongKey_ReturnsNull() { var service1 = CreateService(); var token = service1.GenerateToken("User", "user", new[] { "Administrator" }, null); var service2 = CreateService(new SecurityOptions { JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }); var result = service2.ValidateToken(token); Assert.Null(result); } [Fact] public void ValidateToken_UsesHmacSha256() { var service = CreateService(); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); // Decode header to verify algorithm var parts = token.Split('.'); var headerJson = System.Text.Encoding.UTF8.GetString( Convert.FromBase64String(parts[0].PadRight((parts[0].Length + 3) & ~3, '='))); Assert.Contains("HS256", headerJson); } [Fact] public void ShouldRefresh_TokenNearExpiry_ReturnsTrue() { var options = CreateOptions(); options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min var service = CreateService(options); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.True(service.ShouldRefresh(principal!)); } [Fact] public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse() { var service = CreateService(); // 15 min expiry, 5 min threshold var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.False(service.ShouldRefresh(principal!)); } [Fact] public void IsIdleTimedOut_RecentActivity_ReturnsFalse() { var service = CreateService(); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.False(service.IsIdleTimedOut(principal!)); } [Fact] public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue() { var service = CreateService(); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User") })); Assert.True(service.IsIdleTimedOut(principal)); } [Fact] public void RefreshToken_ReturnsNewTokenWithUpdatedClaims() { var service = CreateService(); var originalToken = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(originalToken); var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" }); Assert.NotNull(newToken); var newPrincipal = service.ValidateToken(newToken!); var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Contains("Designer", roles); } [Fact] public void RefreshToken_MissingClaims_ReturnsNull() { var service = CreateService(); var principal = new ClaimsPrincipal(new ClaimsIdentity()); var result = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.Null(result); } } #endregion #region WP-8: RoleMapper Tests public class RoleMapperTests : IDisposable { private readonly ScadaBridgeDbContext _context; private readonly SecurityRepository _securityRepo; private readonly RoleMapper _roleMapper; public RoleMapperTests() { var options = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; _context = new ScadaBridgeDbContext(options); _context.Database.OpenConnection(); _context.Database.EnsureCreated(); _securityRepo = new SecurityRepository(_context); _roleMapper = new RoleMapper(_securityRepo); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task MapGroupsToRoles_MultiRoleExtraction() { // Add mappings (note: seed data adds SCADA-Admins -> Administrator) _context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", Roles.Designer)); _context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", Roles.Deployer)); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" }); Assert.Contains(Roles.Administrator, result.Roles); Assert.Contains(Roles.Designer, result.Roles); Assert.DoesNotContain(Roles.Deployer, result.Roles); } [Fact] public async Task MapGroupsToRoles_SiteScopedDeployment() { var site1 = new Site("Site1", "S-001"); var site2 = new Site("Site2", "S-002"); _context.Sites.AddRange(site1, site2); _context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", Roles.Deployer)); await _context.SaveChangesAsync(); var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers"); _context.SiteScopeRules.AddRange( new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id }, new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site2.Id }); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" }); Assert.Contains(Roles.Deployer, result.Roles); Assert.False(result.IsSystemWideDeployment); Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds); Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds); } [Fact] public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide() { // Security-016: a user in BOTH an unscoped Deployer mapping // (SCADA-Deploy-All, Id=3) AND a scoped Deployer mapping // (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A // grant. The union semantics now preserve the broader grant: the // unscoped mapping wins, PermittedSiteIds is empty, system-wide. var siteA = new Site("SiteA", "S-A"); _context.Sites.Add(siteA); await _context.SaveChangesAsync(); // Mapping Id=4 (SCADA-Deploy-SiteA) is seeded; attach a scope rule for siteA. _context.SiteScopeRules.Add(new SiteScopeRule { LdapGroupMappingId = 4, SiteId = siteA.Id }); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Deploy-All", "SCADA-Deploy-SiteA" }); Assert.Contains(Roles.Deployer, result.Roles); Assert.True(result.IsSystemWideDeployment); Assert.Empty(result.PermittedSiteIds); } [Fact] public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules() { _context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", Roles.Deployer)); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" }); Assert.Contains(Roles.Deployer, result.Roles); Assert.True(result.IsSystemWideDeployment); Assert.Empty(result.PermittedSiteIds); } [Fact] public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored() { var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "NonExistentGroup", "AnotherRandom" }); Assert.Empty(result.Roles); Assert.Empty(result.PermittedSiteIds); Assert.False(result.IsSystemWideDeployment); } [Fact] public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles() { var result = await _roleMapper.MapGroupsToRolesAsync(Array.Empty()); Assert.Empty(result.Roles); } [Fact] public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch() { // "SCADA-Admins" is seeded (role canonicalized to Administrator, Task 1.7) var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" }); Assert.Contains(Roles.Administrator, result.Roles); } } #endregion #region Code Review Regression Tests /// /// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not /// marked Secure), and Security-003 (JWT signing key length never validated). /// public class SecurityReviewRegressionTests { // --- Security-003: JWT signing key length validation --- private static SecurityOptions JwtOptions(string signingKey) => new() { JwtSigningKey = signingKey, JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; [Fact] public void JwtTokenService_EmptySigningKey_ThrowsAtConstruction() { var ex = Assert.Throws(() => new JwtTokenService(Options.Create(JwtOptions("")), NullLogger.Instance)); Assert.Contains("JwtSigningKey", ex.Message); } [Fact] public void JwtTokenService_ShortSigningKey_ThrowsAtConstruction() { // 31 bytes — one short of the 256-bit HMAC-SHA256 minimum. var shortKey = new string('k', 31); var ex = Assert.Throws(() => new JwtTokenService(Options.Create(JwtOptions(shortKey)), NullLogger.Instance)); Assert.Contains("32", ex.Message); } [Fact] public void JwtTokenService_AdequateSigningKey_ConstructsSuccessfully() { var key = new string('k', 32); var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger.Instance); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); Assert.False(string.IsNullOrEmpty(token)); } // --- Security-002: authentication cookie must be marked Secure --- [Fact] public void AddSecurity_AuthCookie_IsMarkedSecureAlways() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); // Explicitly set RequireHttpsCookie=true so the test asserts SecurePolicy.Always // without relying on the SecurityOptions default value. services.Configure(o => o.RequireHttpsCookie = true); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.Equal( Microsoft.AspNetCore.Http.CookieSecurePolicy.Always, cookieOptions.Cookie.SecurePolicy); Assert.True(cookieOptions.Cookie.HttpOnly); } // --- CentralUI-005: cookie auth must use a sliding session window --- // Documented policy (CLAUDE.md Security & Auth): sliding refresh with a // 30-minute idle timeout. The cookie middleware must enable SlidingExpiration // so an active session is renewed on activity and an idle session expires. [Fact] public void AddSecurity_AuthCookie_UsesSlidingExpiration() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.True(cookieOptions.SlidingExpiration); } [Fact] public void AddSecurity_AuthCookie_ExpireTimeSpanMatchesIdleTimeout() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); // The idle timeout drives the cookie's expiry window. services.Configure(o => o.IdleTimeoutMinutes = 30); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.Equal(TimeSpan.FromMinutes(30), cookieOptions.ExpireTimeSpan); } [Fact] public void AddSecurity_AuthCookie_ExpireTimeSpanIsConfigurable() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); services.Configure(o => o.IdleTimeoutMinutes = 45); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.Equal(TimeSpan.FromMinutes(45), cookieOptions.ExpireTimeSpan); Assert.True(cookieOptions.SlidingExpiration); } // --- Configurable cookie name: two ScadaBridge environments sharing a hostname // (browser cookies are scoped by host+path, NOT port) must be able to use // distinct cookie names so signing into one does not clobber the other's session. --- [Fact] public void AddSecurity_AuthCookie_DefaultsToCanonicalName() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); // Unconfigured deployments (incl. production and the primary docker cluster) keep the // canonical name, so existing sessions survive and only an explicit override diverges. Assert.Equal(SecurityOptions.DefaultCookieName, cookieOptions.Cookie.Name); Assert.Equal("ZB.MOM.WW.ScadaBridge.Auth", cookieOptions.Cookie.Name); } [Fact] public void AddSecurity_AuthCookie_CookieNameIsConfigurable() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); // The per-environment override the docker-env2 cluster uses to avoid clobbering the // primary cluster's cookie on localhost. services.Configure(o => o.CookieName = "ZB.MOM.WW.ScadaBridge.Auth.env2"); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.Equal("ZB.MOM.WW.ScadaBridge.Auth.env2", cookieOptions.Cookie.Name); // The name override must be purely additive — it must not reset the hardened defaults. Assert.True(cookieOptions.Cookie.HttpOnly); Assert.Equal(Microsoft.AspNetCore.Http.SameSiteMode.Strict, cookieOptions.Cookie.SameSite); Assert.True(cookieOptions.SlidingExpiration); } [Theory] [InlineData("")] [InlineData(" ")] public void AddSecurity_AuthCookie_BlankCookieName_FallsBackToDefault(string blank) { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); services.Configure(o => o.CookieName = blank); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); // A blank value must never produce an unnamed cookie. Assert.Equal(SecurityOptions.DefaultCookieName, cookieOptions.Cookie.Name); } // --- Security-001: transport security (now owned by the shared LdapOptions) --- [Fact] public void LdapOptions_Transport_DefaultsToLdaps() { // The shared LdapOptions keeps the donor's secure-by-default transport. var options = new LdapOptions(); Assert.Equal(LdapTransport.Ldaps, options.Transport); } [Fact] public async Task AuthenticateAsync_StartTlsTransport_NotBlockedByInsecureGuard() { // With StartTLS selected the service is NOT rejected as insecure: it reaches the // connection stage (which fails against a dead host) and fails closed to the // system-side bucket — proving the StartTLS path is reachable, not dead code. var options = new LdapOptions { Enabled = true, Server = "nonexistent.invalid", Port = 389, Transport = LdapTransport.StartTls, SearchBase = "dc=example,dc=com", ServiceAccountDn = "cn=admin,dc=example,dc=com", ConnectionTimeoutMs = 2_000, }; var service = new LdapAuthService(options); var result = await service.AuthenticateAsync("user", "password", CancellationToken.None); // Connection fails (host invalid) — fail-closed to ServiceAccountBindFailed, NOT Disabled. Assert.False(result.Succeeded); Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure); } [Fact] public void LdapOptionsValidator_NoTlsTransport_RejectedWithoutAllowInsecure() { // The donor blocked plaintext LDAP (Transport=None) unless AllowInsecure was set. // That guard moved to the shared LdapOptionsValidator (registered with // ValidateOnStart by AddZbLdapAuth), which now fails fast at boot. var options = new LdapOptions { Server = "ldap.example.com", Port = 389, Transport = LdapTransport.None, AllowInsecure = false, SearchBase = "dc=example,dc=com", ServiceAccountDn = "cn=admin,dc=example,dc=com", }; var validator = new LdapOptionsValidator(); var result = validator.Validate(name: null, options); Assert.True(result.Failed); Assert.Contains("AllowInsecure", result.FailureMessage); } } #endregion #region Code Review Regression Tests — Security-004/005/006/007 /// /// Regression tests for Security-006 (JWT issuer/audience checks disabled) and /// Security-007 (idle-timeout claim reset on every token refresh). /// /// /// Task 1.2 cutover: the former Security-004 (uid/cn attribute parity) and /// Security-005 (no-service-account fallback-DN injection) cases tested ScadaBridge's /// bespoke LdapAuthService.BuildFallbackUserDn / EscapeLdapDn static /// helpers. The shared ZB.MOM.WW.Auth.Ldap service is search-then-bind only (no /// no-service-account fallback DN) and its RFC-4514/4515 escaping parity is covered by /// the library's own LdapEscapingTests / LdapAuthServiceTests. Those cases /// were therefore removed here rather than re-aimed. /// public class SecurityReviewRegressionTests2 { private static SecurityOptions JwtOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => new(Options.Create(options ?? JwtOptions()), NullLogger.Instance); // --- Security-006: JWT issuer/audience must be bound and validated --- [Fact] public void GenerateToken_SetsIssuerAndAudience() { var service = CreateJwtService(); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token); Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer); Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences); } [Fact] public void ValidateToken_RejectsTokenWithWrongIssuer() { // A token signed with the same key but a foreign issuer must be rejected. var options = JwtOptions(); var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey)); var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials( key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256); var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken( issuer: "some-other-system", audience: JwtTokenService.TokenAudience, claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") }, expires: DateTime.UtcNow.AddMinutes(10), signingCredentials: creds); var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign); var service = CreateJwtService(options); Assert.Null(service.ValidateToken(foreignToken)); } [Fact] public void ValidateToken_AcceptsOwnIssuerAndAudience() { var service = CreateJwtService(); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); Assert.NotNull(service.ValidateToken(token)); } // --- Security-007: refresh must preserve the original LastActivity timestamp --- [Fact] public void RefreshToken_PreservesOriginalLastActivityClaim() { var service = CreateJwtService(); // Mint a token whose LastActivity is 20 minutes in the past (still inside the // 30-minute idle window). Refresh must NOT move it forward to "now". var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.NotNull(refreshed); var refreshedPrincipal = service.ValidateToken(refreshed!); Assert.NotNull(refreshedPrincipal); var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); Assert.NotNull(claim); Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); // The carried timestamp must equal the original, not "now". Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2, $"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}"); } [Fact] public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle() { // A user idle for 25 of the 30-minute window: a refresh fired by some background // request must not make IsIdleTimedOut flip back to false forever. var service = CreateJwtService(); var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); var refreshedPrincipal = service.ValidateToken(refreshed!); // Still 25 min idle after refresh — not reset to 0. Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20, "Refresh wrongly reset the idle clock to ~now"); } [Fact] public void RecordActivity_UpdatesLastActivityToNow() { // Genuine user activity (a real request) — distinct from a token refresh — // updates LastActivity to the current time. var service = CreateJwtService(); var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); var touched = service.RecordActivity(principal, new[] { "Administrator" }, null); Assert.NotNull(touched); var touchedPrincipal = service.ValidateToken(touched!); var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated)); Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5, "RecordActivity should set LastActivity to ~now"); } } #endregion #region Code Review Regression Tests — Security-009/011 /// /// Regression tests for Security-009 (no LDAP connection timeout — a hung server can /// pin a thread-pool thread indefinitely because ct only guards work-item /// scheduling) and the remaining Security-011 coverage gaps. /// public class SecurityReviewRegressionTests3 { // --- Security-009: LDAP connection timeout must be configurable and bounded --- [Fact] public void LdapOptions_ConnectionTimeout_HasSaneDefault() { var options = new LdapOptions(); // A positive, finite default so a hung LDAP server cannot pin a thread forever // (the donor's Security-009 safeguard, preserved on the shared LdapOptions). Assert.True(options.ConnectionTimeoutMs > 0); Assert.True(options.ConnectionTimeoutMs <= 60_000, "Default LDAP connection timeout should be bounded (<= 60s)."); } [Fact] public async Task AuthenticateAsync_UnreachableHost_FailsWithinConfiguredTimeout() { // A routable-but-non-responsive address would otherwise hang for the OS default // (often minutes). With ConnectionTimeoutMs applied to the LdapConnection the call // must give up promptly. 198.51.100.0/24 (TEST-NET-2, RFC 5737) is reserved and not // routed, so the connect attempt stalls until the timeout fires. var options = new LdapOptions { Enabled = true, Server = "198.51.100.1", Port = 389, Transport = LdapTransport.None, AllowInsecure = true, SearchBase = "dc=example,dc=com", ServiceAccountDn = "cn=admin,dc=example,dc=com", ConnectionTimeoutMs = 2_000, }; var service = new LdapAuthService(options); var sw = System.Diagnostics.Stopwatch.StartNew(); var result = await service.AuthenticateAsync("user", "password", CancellationToken.None); sw.Stop(); Assert.False(result.Succeeded); // Generous ceiling: the 2s timeout plus scheduling/CI overhead, far below the // multi-minute OS default that an unconfigured connection would incur. Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30), $"Authentication did not honour the LDAP connection timeout: took {sw.Elapsed}."); } // Note: the former Security-011 no-service-account fallback-DN / DN-escape static-helper // cases (BuildFallbackUserDn / EscapeLdapDn) were removed: the shared service is // search-then-bind only (no fallback DN), and its escaping parity is covered by the // library's own LdapEscapingTests. } #endregion #region Code Review Regression Tests — Security-012/013/014/015 /// /// Regression tests for Security-014 (RefreshToken re-issues a token without /// checking the idle timeout, so an idle-expired session can be renewed indefinitely). /// /// /// Task 1.2 cutover: the former Security-013 (ExtractFirstRdnValue escaped-comma /// parsing) and Security-015 (username trim before use) cases tested ScadaBridge's /// bespoke LdapAuthService.ExtractFirstRdnValue / NormalizeUsername / /// BuildFallbackUserDn statics. Those behaviours are now owned by the shared /// library and covered by its own tests — LdapEscapingTests.FirstRdnValue_IsEscapeAware /// (escaped-comma RDN extraction) and LdapAuthServiceFailureTests /// (whitespace-username → BadCredentials, no connection) — so they were removed here. /// public class SecurityReviewRegressionTests4 { private static SecurityOptions JwtOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => new(Options.Create(options ?? JwtOptions()), NullLogger.Instance); // --- Security-014: RefreshToken must reject an idle-expired principal --- [Fact] public void RefreshToken_IdleExpiredPrincipal_ReturnsNull() { // A user idle for 31 minutes (past the 30-minute idle window). Even though the // DisplayName/Username claims are present, the refresh must be refused so an // idle-expired session cannot be renewed by a background request. var service = CreateJwtService(); var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.Null(refreshed); } [Fact] public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes() { // A user idle for 10 minutes (well inside the 30-minute window): refresh must // still succeed — the idle-timeout guard must not break the normal sliding path. var service = CreateJwtService(); var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.NotNull(refreshed); } [Fact] public void RefreshToken_MissingLastActivityClaim_ReturnsNull() { // No LastActivity claim — IsIdleTimedOut treats this as timed out, so the // refresh must be refused rather than minting a fresh session out of nothing. var service = CreateJwtService(); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user") }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.Null(refreshed); } } #endregion #region Code Review Regression Tests — Security-012 (partial LDAP outage) /// /// Security-012 (fail-closed group lookup) + the ScadaBridge-side /// failure-code→message adapter. /// /// /// /// Task 1.2 cutover: the donor's fail-closed group-lookup rule (a post-bind group /// search failure FAILS the login rather than admitting a roleless session) is now /// owned by the shared ZB.MOM.WW.Auth.Ldap service and asserted by its own /// LdapAuthServiceFailureTests.GroupLookupFailed_WhenUserHasNoGroups / /// ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows. The donor's /// bespoke BuildAuthResultFromGroupLookup / ServiceAccountBindException /// statics no longer exist, so those cases were re-aimed at the shared /// contract here. /// /// /// DEVIATION (documented): the donor admitted an "authenticated with zero groups" /// login when the group lookup SUCCEEDED but returned no groups /// (BuildAuthResultFromGroupLookup_LookupSucceededNoGroups_IsAuthenticated). /// The shared library is STRICTER and fail-closes a zero-group result to /// — see /// EmptyGroups_IsTreatedAsGroupLookupFailed_LibraryIsStricter below. The /// downstream effect (a user with no mapped groups cannot obtain a useful session) is /// unchanged: the donor's empty-group session mapped to zero roles and was denied by /// every authorization policy anyway. The empty-GROUP-set-at-the-mapper case (a user /// whose groups map to no roles) is covered by /// ScadaBridgeGroupRoleMapperTests.MapAsync_EmptyGroups_*. /// /// public class Security012GroupLookupFailureTests { [Fact] public void LdapAuthResult_Fail_GroupLookupFailed_IsFailedLoginWithNoGroups() { // The fail-closed outcome: a failed group lookup surfaces as a FAILED result with // the GroupLookupFailed code and an empty group set (never a roleless success). var result = LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed); Assert.False(result.Succeeded); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.Empty(result.Groups); } [Fact] public void LdapAuthResult_Success_CarriesGroups() { var result = LdapAuthResult.Success("alice", "Alice", new[] { "SCADA-Admins" }); Assert.True(result.Succeeded); Assert.Equal(new[] { "SCADA-Admins" }, result.Groups); Assert.Null(result.Failure); } [Fact] public void EmptyGroups_IsTreatedAsGroupLookupFailed_LibraryIsStricter() { // Documents the deviation: the shared library NEVER admits a zero-group success. // The success factory requires >=1 group at the service level; a fabricated // zero-group "success" is not a state the service produces. We pin the contract // the cutover relies on: a zero-group outcome is the GroupLookupFailed failure. var failClosed = LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed); Assert.False(failClosed.Succeeded); // And the donor's "Security-012 directory-unavailable" message is preserved for it. Assert.Equal( LdapAuthFailureMessages.DirectoryUnavailable, LdapAuthFailureMessages.ToMessage(LdapAuthFailure.GroupLookupFailed)); } [Theory] [InlineData(LdapAuthFailure.BadCredentials, LdapAuthFailureMessages.InvalidCredentials)] [InlineData(LdapAuthFailure.UserNotFound, LdapAuthFailureMessages.InvalidCredentials)] [InlineData(LdapAuthFailure.AmbiguousUser, LdapAuthFailureMessages.Misconfigured)] [InlineData(LdapAuthFailure.ServiceAccountBindFailed, LdapAuthFailureMessages.Misconfigured)] [InlineData(LdapAuthFailure.GroupLookupFailed, LdapAuthFailureMessages.DirectoryUnavailable)] [InlineData(LdapAuthFailure.Disabled, LdapAuthFailureMessages.Disabled)] public void LdapAuthFailureMessages_MapsEachFailure_ToDonorEquivalentMessage( LdapAuthFailure failure, string expected) { // Security-019 framing preserved: user-credential failures are enumeration-safe // ("Invalid username or password."), system-side faults point the OPERATOR at the // cause ("...misconfigured...") rather than blaming user input. Assert.Equal(expected, LdapAuthFailureMessages.ToMessage(failure)); } [Fact] public void LdapAuthFailureMessages_NullFailure_FallsBackToGeneric() { Assert.Equal(LdapAuthFailureMessages.Generic, LdapAuthFailureMessages.ToMessage(null)); } } #endregion #region WP-9: Authorization Policy Tests public class AuthorizationPolicyTests { [Fact] public async Task AdminPolicy_AdministratorRole_Succeeds() { var principal = CreatePrincipal(new[] { Roles.Administrator }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); Assert.True(result); } [Fact] public async Task AdminPolicy_DesignerRole_Fails() { var principal = CreatePrincipal(new[] { Roles.Designer }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); Assert.False(result); } [Fact] public async Task DesignPolicy_DesignerRole_Succeeds() { var principal = CreatePrincipal(new[] { Roles.Designer }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal); Assert.True(result); } [Fact] public async Task DeploymentPolicy_DeployerRole_Succeeds() { var principal = CreatePrincipal(new[] { Roles.Deployer }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal); Assert.True(result); } [Fact] public async Task NoRoles_DeniedAll() { var principal = CreatePrincipal(Array.Empty()); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } // ───────────────────────────────────────────────────────────────────── // Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15), // post Task 1.7 canonicalization + SoD collapse. Default mapping // (see AuthorizationPolicies XML doc): // Administrator → OperationalAudit + AuditExport // Viewer → OperationalAudit only (former AuditReadOnly home) // Designer → neither // Deployer → neither // The former distinct Audit/AuditReadOnly roles no longer exist: // Audit → collapsed into Administrator // AuditReadOnly → collapsed into Viewer // ───────────────────────────────────────────────────────────────────── [Theory] [InlineData("Administrator")] [InlineData("Viewer")] public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role) { var principal = CreatePrincipal(new[] { role }); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); } [Theory] [InlineData("Designer")] [InlineData("Deployer")] public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role) { var principal = CreatePrincipal(new[] { role }); Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); } [Theory] [InlineData("Administrator")] public async Task AuditExportPolicy_GrantedRoles_Succeed(string role) { var principal = CreatePrincipal(new[] { role }); Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } [Theory] [InlineData("Viewer")] [InlineData("Designer")] [InlineData("Deployer")] public async Task AuditExportPolicy_UngrantedRoles_Fail(string role) { var principal = CreatePrincipal(new[] { role }); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } [Fact] public async Task Viewer_ReadsAudit_ButCannotExport_PreservedHalfSoD() { // The load-bearing preserved-SoD case after the AuditReadOnly→Viewer // collapse: a Viewer satisfies OperationalAudit (read the log + nav) // but NOT AuditExport (bulk CSV exfiltration). var principal = CreatePrincipal(new[] { Roles.Viewer }); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } [Fact] public async Task FormerAuditUser_NowAdministrator_GainsExportAndFullAdmin_DocumentedEscalation() { // The documented privilege escalation: the former Audit role collapsed // into Administrator, so a former audit-only user now passes AuditExport // AND RequireAdmin (the full admin surface). var principal = CreatePrincipal(new[] { Roles.Administrator }); Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); } private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null) { var claims = new List(); foreach (var role in roles) claims.Add(new Claim(JwtTokenService.RoleClaimType, role)); if (siteIds != null) foreach (var siteId in siteIds) claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId)); return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } private static async Task EvaluatePolicy(string policyName, ClaimsPrincipal principal) { var services = new ServiceCollection(); services.AddScadaBridgeAuthorization(); services.AddLogging(); using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); var result = await authService.AuthorizeAsync(principal, null, policyName); return result.Succeeded; } } #endregion #region Code Review Regression Tests — Security-020 (LDAP fail-fast validation) /// /// Security-020: a missing/empty required LDAP field must fail fast at startup with a /// clear, field-naming message, so a typo'd appsettings section fails at boot instead /// of surfacing minutes/hours later as a generic LDAP error on the first real login. /// /// /// Task 1.2/1.4 cutover: the donor's SecurityOptionsValidator (which checked /// Security:LdapServer + Security:LdapSearchBase non-empty) was replaced by /// the shared ZB.MOM.WW.Auth.Ldap.LdapOptionsValidator, registered with /// ValidateOnStart() by AddZbLdapAuth inside AddSecurity. The shared /// validator is STRICTER — it also requires ServiceAccountDn and rejects plaintext /// transport without AllowInsecure — so the boot-time fail-fast guarantee is /// preserved and broadened. These tests pin the shared validator's behaviour and the /// ScadaBridge wiring that registers it. /// public class SecurityOptionsValidatorTests { private static LdapOptions ValidOptions() => new() { Server = "ldap.example.com", SearchBase = "dc=example,dc=com", ServiceAccountDn = "cn=admin,dc=example,dc=com", Transport = LdapTransport.Ldaps, }; [Fact] public void Validate_AllRequiredFieldsSet_Succeeds() { var result = new LdapOptionsValidator().Validate(name: null, ValidOptions()); Assert.True(result.Succeeded); } [Theory] [InlineData("")] [InlineData(" ")] public void Validate_EmptyOrWhitespaceServer_Fails(string server) { var options = ValidOptions() with { Server = server }; var result = new LdapOptionsValidator().Validate(name: null, options); Assert.True(result.Failed); Assert.Contains(nameof(LdapOptions.Server), result.FailureMessage); } [Theory] [InlineData("")] [InlineData(" ")] public void Validate_EmptyOrWhitespaceSearchBase_Fails(string searchBase) { var options = ValidOptions() with { SearchBase = searchBase }; var result = new LdapOptionsValidator().Validate(name: null, options); Assert.True(result.Failed); Assert.Contains(nameof(LdapOptions.SearchBase), result.FailureMessage); } [Fact] public void Validate_EmptyServiceAccountDn_Fails() { // Stricter than the donor: an empty ServiceAccountDn would bind anonymously and // defeat search-then-bind, so the shared validator rejects it at boot. var options = ValidOptions() with { ServiceAccountDn = string.Empty }; var result = new LdapOptionsValidator().Validate(name: null, options); Assert.True(result.Failed); Assert.Contains(nameof(LdapOptions.ServiceAccountDn), result.FailureMessage); } /// /// Verifies the security composition the Host performs at its composition root — /// AddZbLdapAuth(configuration, LdapSectionPath) followed by AddSecurity() — /// wires the shared as an /// for LdapOptions (which is what makes /// ValidateOnStart() fire). The LDAP registration moved to the Host because it is /// config-coupled; AddSecurity() is a component library and no longer takes /// IConfiguration. /// [Fact] public void AddSecurity_RegistersLdapOptionsValidator_WithValidateOnStart() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); // Mirror the Host composition order: shared LDAP auth (config-coupled) first, // then the config-free AddSecurity(). services.AddZbLdapAuth( new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ZB.MOM.WW.ScadaBridge.Security.ServiceCollectionExtensions.LdapSectionPath); services.AddSecurity(); using var provider = services.BuildServiceProvider(); // The shared validator participates in IValidateOptions — // registration is the load-bearing wiring that makes ValidateOnStart() fire. var validators = provider.GetServices>().ToList(); Assert.Contains(validators, v => v is LdapOptionsValidator); } } #endregion #region Task 1.5: Canonical claim vocabulary (ZbClaimTypes) — role/scope migration /// /// Task 1.5 (full canonical adoption): the Security module's claim-type constants now /// alias the shared vocabulary. These tests pin the contract /// that the MINTED claim type is exactly the type the policies + token validation CONSUME, /// across both the JWT bearer and the cookie principal, so a future drift fails loudly. /// public class CanonicalClaimVocabularyTests { private static SecurityOptions CreateOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; private static JwtTokenService CreateService() => new(Options.Create(CreateOptions()), NullLogger.Instance); [Fact] public void ClaimTypeConstants_AreCanonicalZbClaimTypes() { // The constants must be the canonical shared types so every mint/consume site // inherits them centrally. Assert.Equal(ZbClaimTypes.Role, JwtTokenService.RoleClaimType); Assert.Equal(ZbClaimTypes.ScopeId, JwtTokenService.SiteIdClaimType); Assert.Equal(ZbClaimTypes.DisplayName, JwtTokenService.DisplayNameClaimType); Assert.Equal(ZbClaimTypes.Username, JwtTokenService.UsernameClaimType); // Role aliases the framework URI; scope/display/username are the zb: strings. Assert.Equal(ClaimTypes.Role, JwtTokenService.RoleClaimType); Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType); Assert.Equal("zb:displayname", JwtTokenService.DisplayNameClaimType); Assert.Equal("zb:username", JwtTokenService.UsernameClaimType); // LastActivity has no canonical equivalent and is unchanged. Assert.Equal("LastActivity", JwtTokenService.LastActivityClaimType); } [Fact] public void MintedJwt_RoundTrips_CanonicalClaimTypesVerbatim() { var service = CreateService(); var token = service.GenerateToken( "Jane Roe", "janer", new[] { Roles.Administrator }, new[] { "7" }); var principal = service.ValidateToken(token); Assert.NotNull(principal); // Claim TYPES must be the canonical strings, not the framework's short JWT names // (MapInboundClaims=false / cleared outbound map guarantee this). Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.Value); Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value); Assert.Equal(Roles.Administrator, principal.FindFirst(ZbClaimTypes.Role)?.Value); Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value); } [Fact] public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy() { // The load-bearing round-trip: a JWT minted with RoleClaimType=Administrator // (post Task 1.7, the home of the former full-audit Audit role) must satisfy // a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation. var service = CreateService(); var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Administrator }, null); var principal = service.ValidateToken(token); Assert.NotNull(principal); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!)); // Administrator grants AuditExport too (it absorbed the former Audit role): Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!)); // Viewer (post Task 1.7 home of the former AuditReadOnly role) is read-only — // separate assertion that the read-not-export half-SoD survives the type // migration AND the role collapse. var roPrincipal = service.ValidateToken( service.GenerateToken("RO", "ro", new[] { Roles.Viewer }, null)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, roPrincipal!)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!)); } [Fact] public async Task CookiePrincipal_BuiltLikeLogin_AuthorizesAndExposesCanonicalTypes() { // Reproduce AuthEndpoints' cookie principal exactly: canonical claim types via the // constants + a ClaimsIdentity whose roleType is RoleClaimType so IsInRole resolves. var claims = new List { new(ClaimTypes.Name, "janer"), new(JwtTokenService.DisplayNameClaimType, "Jane Roe"), new(JwtTokenService.UsernameClaimType, "janer"), new(JwtTokenService.RoleClaimType, Roles.Administrator), new(JwtTokenService.SiteIdClaimType, "3"), }; var identity = new ClaimsIdentity( claims, authenticationType: "TestCookie", nameType: ClaimTypes.Name, roleType: JwtTokenService.RoleClaimType); var principal = new ClaimsPrincipal(identity); // Claim types are canonical. Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType); Assert.Equal("3", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value); Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name // roleType wiring => IsInRole resolves against the canonical role claim. Assert.True(principal.IsInRole(Roles.Administrator)); // Admin holds every permission by convention. Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } private static async Task EvaluatePolicy(string policyName, ClaimsPrincipal principal) { var services = new ServiceCollection(); services.AddScadaBridgeAuthorization(); services.AddLogging(); using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); var result = await authService.AuthorizeAsync(principal, null, policyName); return result.Succeeded; } } #endregion