fix(host): resolve Host-005..011 — async startup, HOCON escaping, port-conflict check, dead-config cleanup, migration retry, log-level wiring; Host-002 flagged

This commit is contained in:
Joseph Doherty
2026-05-16 22:24:03 -04:00
parent 3f19371017
commit 8664cdf940
14 changed files with 614 additions and 99 deletions

View File

@@ -0,0 +1,822 @@
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 ScadaLink.Commons.Entities.Security;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.Security;
namespace ScadaLink.Security.Tests;
#region WP-6: LdapAuthService Tests
public class LdapAuthServiceTests
{
private static SecurityOptions CreateOptions(bool useTls = true, bool allowInsecure = false) => new()
{
LdapServer = "ldap.example.com",
LdapPort = 636,
LdapUseTls = useTls,
AllowInsecureLdap = allowInsecure,
LdapSearchBase = "dc=example,dc=com",
JwtSigningKey = "test-key-that-is-long-enough-for-hmac-sha256-minimum"
};
[Fact]
public async Task AuthenticateAsync_EmptyUsername_ReturnsFailed()
{
var service = new LdapAuthService(
Options.Create(CreateOptions()),
NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync("", "password");
Assert.False(result.Success);
Assert.Contains("Username is required", result.ErrorMessage);
}
[Fact]
public async Task AuthenticateAsync_EmptyPassword_ReturnsFailed()
{
var service = new LdapAuthService(
Options.Create(CreateOptions()),
NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync("user", "");
Assert.False(result.Success);
Assert.Contains("Password is required", result.ErrorMessage);
}
[Fact]
public async Task AuthenticateAsync_InsecureLdapNotAllowed_ReturnsFailed()
{
var service = new LdapAuthService(
Options.Create(CreateOptions(useTls: false, allowInsecure: false)),
NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync("user", "password");
Assert.False(result.Success);
Assert.Contains("Insecure LDAP", result.ErrorMessage);
}
[Fact]
public async Task AuthenticateAsync_ConnectionFailure_ReturnsFailed()
{
// Point to a non-existent server — connection should fail
var options = CreateOptions();
options.LdapServer = "nonexistent.invalid";
options.LdapPort = 9999;
var service = new LdapAuthService(
Options.Create(options),
NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync("user", "password");
Assert.False(result.Success);
}
}
#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<JwtTokenService>.Instance);
}
[Fact]
public void GenerateToken_ContainsCorrectClaims()
{
var service = CreateService();
var token = service.GenerateToken(
"John Doe", "johnd",
new[] { "Admin", "Design" },
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("Admin", roles);
Assert.Contains("Design", 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, null);
var principal = service.ValidateToken(originalToken);
var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" });
Assert.NotNull(newToken);
var newPrincipal = service.ValidateToken(newToken!);
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
Assert.Contains("Design", roles);
}
[Fact]
public void RefreshToken_MissingClaims_ReturnsNull()
{
var service = CreateService();
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var result = service.RefreshToken(principal, new[] { "Admin" }, null);
Assert.Null(result);
}
}
#endregion
#region WP-8: RoleMapper Tests
public class RoleMapperTests : IDisposable
{
private readonly ScadaLinkDbContext _context;
private readonly SecurityRepository _securityRepo;
private readonly RoleMapper _roleMapper;
public RoleMapperTests()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlite("DataSource=:memory:")
.Options;
_context = new ScadaLinkDbContext(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 -> Admin)
_context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design"));
_context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment"));
await _context.SaveChangesAsync();
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" });
Assert.Contains("Admin", result.Roles);
Assert.Contains("Design", result.Roles);
Assert.DoesNotContain("Deployment", 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", "Deployment"));
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("Deployment", 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_SystemWideDeployment_NoScopeRules()
{
_context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment"));
await _context.SaveChangesAsync();
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" });
Assert.Contains("Deployment", 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<string>());
Assert.Empty(result.Roles);
}
[Fact]
public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch()
{
// "SCADA-Admins" is seeded
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" });
Assert.Contains("Admin", result.Roles);
}
}
#endregion
#region Code Review Regression Tests
/// <summary>
/// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not
/// marked Secure), and Security-003 (JWT signing key length never validated).
/// </summary>
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<InvalidOperationException>(() =>
new JwtTokenService(Options.Create(JwtOptions("")), NullLogger<JwtTokenService>.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<InvalidOperationException>(() =>
new JwtTokenService(Options.Create(JwtOptions(shortKey)), NullLogger<JwtTokenService>.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<JwtTokenService>.Instance);
var token = service.GenerateToken("User", "user", new[] { "Admin" }, 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();
using var provider = services.BuildServiceProvider();
var cookieOptions = provider
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(
Microsoft.AspNetCore.Http.CookieSecurePolicy.Always,
cookieOptions.Cookie.SecurePolicy);
Assert.True(cookieOptions.Cookie.HttpOnly);
}
// --- Security-001: StartTLS transport must be reachable ---
[Fact]
public void SecurityOptions_LdapTransport_DefaultsToLdaps()
{
var options = new SecurityOptions();
Assert.Equal(LdapTransport.Ldaps, options.LdapTransport);
}
[Fact]
public async Task AuthenticateAsync_StartTlsTransport_AttemptsConnection()
{
// With StartTLS selected the service must not be blocked by the insecure-LDAP
// guard and must reach the connection stage (which fails against a dead host).
// This proves the StartTLS path is reachable rather than dead code.
var options = new SecurityOptions
{
LdapServer = "nonexistent.invalid",
LdapPort = 389,
LdapTransport = LdapTransport.StartTls,
LdapSearchBase = "dc=example,dc=com"
};
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync("user", "password");
// Connection fails (host invalid) — but crucially NOT with the insecure-LDAP message.
Assert.False(result.Success);
Assert.DoesNotContain("Insecure LDAP", result.ErrorMessage ?? string.Empty);
}
[Fact]
public async Task AuthenticateAsync_NoTlsTransport_RejectedWithoutAllowInsecure()
{
var options = new SecurityOptions
{
LdapServer = "ldap.example.com",
LdapPort = 389,
LdapTransport = LdapTransport.None,
AllowInsecureLdap = false
};
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync("user", "password");
Assert.False(result.Success);
Assert.Contains("Insecure LDAP", result.ErrorMessage);
}
}
#endregion
#region Code Review Regression Tests Security-004/005/006/007
/// <summary>
/// Regression tests for Security-004 (uid/cn attribute mismatch between search filter
/// and fallback DN), Security-005 (DN injection in the no-service-account fallback),
/// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout
/// claim reset on every token refresh).
/// </summary>
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<JwtTokenService>.Instance);
// --- Security-004: search filter and fallback DN must use the same attribute ---
[Fact]
public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute()
{
// The default user-id attribute is "uid"; the fallback DN must use it,
// not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable.
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid");
Assert.Equal("uid=alice,dc=example,dc=com", dn);
}
[Fact]
public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute()
{
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName");
Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn);
}
[Fact]
public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid()
{
Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute);
}
// --- Security-005: DN-component escaping must be applied to the username ---
[Fact]
public void BuildFallbackUserDn_EscapesDnMetacharacters()
{
// A hostile username must not be able to alter the DN structure: the comma
// that would otherwise start a new RDN ("ou=admins") must be escaped so the
// whole string remains a single RDN value.
var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid");
Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn);
// The comma from the username is backslash-escaped, so it does not act as an
// RDN separator: the only unescaped comma is the one joining RDN and base DN.
Assert.Contains(@"victim\,ou=admins", dn);
}
[Fact]
public void EscapeLdapDn_EscapesAllRfc4514Specials()
{
var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\e<f>g;h");
Assert.Equal(@"a\,b\+c\""d\\e\<f\>g\;h", escaped);
}
[Fact]
public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces()
{
Assert.Equal(@"\ x \ ", LdapAuthService.EscapeLdapDn(" x "));
}
// --- 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, 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[] { "Admin" }, 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 WP-9: Authorization Policy Tests
public class AuthorizationPolicyTests
{
[Fact]
public async Task AdminPolicy_AdminRole_Succeeds()
{
var principal = CreatePrincipal(new[] { "Admin" });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
Assert.True(result);
}
[Fact]
public async Task AdminPolicy_DesignRole_Fails()
{
var principal = CreatePrincipal(new[] { "Design" });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
Assert.False(result);
}
[Fact]
public async Task DesignPolicy_DesignRole_Succeeds()
{
var principal = CreatePrincipal(new[] { "Design" });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal);
Assert.True(result);
}
[Fact]
public async Task DeploymentPolicy_DeploymentRole_Succeeds()
{
var principal = CreatePrincipal(new[] { "Deployment" });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal);
Assert.True(result);
}
[Fact]
public async Task NoRoles_DeniedAll()
{
var principal = CreatePrincipal(Array.Empty<string>());
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal));
}
[Fact]
public async Task SiteScope_SystemWideDeployer_Succeeds()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment")
// No SiteId claims = system-wide
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("42");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_PermittedSite_Succeeds()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment"),
new(JwtTokenService.SiteIdClaimType, "1"),
new(JwtTokenService.SiteIdClaimType, "2")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("1");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_UnpermittedSite_Fails()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment"),
new(JwtTokenService.SiteIdClaimType, "1")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("99");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_NoDeploymentRole_Fails()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Admin")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("1");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
{
var claims = new List<Claim>();
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<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
{
var services = new ServiceCollection();
services.AddScadaLinkAuthorization();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>();
var result = await authService.AuthorizeAsync(principal, null, policyName);
return result.Succeeded;
}
}
#endregion