Phase 1 WP-2–10: Repositories, audit service, security & auth (LDAP, JWT, roles, policies, data protection)
- WP-2: SecurityRepository + CentralUiRepository with audit log queries - WP-3: AuditService with transactional guarantee (same SaveChangesAsync) - WP-4: Optimistic concurrency tests (deployment records vs template last-write-wins) - WP-5: Seed data (SCADA-Admins → Admin role mapping) - WP-6: LdapAuthService (direct bind, TLS enforcement, group query) - WP-7: JwtTokenService (HMAC-SHA256, 15-min refresh, 30-min idle timeout) - WP-8: RoleMapper (LDAP groups → roles with site-scoped deployment) - WP-9: Authorization policies (Admin/Design/Deployment + site scope handler) - WP-10: Shared Data Protection keys via EF Core 141 tests pass, zero warnings.
This commit is contained in:
@@ -1,10 +1,507 @@
|
||||
namespace ScadaLink.Security.Tests;
|
||||
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;
|
||||
|
||||
public class UnitTest1
|
||||
namespace ScadaLink.Security.Tests;
|
||||
|
||||
#region WP-6: LdapAuthService Tests
|
||||
|
||||
public class LdapAuthServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
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 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
|
||||
|
||||
Reference in New Issue
Block a user