diff --git a/tests/ScadaLink.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs b/tests/ScadaLink.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs index 538cbfa..04a90ba 100644 --- a/tests/ScadaLink.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs +++ b/tests/ScadaLink.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs @@ -197,4 +197,207 @@ public class OpcUaEndpointConfigSerializerTests Assert.NotNull(config.Heartbeat); Assert.Equal("Hb", config.Heartbeat!.TagPath); } + + // ── Layer A/B extensions: subscription tuning, auth, deadband ── + + [Fact] + public void Serialize_RoundtripsNewSubscriptionScalars() + { + var original = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://x:4840", + DiscardOldest = false, + SubscriptionPriority = 200, + SubscriptionDisplayName = "ScadaLink-Primary", + TimestampsToReturn = OpcUaTimestampsToReturn.Both + }; + + var json = OpcUaEndpointConfigSerializer.Serialize(original); + var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json); + + Assert.False(isLegacy); + Assert.False(round.DiscardOldest); + Assert.Equal((byte)200, round.SubscriptionPriority); + Assert.Equal("ScadaLink-Primary", round.SubscriptionDisplayName); + Assert.Equal(OpcUaTimestampsToReturn.Both, round.TimestampsToReturn); + } + + [Fact] + public void Serialize_RoundtripsDeadband() + { + var original = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://x:4840", + Deadband = new OpcUaDeadbandConfig + { + Type = OpcUaDeadbandType.Percent, + Value = 2.5 + } + }; + + var json = OpcUaEndpointConfigSerializer.Serialize(original); + var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json); + + Assert.NotNull(round.Deadband); + Assert.Equal(OpcUaDeadbandType.Percent, round.Deadband!.Type); + Assert.Equal(2.5, round.Deadband.Value); + } + + [Theory] + [InlineData(OpcUaUserTokenType.UsernamePassword, "user1", "pass1", "", "")] + [InlineData(OpcUaUserTokenType.X509Certificate, "", "", "/etc/pki/client.pfx", "pfxpass")] + [InlineData(OpcUaUserTokenType.Anonymous, "", "", "", "")] + public void Serialize_RoundtripsUserIdentity( + OpcUaUserTokenType tokenType, string user, string pass, string certPath, string certPass) + { + var original = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://x:4840", + UserIdentity = new OpcUaUserIdentityConfig + { + TokenType = tokenType, + Username = user, + Password = pass, + CertificatePath = certPath, + CertificatePassword = certPass + } + }; + + var json = OpcUaEndpointConfigSerializer.Serialize(original); + var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json); + + Assert.NotNull(round.UserIdentity); + Assert.Equal(tokenType, round.UserIdentity!.TokenType); + Assert.Equal(user, round.UserIdentity.Username); + Assert.Equal(pass, round.UserIdentity.Password); + Assert.Equal(certPath, round.UserIdentity.CertificatePath); + Assert.Equal(certPass, round.UserIdentity.CertificatePassword); + } + + [Fact] + public void Serialize_NullUserIdentityAndDeadband_OmittedFromTypedJson() + { + // Default config: UserIdentity and Deadband are null. Roundtrip should + // preserve nulls (anonymous = no auth needed in flattened JSON either). + var original = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" }; + var json = OpcUaEndpointConfigSerializer.Serialize(original); + var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json); + + Assert.Null(round.UserIdentity); + Assert.Null(round.Deadband); + } + + [Fact] + public void ToFlatDict_IncludesNewScalars() + { + var config = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://x:4840", + DiscardOldest = false, + SubscriptionPriority = 50, + SubscriptionDisplayName = "ScadaLink-Edge", + TimestampsToReturn = OpcUaTimestampsToReturn.Server + }; + var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config); + + Assert.Equal("False", dict["DiscardOldest"]); + Assert.Equal("50", dict["SubscriptionPriority"]); + Assert.Equal("ScadaLink-Edge", dict["SubscriptionDisplayName"]); + Assert.Equal("Server", dict["TimestampsToReturn"]); + } + + [Fact] + public void ToFlatDict_OmitsNullUserIdentityAndDeadband() + { + var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" }; + var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config); + + Assert.False(dict.ContainsKey("UserIdentity.TokenType")); + Assert.False(dict.ContainsKey("UserIdentity.Username")); + Assert.False(dict.ContainsKey("Deadband.Type")); + Assert.False(dict.ContainsKey("Deadband.Value")); + } + + [Fact] + public void ToFlatDict_IncludesUserIdentity_WhenSet() + { + var config = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://x:4840", + UserIdentity = new OpcUaUserIdentityConfig + { + TokenType = OpcUaUserTokenType.UsernamePassword, + Username = "alice", + Password = "secret" + } + }; + var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config); + + Assert.Equal("UsernamePassword", dict["UserIdentity.TokenType"]); + Assert.Equal("alice", dict["UserIdentity.Username"]); + Assert.Equal("secret", dict["UserIdentity.Password"]); + } + + [Fact] + public void ToFlatDict_IncludesDeadband_WhenSet() + { + var config = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://x:4840", + Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 } + }; + var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config); + + Assert.Equal("Percent", dict["Deadband.Type"]); + Assert.Equal("1.5", dict["Deadband.Value"]); + } + + [Fact] + public void FromFlatDict_MaterializesUserIdentity() + { + var dict = new Dictionary + { + ["endpoint"] = "opc.tcp://x:4840", + ["UserIdentity.TokenType"] = "UsernamePassword", + ["UserIdentity.Username"] = "bob", + ["UserIdentity.Password"] = "hunter2" + }; + var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict); + + Assert.NotNull(config.UserIdentity); + Assert.Equal(OpcUaUserTokenType.UsernamePassword, config.UserIdentity!.TokenType); + Assert.Equal("bob", config.UserIdentity.Username); + Assert.Equal("hunter2", config.UserIdentity.Password); + } + + [Fact] + public void FromFlatDict_MaterializesDeadband() + { + var dict = new Dictionary + { + ["endpoint"] = "opc.tcp://x:4840", + ["Deadband.Type"] = "Absolute", + ["Deadband.Value"] = "0.25" + }; + var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict); + + Assert.NotNull(config.Deadband); + Assert.Equal(OpcUaDeadbandType.Absolute, config.Deadband!.Type); + Assert.Equal(0.25, config.Deadband.Value); + } + + [Fact] + public void FromFlatDict_AnonymousTokenTypeStillMaterializesUserIdentity() + { + // Explicit Anonymous TokenType (different from "missing") materializes the sub-object. + var dict = new Dictionary + { + ["endpoint"] = "opc.tcp://x:4840", + ["UserIdentity.TokenType"] = "Anonymous" + }; + var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict); + + Assert.NotNull(config.UserIdentity); + Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType); + } } diff --git a/tests/ScadaLink.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs b/tests/ScadaLink.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs index 023704b..10ddcb0 100644 --- a/tests/ScadaLink.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs +++ b/tests/ScadaLink.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs @@ -109,4 +109,96 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "Primary.EndpointUrl"); Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize"); } + + // ── Layer B extensions: auth, deadband, subscription display name ── + + [Fact] + public void Validate_EmptySubscriptionDisplayName_Fails() + { + var c = Valid(); + c.SubscriptionDisplayName = ""; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.Contains(r.Errors, e => e.EntityName == "SubscriptionDisplayName"); + } + + [Fact] + public void Validate_UserIdentityAnonymous_NoExtraFieldsRequired() + { + var c = Valid(); + c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.Anonymous }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.True(r.IsValid); + } + + [Fact] + public void Validate_UsernamePasswordWithoutUsername_Fails() + { + var c = Valid(); + c.UserIdentity = new OpcUaUserIdentityConfig + { + TokenType = OpcUaUserTokenType.UsernamePassword, + Username = "", + Password = "secret" + }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.Username"); + } + + [Fact] + public void Validate_X509WithoutCertificatePath_Fails() + { + var c = Valid(); + c.UserIdentity = new OpcUaUserIdentityConfig + { + TokenType = OpcUaUserTokenType.X509Certificate, + CertificatePath = "" + }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.CertificatePath"); + } + + [Fact] + public void Validate_UsernamePasswordWithUsername_Passes() + { + var c = Valid(); + c.UserIdentity = new OpcUaUserIdentityConfig + { + TokenType = OpcUaUserTokenType.UsernamePassword, + Username = "alice", + Password = "" + }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.True(r.IsValid); + } + + [Fact] + public void Validate_DeadbandWithNonPositiveValue_Fails() + { + var c = Valid(); + c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Absolute, Value = 0 }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.Contains(r.Errors, e => e.EntityName == "Deadband.Value"); + } + + [Fact] + public void Validate_DeadbandWithPositiveValue_Passes() + { + var c = Valid(); + c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.True(r.IsValid); + } + + [Fact] + public void Validate_UserIdentityErrorsPrefixed_WithFieldPrefix() + { + var c = Valid(); + c.UserIdentity = new OpcUaUserIdentityConfig + { + TokenType = OpcUaUserTokenType.UsernamePassword, + Username = "" + }; + var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary."); + Assert.Contains(r.Errors, e => e.EntityName == "Primary.UserIdentity.Username"); + } }