feat(dcl): move the OPC UA client to OPC Foundation SDK 2.0.1 (#125)
OPCFoundation.NetStandard.Opc.Ua.Client and the test-only .Server move 1.5.378.182 -> 2.0.1, the version OtOpcUa master runs (lmxopcua#782; #799 and #801 closed). Transitive Core/Core.Types/Types/Configuration/ Security.Certificates resolve to 2.0.1. API breaks fixed in the DCL adapter: - Built-in types are immutable structs (Variant, DataValue, NodeId, LocalizedText, StatusCode, ArrayOf<T>). New OpcUaValueBoundary restores the SDK 1.5 CLR shapes for every read, subscription and alarm-event value (byte[], UTC DateTime with no-timestamp = MinValue, Guid, detached arrays) and builds write variants with SDK 1.5's new Variant(object) semantics (byte[] = ByteString scalar). - StatusCodes members are no longer constants: switches/patterns over them (verify-outcome mapping, connection-level classification, BadResourceUnavailable) match by value. - CertificateIdentifier no longer caches a certificate and the CertificateValidator/CertificateValidation event is gone: the configuration's CertificateManager owns trust lists and the loaded application certificate and is disposed with the session; auto-accept and the verify probe's capture-and-reject use AcceptError. - new UserIdentity(X509Certificate2) is gone: X509 user identities use X509IdentityTokenHandler with an on-demand PFX provider (still fails fast on a missing file/key). - DefaultSessionFactory takes the telemetry context; collections are ArrayOf<T>; EventFilter select clauses and ContentFilter operands are built immutably. UserName identity (lmxopcua#880): SDK 2's default handler sends a password over 64 bytes - every 65-character OtOpcUa API key - as an RsaEncryptedSecret that SDK 1.5 servers reject with BadIdentityTokenInvalid and SDK 2 servers accept only within a 5-minute clock window (#873). The DCL now always uses LegacyRsaUserNameIdentityTokenHandler (ported from OtOpcUa be28aa44), i.e. exactly what it sent on SDK 1.5, so it logs in to both server generations during a mixed rollout. Tests: the in-process secured test server is ported to SDK 2 and can admit an API key; new tests cover the handler (mutation-checked: 5 of 7 fail with the SDK default handler), the value boundary, and a 65-char key end to end over None/Sign/SignAndEncrypt with a wrong key reaching the key check (BadIdentityTokenRejected). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
+3
-3
@@ -40,7 +40,7 @@ public class OpcUaNodeReferenceTests
|
||||
var nodeId = OpcUaNodeReference.Resolve("ns=2;s=pymodbus/plc/HR200", V3Table());
|
||||
|
||||
Assert.Equal(2, nodeId.NamespaceIndex);
|
||||
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
|
||||
Assert.Equal("pymodbus/plc/HR200", nodeId.IdentifierAsString);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the namespace URI form resolves to the server's live namespace index.</summary>
|
||||
@@ -50,7 +50,7 @@ public class OpcUaNodeReferenceTests
|
||||
var nodeId = OpcUaNodeReference.Resolve($"nsu={RawUri};s=pymodbus/plc/HR200", V3Table());
|
||||
|
||||
Assert.Equal(2, nodeId.NamespaceIndex);
|
||||
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
|
||||
Assert.Equal("pymodbus/plc/HR200", nodeId.IdentifierAsString);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the namespace URI form tracks the index across servers with different namespace tables.</summary>
|
||||
@@ -159,7 +159,7 @@ public class OpcUaNodeReferenceTests
|
||||
var resolved = OpcUaNodeReference.Resolve(durable, table);
|
||||
|
||||
Assert.Equal(3, resolved.NamespaceIndex);
|
||||
Assert.Equal("Area/Line/Equip/Speed", resolved.Identifier);
|
||||
Assert.Equal("Area/Line/Equip/Speed", resolved.IdentifierAsString);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a URI missing from the namespace table falls back instead of throwing.</summary>
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ public class OpcUaSecurityDowngradeTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => RealOpcUaClient.DiscoverEndpointAsync("opc.tcp://test:4840",
|
||||
MessageSecurityMode.SignAndEncrypt, _ => discoveryFails
|
||||
? throw new Exception("discovery unavailable")
|
||||
: Task.FromResult(new EndpointDescriptionCollection { new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None } })));
|
||||
: Task.FromResult<IReadOnlyList<EndpointDescription>>([new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None }])));
|
||||
}
|
||||
/// <summary>Verifies that DC4: exact requested mode is selected.</summary>
|
||||
[Fact]
|
||||
@@ -21,6 +21,6 @@ public class OpcUaSecurityDowngradeTests
|
||||
{
|
||||
var encrypted = new EndpointDescription("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.SignAndEncrypt };
|
||||
Assert.Same(encrypted, await RealOpcUaClient.DiscoverEndpointAsync("opc.tcp://test:4840", MessageSecurityMode.SignAndEncrypt,
|
||||
_ => Task.FromResult(new EndpointDescriptionCollection { new("opc.tcp://test:4840"), encrypted })));
|
||||
_ => Task.FromResult<IReadOnlyList<EndpointDescription>>([new("opc.tcp://test:4840"), encrypted])));
|
||||
}
|
||||
}
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#125 / lmxopcua#880: under SDK 2.0.1 a DCL UserName login must still send the
|
||||
/// password in the legacy RSA format, whatever its length. SDK 2's own handler sends a password
|
||||
/// over 64 bytes, such as a 65-character OtOpcUa API key, as an <c>RsaEncryptedSecret</c> with a
|
||||
/// null EncryptionAlgorithm, which an SDK 1.5.378 server rejects with <c>BadIdentityTokenInvalid</c>
|
||||
/// ("Unexpected encryption algorithm"): under an RSA policy it accepts only the URIs below.
|
||||
/// </summary>
|
||||
public sealed class OpcUaUserIdentitiesTests
|
||||
{
|
||||
// What SDK 1.5.378's SecurityPolicies.Decrypt demands, per policy, before it decrypts a password.
|
||||
private const string RsaOaep = "http://www.w3.org/2001/04/xmlenc#rsa-oaep";
|
||||
private const string RsaOaepSha256 = "http://opcfoundation.org/UA/security/rsa-oaep-sha2-256";
|
||||
|
||||
/// <summary>The OtOpcUa <c>ApiKeySecret.Generate</c> shape: otua_ + 16 hex + _ + 43 base64url = 65.</summary>
|
||||
internal static string ProductionLengthKey()
|
||||
{
|
||||
var key = $"otua_{Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant()}_"
|
||||
+ Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
Assert.Equal(65, key.Length);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static Certificate ServerCertificate() =>
|
||||
CertificateBuilder.Create("CN=ScadaBridge#125 receiver").SetRSAKeySize(2048).CreateForRSA();
|
||||
|
||||
private static async Task<UserNameIdentityToken> EncryptAsync(
|
||||
IUserIdentityTokenHandler handler, Certificate cert, string policyUri, byte[] serverNonce)
|
||||
{
|
||||
var context = ServiceMessageContext.CreateEmpty(DefaultTelemetry.Create(_ => { }));
|
||||
await handler.EncryptAsync(cert, serverNonce, policyUri, context);
|
||||
return Assert.IsType<UserNameIdentityToken>(handler.Token);
|
||||
}
|
||||
|
||||
// The unmodified SDK 2 server path: what an SDK 2 OtOpcUa server does with the token on ActivateSession.
|
||||
private static async Task<byte[]?> ServerDecryptAsync(
|
||||
UserNameIdentityToken token, Certificate cert, string policyUri, byte[] serverNonce)
|
||||
{
|
||||
var context = ServiceMessageContext.CreateEmpty(DefaultTelemetry.Create(_ => { }));
|
||||
var policy = SecurityPolicies.Default.GetInfo(policyUri)!;
|
||||
var server = new UserNameIdentityTokenHandler(token);
|
||||
await server.DecryptAsync(cert, Nonce.CreateNonce(policy, serverNonce), policyUri, context);
|
||||
return server.DecryptedPassword;
|
||||
}
|
||||
|
||||
private static byte[] ServerNonce(string policyUri) =>
|
||||
Nonce.CreateNonce(SecurityPolicies.Default.GetInfo(policyUri)!.SecureChannelNonceLength).Data!;
|
||||
|
||||
/// <summary>A production-length API key travels in the format an SDK 1.5 server decrypts, and an SDK 2 server still reads it.</summary>
|
||||
[Theory]
|
||||
[InlineData(SecurityPolicies.Basic256Sha256, RsaOaep)]
|
||||
[InlineData(SecurityPolicies.Aes128_Sha256_RsaOaep, RsaOaep)]
|
||||
[InlineData(SecurityPolicies.Aes256_Sha256_RsaPss, RsaOaepSha256)]
|
||||
public async Task ProductionLengthApiKey_IsSentInTheLegacyFormat(string policyUri, string sdk15RequiredAlgorithm)
|
||||
{
|
||||
using var cert = ServerCertificate();
|
||||
var key = ProductionLengthKey();
|
||||
var nonce = ServerNonce(policyUri);
|
||||
var identity = OpcUaUserIdentities.Build(
|
||||
new OpcUaUserIdentityOptions("UsernamePassword", "apikey", key, null!, null!))!;
|
||||
|
||||
var token = await EncryptAsync(identity.TokenHandler, cert, policyUri, nonce);
|
||||
|
||||
Assert.Equal("apikey", token.UserName);
|
||||
// An SDK 1.5 server rejects any other algorithm URI (null is RsaEncryptedSecret) as BadIdentityTokenInvalid.
|
||||
Assert.Equal(sdk15RequiredAlgorithm, token.EncryptionAlgorithm);
|
||||
Assert.NotEqual(Encoding.UTF8.GetBytes(key), token.Password.ToArray());
|
||||
Assert.Equal(Encoding.UTF8.GetBytes(key), await ServerDecryptAsync(token, cert, policyUri, nonce));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Control: pins the SDK behaviour the handler exists for. If a later SDK stops choosing
|
||||
/// RsaEncryptedSecret for a long password, this fails and the workaround can be revisited.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Control_TheSdkDefaultHandler_SendsRsaEncryptedSecretAbove64Bytes()
|
||||
{
|
||||
using var cert = ServerCertificate();
|
||||
var key = ProductionLengthKey();
|
||||
var nonce = ServerNonce(SecurityPolicies.Basic256Sha256);
|
||||
|
||||
var longToken = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key)).TokenHandler,
|
||||
cert, SecurityPolicies.Basic256Sha256, nonce);
|
||||
Assert.Null(longToken.EncryptionAlgorithm);
|
||||
|
||||
var shortToken = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key[..64])).TokenHandler,
|
||||
cert, SecurityPolicies.Basic256Sha256, nonce);
|
||||
Assert.Equal(RsaOaep, shortToken.EncryptionAlgorithm);
|
||||
}
|
||||
|
||||
/// <summary>Policy None is handed to the SDK handler unchanged.</summary>
|
||||
[Fact]
|
||||
public async Task PolicyNone_IsLeftToTheSdk()
|
||||
{
|
||||
using var cert = ServerCertificate();
|
||||
var key = ProductionLengthKey();
|
||||
|
||||
var ours = await EncryptAsync(OpcUaUserIdentities.UserName("apikey", key).TokenHandler, cert,
|
||||
SecurityPolicies.None, []);
|
||||
var sdk = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key)).TokenHandler, cert,
|
||||
SecurityPolicies.None, []);
|
||||
|
||||
Assert.Equal(sdk.EncryptionAlgorithm, ours.EncryptionAlgorithm);
|
||||
Assert.Equal(sdk.Password.ToArray(), ours.Password.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>The SDK session encrypts <c>TokenHandler.Copy()</c>, so the copy must keep the legacy type.</summary>
|
||||
[Fact]
|
||||
public async Task TheSessionCopy_KeepsTheLegacyFormat()
|
||||
{
|
||||
using var cert = ServerCertificate();
|
||||
var identity = OpcUaUserIdentities.UserName("apikey", ProductionLengthKey());
|
||||
|
||||
var copy = identity.TokenHandler.Copy();
|
||||
|
||||
Assert.IsType<LegacyRsaUserNameIdentityTokenHandler>(copy);
|
||||
Assert.NotSame(identity.TokenHandler, copy);
|
||||
Assert.True(copy.Equals(identity.TokenHandler));
|
||||
var token = await EncryptAsync(copy, cert, SecurityPolicies.Basic256Sha256,
|
||||
ServerNonce(SecurityPolicies.Basic256Sha256));
|
||||
Assert.Equal(RsaOaep, token.EncryptionAlgorithm);
|
||||
}
|
||||
|
||||
/// <summary>Token-type mapping: UserName uses the legacy handler, anonymous/unknown map to no identity.</summary>
|
||||
[Fact]
|
||||
public void Build_MapsTheConfiguredTokenType()
|
||||
{
|
||||
var userName = OpcUaUserIdentities.Build(
|
||||
new OpcUaUserIdentityOptions("usernamepassword", "apikey", "k", null!, null!))!;
|
||||
Assert.Equal("apikey", Assert.IsType<LegacyRsaUserNameIdentityTokenHandler>(userName.TokenHandler).UserName);
|
||||
Assert.Equal(UserTokenType.UserName, userName.TokenType);
|
||||
|
||||
Assert.Null(OpcUaUserIdentities.Build(null));
|
||||
Assert.Null(OpcUaUserIdentities.Build(new OpcUaUserIdentityOptions("Anonymous", null!, null!, null!, null!)));
|
||||
}
|
||||
|
||||
/// <summary>An X509 identity still fails fast on a missing PFX, before any session is created.</summary>
|
||||
[Fact]
|
||||
public void X509_MissingFile_FailsBeforeConnect()
|
||||
{
|
||||
var missing = Path.Combine(Path.GetTempPath(), $"sb125-missing-{Guid.NewGuid():N}.pfx");
|
||||
Assert.ThrowsAny<Exception>(() => OpcUaUserIdentities.Build(
|
||||
new OpcUaUserIdentityOptions("X509Certificate", null!, null!, missing, null!)));
|
||||
}
|
||||
|
||||
/// <summary>An X509 identity is built from a PFX with a private key and carries its thumbprint.</summary>
|
||||
[Fact]
|
||||
public void X509_FromPfx_BuildsACertificateIdentity()
|
||||
{
|
||||
using var cert = CertificateBuilder.Create("CN=ScadaBridge#125 user").SetRSAKeySize(2048).CreateForRSA();
|
||||
var path = Path.Combine(Path.GetTempPath(), $"sb125-user-{Guid.NewGuid():N}.pfx");
|
||||
File.WriteAllBytes(path, cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Pfx, "pw"));
|
||||
try
|
||||
{
|
||||
var identity = OpcUaUserIdentities.Build(
|
||||
new OpcUaUserIdentityOptions("X509Certificate", null!, null!, path, "pw"))!;
|
||||
Assert.Equal(UserTokenType.Certificate, identity.TokenType);
|
||||
Assert.IsType<X509IdentityTokenHandler>(identity.TokenHandler);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#125: SDK 2's legacy boxing hands back SDK wrapper types where SDK 1.5 handed back
|
||||
/// CLR ones. The DCL boundary must restore the SDK 1.5 payload shapes the codec, scripts and the
|
||||
/// alarm mapper were written against, and must not alias SDK buffers.
|
||||
/// </summary>
|
||||
public class OpcUaValueBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Scalars_KeepTheirClrTypes()
|
||||
{
|
||||
Assert.Equal(42, OpcUaValueBoundary.ToClr(Variant.From(42)));
|
||||
Assert.Equal(42.5, OpcUaValueBoundary.ToClr(Variant.From(42.5)));
|
||||
Assert.Equal(true, OpcUaValueBoundary.ToClr(Variant.From(true)));
|
||||
Assert.Equal("abc", OpcUaValueBoundary.ToClr(Variant.From("abc")));
|
||||
Assert.Equal((ushort)500, OpcUaValueBoundary.ToClr(Variant.From((ushort)500)));
|
||||
Assert.Null(OpcUaValueBoundary.ToClr(Variant.Null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteString_BecomesAByteArray()
|
||||
{
|
||||
var value = OpcUaValueBoundary.ToClr(Variant.From(ByteString.From(new byte[] { 1, 2, 3 })));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, Assert.IsType<byte[]>(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_BecomesAUtcDateTime_AndNoTimestampIsMinValue()
|
||||
{
|
||||
var when = new DateTime(2026, 11, 12, 10, 30, 0, DateTimeKind.Utc);
|
||||
var value = Assert.IsType<DateTime>(OpcUaValueBoundary.ToClr(Variant.From(when)));
|
||||
Assert.Equal(when, value);
|
||||
Assert.Equal(DateTimeKind.Utc, value.Kind);
|
||||
|
||||
Assert.Equal(DateTime.MinValue, OpcUaValueBoundary.ToClrTimestamp(DateTimeUtc.MinValue));
|
||||
Assert.Equal(when, OpcUaValueBoundary.ToClrTimestamp(when));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guid_BecomesASystemGuid()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
Assert.Equal(id, Assert.IsType<Guid>(OpcUaValueBoundary.ToClr(Variant.From(new Uuid(id)))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int32Array_BecomesADetachedClrArray()
|
||||
{
|
||||
var source = new[] { 1, 2, 3 };
|
||||
var value = Assert.IsType<int[]>(OpcUaValueBoundary.ToClr(Variant.From((ArrayOf<int>)source)));
|
||||
Assert.Equal(source, value);
|
||||
Assert.NotSame(source, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToVariant_ByteArray_IsAByteStringScalar()
|
||||
{
|
||||
// SDK 1.5's new Variant(byte[]) was a ByteString scalar; a Byte[] array would change the wire type.
|
||||
var variant = OpcUaValueBoundary.ToVariant(new byte[] { 9, 8 });
|
||||
Assert.Equal(BuiltInType.ByteString, variant.TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.Scalar, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToVariant_Int32Array_IsAOneDimensionalInt32Array_AndCopiesTheBuffer()
|
||||
{
|
||||
var source = new[] { 4, 5, 6 };
|
||||
var variant = OpcUaValueBoundary.ToVariant(source);
|
||||
source[0] = 99;
|
||||
|
||||
Assert.Equal(BuiltInType.Int32, variant.TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
Assert.Equal(new[] { 4, 5, 6 }, Assert.IsType<int[]>(OpcUaValueBoundary.ToClr(variant)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToVariant_Scalars_MapToTheirBuiltInType()
|
||||
{
|
||||
Assert.Equal(BuiltInType.Double, OpcUaValueBoundary.ToVariant(1.5).TypeInfo.BuiltInType);
|
||||
Assert.Equal(BuiltInType.Boolean, OpcUaValueBoundary.ToVariant(true).TypeInfo.BuiltInType);
|
||||
Assert.Equal(BuiltInType.String, OpcUaValueBoundary.ToVariant("x").TypeInfo.BuiltInType);
|
||||
Assert.True(OpcUaValueBoundary.ToVariant(null).IsNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToVariant_UnsupportedType_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => OpcUaValueBoundary.ToVariant(new object()));
|
||||
Assert.Throws<ArgumentException>(() => OpcUaValueBoundary.ToVariant(new int[2, 2]));
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -187,11 +187,12 @@ public class OpcUaWriteCoercionTests
|
||||
[Fact]
|
||||
public void Coerced_string_becomes_a_typed_variant()
|
||||
{
|
||||
// The #58 failure: new Variant("True") is a String Variant. After coercion it is Boolean.
|
||||
// The #58 failure: "True" written as-is is a String Variant. After coercion it is Boolean.
|
||||
// OpcUaValueBoundary.ToVariant is what RealOpcUaClient wraps each write value with.
|
||||
var coerced = OpcUaWriteCoercion.Coerce("True", BuiltInType.Boolean).Value;
|
||||
|
||||
Assert.Equal(BuiltInType.String, new Variant("True").TypeInfo.BuiltInType);
|
||||
Assert.Equal(BuiltInType.Boolean, new Variant(coerced).TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.Scalar, new Variant(coerced).TypeInfo.ValueRank);
|
||||
Assert.Equal(BuiltInType.String, OpcUaValueBoundary.ToVariant("True").TypeInfo.BuiltInType);
|
||||
Assert.Equal(BuiltInType.Boolean, OpcUaValueBoundary.ToVariant(coerced).TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.Scalar, OpcUaValueBoundary.ToVariant(coerced).TypeInfo.ValueRank);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-13
@@ -31,20 +31,20 @@ public class RealOpcUaAlarmLastStateTests
|
||||
|
||||
private static void Emit(RealOpcUaClient client, string condition, bool active, bool acked, List<NativeAlarmTransition> sink)
|
||||
{
|
||||
var fields = new VariantCollection
|
||||
var fields = new Variant[]
|
||||
{
|
||||
new(ObjectTypeIds.AlarmConditionType), // 0 EventType
|
||||
new(new NodeId("Tank01", 2)), // 1 SourceNode
|
||||
new("Tank01"), // 2 SourceName
|
||||
new(DateTime.UtcNow), // 3 Time
|
||||
new(new LocalizedText("msg")), // 4 Message
|
||||
new((ushort)500), // 5 Severity
|
||||
new(active), // 6 ActiveState/Id
|
||||
new(acked), // 7 AckedState/Id
|
||||
Variant.Null, // 8 ConfirmedState/Id
|
||||
new(false), // 9 SuppressedOrShelved
|
||||
Variant.Null, // 10 ShelvingState
|
||||
new(condition), // 11 ConditionName
|
||||
Variant.From(ObjectTypeIds.AlarmConditionType), // 0 EventType
|
||||
Variant.From(new NodeId("Tank01", 2)), // 1 SourceNode
|
||||
Variant.From("Tank01"), // 2 SourceName
|
||||
Variant.From(DateTime.UtcNow), // 3 Time
|
||||
Variant.From(new LocalizedText("msg")), // 4 Message
|
||||
Variant.From((ushort)500), // 5 Severity
|
||||
Variant.From(active), // 6 ActiveState/Id
|
||||
Variant.From(acked), // 7 AckedState/Id
|
||||
Variant.Null, // 8 ConfirmedState/Id
|
||||
Variant.From(false), // 9 SuppressedOrShelved
|
||||
Variant.Null, // 10 ShelvingState
|
||||
Variant.From(condition), // 11 ConditionName
|
||||
};
|
||||
Handler.Invoke(client, [Handle, "ns=2;s=Tank01", new EventFieldList { EventFields = fields },
|
||||
(Action<NativeAlarmTransition>)sink.Add]);
|
||||
|
||||
+10
-10
@@ -17,8 +17,8 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
public void BuildAlarmEventFilter_NoFilter_HasNoWhereClause()
|
||||
{
|
||||
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
|
||||
Assert.NotEmpty(filter.SelectClauses);
|
||||
Assert.Empty(filter.WhereClause.Elements);
|
||||
Assert.NotEmpty(filter.SelectClauses.ToList());
|
||||
Assert.Empty(filter.WhereClause.Elements.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that known condition types produce a non-empty where clause.</summary>
|
||||
@@ -28,11 +28,11 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
var parsed = AlarmConditionFilter.Parse("LimitAlarmType,DiscreteAlarmType");
|
||||
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
|
||||
|
||||
Assert.NotEmpty(filter.WhereClause.Elements);
|
||||
Assert.NotEmpty(filter.WhereClause.Elements.ToList());
|
||||
// Two known types → two OfType operands (OR'd when more than one).
|
||||
var ofTypeCount = filter.WhereClause.Elements.Count(e => e.FilterOperator == FilterOperator.OfType);
|
||||
var ofTypeCount = filter.WhereClause.Elements.ToList().Count(e => e.FilterOperator == FilterOperator.OfType);
|
||||
Assert.Equal(2, ofTypeCount);
|
||||
Assert.Contains(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.Or);
|
||||
Assert.Contains(filter.WhereClause.Elements.ToList(), e => e.FilterOperator == FilterOperator.Or);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single known condition type produces a single of-type clause with no OR.</summary>
|
||||
@@ -42,8 +42,8 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
var parsed = AlarmConditionFilter.Parse("AlarmConditionType");
|
||||
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
|
||||
|
||||
Assert.Single(filter.WhereClause.Elements);
|
||||
Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements[0].FilterOperator);
|
||||
Assert.Single(filter.WhereClause.Elements.ToList());
|
||||
Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements.ToList()[0].FilterOperator);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that condition type matching is case-insensitive.</summary>
|
||||
@@ -52,7 +52,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
{
|
||||
var parsed = AlarmConditionFilter.Parse("limitalarmtype");
|
||||
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
|
||||
Assert.Single(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.OfType);
|
||||
Assert.Single(filter.WhereClause.Elements.ToList(), e => e.FilterOperator == FilterOperator.OfType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a filter of only unknown types omits the where clause.</summary>
|
||||
@@ -65,7 +65,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
// unmapped types at the server and break correctness.
|
||||
var parsed = AlarmConditionFilter.Parse("MyVendorCustomAlarm,AnotherCustomThing");
|
||||
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
|
||||
Assert.Empty(filter.WhereClause.Elements);
|
||||
Assert.Empty(filter.WhereClause.Elements.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a mix of known and unknown types omits the where clause.</summary>
|
||||
@@ -77,7 +77,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
// and let the (authoritative) client gate do the filtering.
|
||||
var parsed = AlarmConditionFilter.Parse("LimitAlarmType,MyVendorCustomAlarm");
|
||||
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
|
||||
Assert.Empty(filter.WhereClause.Elements);
|
||||
Assert.Empty(filter.WhereClause.Elements.ToList());
|
||||
}
|
||||
|
||||
// ── SelectClause index alignment ───────────────────────────
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ public class RealOpcUaDiscoveryBoundTests
|
||||
async ct =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return new Opc.Ua.EndpointDescriptionCollection();
|
||||
return (IReadOnlyList<Opc.Ua.EndpointDescription>)[];
|
||||
},
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
+38
-1
@@ -216,6 +216,42 @@ public sealed class RealOpcUaSecureConnectTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#125 / lmxopcua#880: a production-length (65-character) API key logs in over every
|
||||
/// security mode, end to end through the real adapter, and a wrong key of the same length reaches
|
||||
/// the server's key check (<c>BadIdentityTokenRejected</c>) rather than failing to decrypt
|
||||
/// (<c>BadIdentityTokenInvalid</c>). The server here is SDK 2, which reads both password formats;
|
||||
/// the SDK 1.5 direction is covered by the wire-compat harness and the handler's own tests.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("None")]
|
||||
[InlineData("Sign")]
|
||||
[InlineData("SignAndEncrypt")]
|
||||
public async Task Connect_ProductionLengthApiKey_IsAdmitted_AndAWrongKeyReachesTheKeyCheck(string mode)
|
||||
{
|
||||
var key = OpcUaUserIdentitiesTests.ProductionLengthKey();
|
||||
using var clientCert = await ProvisionClientAsync();
|
||||
await using var server = await SecureOpcUaTestServer.StartWithApiKeyAsync(key, clientCert);
|
||||
ClientTrusts(server.Certificate);
|
||||
|
||||
OpcUaConnectionOptions WithKey(string password) => Secured(mode) with
|
||||
{
|
||||
UserIdentity = new OpcUaUserIdentityOptions("UsernamePassword", "apikey", password, null!, null!),
|
||||
};
|
||||
|
||||
await using (var client = new RealOpcUaClient(ClientOptions()))
|
||||
{
|
||||
await client.ConnectAsync(server.Url, WithKey(key));
|
||||
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
|
||||
}
|
||||
|
||||
var wrong = key[..^1] + (key[^1] == 'A' ? 'B' : 'A');
|
||||
await using var refused = new RealOpcUaClient(ClientOptions());
|
||||
var ex = await Assert.ThrowsAsync<Opc.Ua.ServiceResultException>(
|
||||
() => refused.ConnectAsync(server.Url, WithKey(wrong)));
|
||||
Assert.Equal(Opc.Ua.StatusCodes.BadIdentityTokenRejected, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>None is unchanged: it connects without reading or creating the own store.</summary>
|
||||
[Fact]
|
||||
public async Task Connect_None_DoesNotTouchTheOwnStore()
|
||||
@@ -245,7 +281,8 @@ public sealed class RealOpcUaSecureConnectTests : IDisposable
|
||||
using var again = await ProvisionClientAsync();
|
||||
Assert.Equal(certs[0].Thumbprint, again.Thumbprint);
|
||||
Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
|
||||
Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(again, "urn:localhost:SB121Client", out _),
|
||||
using var wrapped = new Opc.Ua.Security.Certificates.Certificate(again.RawData);
|
||||
Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(wrapped, "urn:localhost:SB121Client", out _),
|
||||
"the certificate carries the configured ApplicationUri");
|
||||
}
|
||||
finally
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public class RealOpcUaSnapshotScopeTests
|
||||
var events = new List<NativeAlarmTransition>();
|
||||
var handler = typeof(RealOpcUaClient).GetMethod("HandleAlarmEvent", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
void Emit(string handle, string source, NodeId type) => handler.Invoke(client,
|
||||
[handle, source, new EventFieldList { EventFields = new VariantCollection { new(type), new(), new(), new(), new(), new() } }, (Action<NativeAlarmTransition>)events.Add]);
|
||||
[handle, source, new EventFieldList { EventFields = new Variant[] { Variant.From(type), Variant.Null, Variant.Null, Variant.Null, Variant.Null, Variant.Null } }, (Action<NativeAlarmTransition>)events.Add]);
|
||||
Emit("a", "A", ObjectTypeIds.RefreshStartEventType);
|
||||
Emit("b", "B", ObjectTypeIds.RefreshStartEventType);
|
||||
Emit("a", "A", ObjectTypeIds.RefreshEndEventType);
|
||||
|
||||
+71
-10
@@ -3,7 +3,9 @@ using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Identity;
|
||||
using Opc.Ua.Server;
|
||||
using Opc.Ua.Server.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
@@ -35,7 +37,22 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
|
||||
/// <summary>Starts a server that trusts exactly <paramref name="trustedClients"/>.</summary>
|
||||
/// <param name="trustedClients">Client certificates the server's trusted peer store holds at start.</param>
|
||||
/// <returns>The running server.</returns>
|
||||
public static async Task<SecureOpcUaTestServer> StartAsync(params X509Certificate2[] trustedClients)
|
||||
public static Task<SecureOpcUaTestServer> StartAsync(params X509Certificate2[] trustedClients) =>
|
||||
StartCoreAsync(apiKey: null, trustedClients);
|
||||
|
||||
/// <summary>
|
||||
/// Starts a server that also admits the UserName <c>apikey</c> with exactly
|
||||
/// <paramref name="apiKey"/> as its password (ScadaBridge#125), and refuses any other password
|
||||
/// with <c>BadIdentityTokenRejected</c> once it has decrypted the token. The UserName token
|
||||
/// policy is Basic256Sha256, so the password is encrypted on every channel, None included.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The one password the server accepts.</param>
|
||||
/// <param name="trustedClients">Client certificates the server's trusted peer store holds at start.</param>
|
||||
/// <returns>The running server.</returns>
|
||||
public static Task<SecureOpcUaTestServer> StartWithApiKeyAsync(string apiKey, params X509Certificate2[] trustedClients) =>
|
||||
StartCoreAsync(apiKey, trustedClients);
|
||||
|
||||
private static async Task<SecureOpcUaTestServer> StartCoreAsync(string? apiKey, X509Certificate2[] trustedClients)
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), "sb121-server", Guid.NewGuid().ToString("N"));
|
||||
var port = FreePort();
|
||||
@@ -47,7 +64,8 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
|
||||
foreach (var c in trustedClients)
|
||||
await File.WriteAllBytesAsync(Path.Combine(trustedCerts, $"{c.Thumbprint}.der"), c.RawData);
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var config = new ApplicationConfiguration(telemetry)
|
||||
{
|
||||
ApplicationName = "SB121TestServer",
|
||||
ApplicationUri = $"urn:localhost:SB121TestServer:{port}",
|
||||
@@ -70,30 +88,35 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 },
|
||||
ServerConfiguration = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { url },
|
||||
BaseAddresses = [url],
|
||||
SecurityPolicies =
|
||||
{
|
||||
[
|
||||
new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None },
|
||||
new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.Sign, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 },
|
||||
new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 },
|
||||
},
|
||||
UserTokenPolicies = { new UserTokenPolicy(UserTokenType.Anonymous) },
|
||||
],
|
||||
UserTokenPolicies = apiKey is null
|
||||
? [new UserTokenPolicy(UserTokenType.Anonymous)]
|
||||
: [new UserTokenPolicy(UserTokenType.Anonymous),
|
||||
new UserTokenPolicy(UserTokenType.UserName) { SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }],
|
||||
},
|
||||
};
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Server);
|
||||
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var app = new ApplicationInstance(config, telemetry)
|
||||
{
|
||||
ApplicationName = config.ApplicationName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: 12);
|
||||
await app.StartAsync(new StandardServer());
|
||||
await app.StartAsync(new TestServer(telemetry, apiKey));
|
||||
|
||||
var cert = X509CertificateLoader.LoadCertificate(
|
||||
config.SecurityConfiguration.ApplicationCertificate.Certificate.RawData);
|
||||
// SDK 2: the loaded application certificate lives in the certificate manager, not on the identifier.
|
||||
using var entry = config.CertificateManager.AcquireApplicationCertificateByType(
|
||||
ObjectTypeIds.RsaSha256ApplicationCertificateType)
|
||||
?? throw new InvalidOperationException("The test server has no application certificate.");
|
||||
var cert = X509CertificateLoader.LoadCertificate(entry.Certificate.RawData);
|
||||
return new SecureOpcUaTestServer(app, pkiRoot, url, cert);
|
||||
}
|
||||
|
||||
@@ -105,6 +128,44 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/// <summary>A <see cref="StandardServer"/> that registers the API-key authenticator before it starts.</summary>
|
||||
private sealed class TestServer : StandardServer
|
||||
{
|
||||
public TestServer(ITelemetryContext telemetry, string? apiKey) : base(telemetry)
|
||||
{
|
||||
if (apiKey is not null)
|
||||
AddPreStartupTask(new RegisterAuthenticator(new ApiKeyAuthenticator(apiKey)));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegisterAuthenticator(IUserTokenAuthenticator authenticator) : IServerPreStartupTask
|
||||
{
|
||||
public ValueTask OnServerStartingAsync(IServerContext server, CancellationToken cancellationToken = default)
|
||||
{
|
||||
((IServerInternal)server).IdentityRegistry.Register(authenticator);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ApiKeyAuthenticator(string apiKey) : IUserTokenAuthenticator
|
||||
{
|
||||
public UserTokenType TokenType => UserTokenType.UserName;
|
||||
|
||||
public string? IssuedTokenProfileUri => null;
|
||||
|
||||
public ValueTask<AuthenticationResult> AuthenticateAsync(AuthenticationContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (context.TokenHandler is not UserNameIdentityTokenHandler userName)
|
||||
return ValueTask.FromResult(AuthenticationResult.NotHandled);
|
||||
var accepted = userName.UserName == "apikey"
|
||||
&& userName.DecryptedPassword is { } password
|
||||
&& System.Text.Encoding.UTF8.GetString(password) == apiKey;
|
||||
return ValueTask.FromResult(accepted
|
||||
? AuthenticationResult.Accept(new UserIdentity(userName))
|
||||
: AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected)));
|
||||
}
|
||||
}
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
|
||||
@@ -34,8 +34,9 @@ public class OpcUaDataTypeNameMapTests
|
||||
// Look up the well-known DataType NodeId by its standard name on
|
||||
// DataTypeIds (e.g. DataTypeIds.Double) so the test mirrors exactly
|
||||
// the constants the helper maps against.
|
||||
var field = typeof(DataTypeIds).GetField(typeName)!;
|
||||
var nodeId = (NodeId)field.GetValue(null)!;
|
||||
// SDK 2 (ScadaBridge#125) exposes the ids as static properties, not fields.
|
||||
var property = typeof(DataTypeIds).GetProperty(typeName)!;
|
||||
var nodeId = (NodeId)property.GetValue(null)!;
|
||||
|
||||
Assert.Equal(typeName, OpcUaBuiltInTypeNames.Resolve(nodeId));
|
||||
}
|
||||
|
||||
+18
-11
@@ -7,18 +7,25 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
/// <summary>ScadaBridge#117: quality follows the StatusCode severity bits, not "== 0".</summary>
|
||||
public class OpcUaStatusCodeMappingTests
|
||||
{
|
||||
// SDK 2 (ScadaBridge#125): StatusCodes members are StatusCode structs, not constants, so the
|
||||
// cases are a TheoryData rather than InlineData.
|
||||
public static TheoryData<uint, QualityCode> Cases => new()
|
||||
{
|
||||
{ StatusCodes.Good.Code, QualityCode.Good },
|
||||
{ StatusCodes.GoodLocalOverride.Code, QualityCode.Good },
|
||||
{ StatusCodes.GoodClamped.Code, QualityCode.Good },
|
||||
{ StatusCodes.Good.Code | 0x0480u, QualityCode.Good }, // info bits set
|
||||
{ StatusCodes.Uncertain.Code, QualityCode.Uncertain },
|
||||
{ StatusCodes.UncertainLastUsableValue.Code, QualityCode.Uncertain },
|
||||
{ StatusCodes.UncertainSensorNotAccurate.Code, QualityCode.Uncertain },
|
||||
{ StatusCodes.Bad.Code, QualityCode.Bad },
|
||||
{ StatusCodes.BadNodeIdUnknown.Code, QualityCode.Bad },
|
||||
{ StatusCodes.BadResourceUnavailable.Code, QualityCode.Bad },
|
||||
{ 0xC0000000u, QualityCode.Bad },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(StatusCodes.Good, QualityCode.Good)]
|
||||
[InlineData(StatusCodes.GoodLocalOverride, QualityCode.Good)]
|
||||
[InlineData(StatusCodes.GoodClamped, QualityCode.Good)]
|
||||
[InlineData(StatusCodes.Good | 0x0480u, QualityCode.Good)] // info bits set
|
||||
[InlineData(StatusCodes.Uncertain, QualityCode.Uncertain)]
|
||||
[InlineData(StatusCodes.UncertainLastUsableValue, QualityCode.Uncertain)]
|
||||
[InlineData(StatusCodes.UncertainSensorNotAccurate, QualityCode.Uncertain)]
|
||||
[InlineData(StatusCodes.Bad, QualityCode.Bad)]
|
||||
[InlineData(StatusCodes.BadNodeIdUnknown, QualityCode.Bad)]
|
||||
[InlineData(StatusCodes.BadResourceUnavailable, QualityCode.Bad)]
|
||||
[InlineData(0xC0000000u, QualityCode.Bad)]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void MapStatusCode_UsesSeverityBits(uint code, QualityCode expected) =>
|
||||
Assert.Equal(expected, OpcUaDataConnection.MapStatusCode(code));
|
||||
}
|
||||
|
||||
+14
-9
@@ -109,12 +109,12 @@ public class VerifyEndpointResultMappingTests
|
||||
/// <summary>Verifies that ServiceResultException authentication status codes map to an auth-failed result.</summary>
|
||||
/// <param name="statusCode">The status code.</param>
|
||||
[Theory]
|
||||
[InlineData(StatusCodes.BadUserAccessDenied)]
|
||||
[InlineData(StatusCodes.BadIdentityTokenRejected)]
|
||||
[InlineData(StatusCodes.BadIdentityTokenInvalid)]
|
||||
public void ServiceResultException_AuthCodes_MapToAuthFailed(uint statusCode)
|
||||
[InlineData(nameof(StatusCodes.BadUserAccessDenied))]
|
||||
[InlineData(nameof(StatusCodes.BadIdentityTokenRejected))]
|
||||
[InlineData(nameof(StatusCodes.BadIdentityTokenInvalid))]
|
||||
public void ServiceResultException_AuthCodes_MapToAuthFailed(string statusCode)
|
||||
{
|
||||
var ex = new ServiceResultException(statusCode, "access denied");
|
||||
var ex = new ServiceResultException(Status(statusCode), "access denied");
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
|
||||
|
||||
@@ -126,11 +126,11 @@ public class VerifyEndpointResultMappingTests
|
||||
/// <summary>Verifies that ServiceResultException connection status codes map to an unreachable result.</summary>
|
||||
/// <param name="statusCode">The status code.</param>
|
||||
[Theory]
|
||||
[InlineData(StatusCodes.BadConnectionRejected)]
|
||||
[InlineData(StatusCodes.BadNotConnected)]
|
||||
public void ServiceResultException_ConnectionCodes_MapToUnreachable(uint statusCode)
|
||||
[InlineData(nameof(StatusCodes.BadConnectionRejected))]
|
||||
[InlineData(nameof(StatusCodes.BadNotConnected))]
|
||||
public void ServiceResultException_ConnectionCodes_MapToUnreachable(string statusCode)
|
||||
{
|
||||
var ex = new ServiceResultException(statusCode, "no route");
|
||||
var ex = new ServiceResultException(Status(statusCode), "no route");
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
|
||||
|
||||
@@ -138,6 +138,11 @@ public class VerifyEndpointResultMappingTests
|
||||
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
|
||||
}
|
||||
|
||||
// SDK 2 (ScadaBridge#125): StatusCodes members are StatusCode structs, not constants, so the
|
||||
// theories name them and resolve the value here.
|
||||
private static StatusCode Status(string name) =>
|
||||
(StatusCode)typeof(StatusCodes).GetField(name)!.GetValue(null)!;
|
||||
|
||||
/// <summary>Verifies that a socket exception maps to an unreachable result.</summary>
|
||||
[Fact]
|
||||
public void SocketException_MapsToUnreachable()
|
||||
|
||||
Reference in New Issue
Block a user