From 38ea0c5086ba12ad0245d5cd913e0509ee6d03a7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 04:35:51 -0400 Subject: [PATCH] test(security): cookie+JWT roundtrip, role mapper, LDAP escape/RDN helpers --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../JwtTokenServiceTests.cs | 79 +++++++++++++++++++ .../LdapHelperTests.cs | 37 +++++++++ .../RoleMapperTests.cs | 50 ++++++++++++ .../ZB.MOM.WW.OtOpcUa.Security.Tests.csproj | 26 ++++++ 5 files changed, 193 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtTokenServiceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 779b37f..62fd762 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -63,6 +63,7 @@ + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtTokenServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtTokenServiceTests.cs new file mode 100644 index 0000000..9866e07 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtTokenServiceTests.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Security.Jwt; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +public sealed class JwtTokenServiceTests +{ + private const string TestKey = "this-is-a-32-byte-test-signing-key!!"; + + private static JwtTokenService NewService(string key = TestKey, int expiryMinutes = 15) => + new(Options.Create(new JwtOptions + { + SigningKey = key, + Issuer = "otopcua-test", + Audience = "otopcua-test", + ExpiryMinutes = expiryMinutes, + }), NullLogger.Instance); + + [Fact] + public void Short_signing_key_throws() + { + Should.Throw(() => NewService("too-short")); + } + + [Fact] + public void Issue_then_validate_roundtrips_claims() + { + var jwt = NewService(); + var token = jwt.Issue("Joe User", "joe", new[] { "ReadOnly", "AlarmAck" }); + + jwt.TryValidate(token, out var principal).ShouldBeTrue(); + principal.ShouldNotBeNull(); + principal!.FindFirst(JwtTokenService.UsernameClaimType)!.Value.ShouldBe("joe"); + principal.FindFirst(JwtTokenService.DisplayNameClaimType)!.Value.ShouldBe("Joe User"); + principal.FindAll(JwtTokenService.RoleClaimType) + .Select(c => c.Value).ShouldBe(new[] { "ReadOnly", "AlarmAck" }, ignoreOrder: true); + } + + [Fact] + public void Tampered_token_is_rejected() + { + var jwt = NewService(); + var token = jwt.Issue("Joe", "joe", Array.Empty()); + + // Corrupt the payload — flip a char in the middle segment. + var parts = token.Split('.'); + parts[1] = parts[1][..^2] + "AA"; + var tampered = string.Join('.', parts); + + jwt.TryValidate(tampered, out var principal).ShouldBeFalse(); + principal.ShouldBeNull(); + } + + [Fact] + public void Expired_token_is_rejected() + { + // Issue with -1 min expiry so it's already past at validate time. + var jwt = NewService(expiryMinutes: -1); + var token = jwt.Issue("Joe", "joe", Array.Empty()); + + jwt.TryValidate(token, out var principal).ShouldBeFalse(); + principal.ShouldBeNull(); + } + + [Fact] + public void Cross_key_token_is_rejected() + { + var issuer = NewService(); + var token = issuer.Issue("Joe", "joe", Array.Empty()); + + var different = NewService("a-different-32-byte-signing-key!!!"); + different.TryValidate(token, out var principal).ShouldBeFalse(); + principal.ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs new file mode 100644 index 0000000..0cd782b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs @@ -0,0 +1,37 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +public sealed class LdapHelperTests +{ + [Theory] + [InlineData("joe", "joe")] + [InlineData("jo*e", "jo\\2ae")] + [InlineData("jo(e", "jo\\28e")] + [InlineData("jo)e", "jo\\29e")] + [InlineData("jo\\e", "jo\\5ce")] + public void EscapeLdapFilter_escapes_special_chars(string input, string expected) + { + LdapAuthService.EscapeLdapFilter(input).ShouldBe(expected); + } + + [Theory] + [InlineData("cn=joe,ou=Admins,dc=lmxopcua,dc=local", "Admins")] + [InlineData("cn=alice,dc=lmxopcua,dc=local", null)] + [InlineData("ou=Admins,dc=lmxopcua,dc=local", "Admins")] + public void ExtractOuSegment_returns_first_ou(string dn, string? expected) + { + LdapAuthService.ExtractOuSegment(dn).ShouldBe(expected); + } + + [Theory] + [InlineData("cn=Admins,dc=lmxopcua,dc=local", "Admins")] + [InlineData("cn=Admins", "Admins")] + [InlineData("Admins", "Admins")] + public void ExtractFirstRdnValue_handles_full_and_short_dns(string dn, string expected) + { + LdapAuthService.ExtractFirstRdnValue(dn).ShouldBe(expected); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs new file mode 100644 index 0000000..fbeaf16 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs @@ -0,0 +1,50 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +public sealed class RoleMapperTests +{ + [Fact] + public void Empty_mapping_returns_empty() + { + RoleMapper.Map(new[] { "Admins" }, new Dictionary()) + .ShouldBeEmpty(); + } + + [Fact] + public void Maps_group_to_role() + { + RoleMapper.Map( + new[] { "AdminGroup" }, + new Dictionary { ["AdminGroup"] = "FleetAdmin" }) + .ShouldBe(new[] { "FleetAdmin" }); + } + + [Fact] + public void Case_insensitive_group_match() + { + RoleMapper.Map( + new[] { "admingroup" }, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["AdminGroup"] = "FleetAdmin", + }) + .ShouldBe(new[] { "FleetAdmin" }); + } + + [Fact] + public void Multiple_groups_dedup_roles() + { + var roles = RoleMapper.Map( + new[] { "AdminGroup", "AlsoAdmin" }, + new Dictionary + { + ["AdminGroup"] = "FleetAdmin", + ["AlsoAdmin"] = "FleetAdmin", + }); + + roles.ShouldBe(new[] { "FleetAdmin" }); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj new file mode 100644 index 0000000..0d939e8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj @@ -0,0 +1,26 @@ + + + + false + true + ZB.MOM.WW.OtOpcUa.Security.Tests + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + +