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
+
+
+
+
+
+
+
+
+