a2e9d71824
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
199 lines
7.8 KiB
C#
199 lines
7.8 KiB
C#
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using Opc.Ua;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
|
|
|
/// <summary>
|
|
/// Gitea #58: the scalar write-coercion table <see cref="OpcUaWriteCoercion"/> that
|
|
/// <see cref="RealOpcUaClient.WriteValuesAsync"/> applies before wrapping a value in a
|
|
/// <see cref="Variant"/>. Each success case asserts the exact CLR type (the Variant's built-in
|
|
/// type follows from it); each failure case asserts no value is produced.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public class OpcUaWriteCoercionTests
|
|
{
|
|
public static TheoryData<object, BuiltInType, object> Successes => new()
|
|
{
|
|
// Boolean
|
|
{ "True", BuiltInType.Boolean, true },
|
|
{ "false", BuiltInType.Boolean, false },
|
|
{ " TRUE ", BuiltInType.Boolean, true },
|
|
{ "1", BuiltInType.Boolean, true },
|
|
{ "0", BuiltInType.Boolean, false },
|
|
{ 1, BuiltInType.Boolean, true },
|
|
{ true, BuiltInType.Boolean, true },
|
|
// Signed integers
|
|
{ "-128", BuiltInType.SByte, (sbyte)-128 },
|
|
{ "32767", BuiltInType.Int16, (short)32767 },
|
|
{ "-42", BuiltInType.Int32, -42 },
|
|
{ "42.0", BuiltInType.Int32, 42 },
|
|
{ 7L, BuiltInType.Int32, 7 },
|
|
{ 3.0, BuiltInType.Int32, 3 },
|
|
{ "-9223372036854775808", BuiltInType.Int64, long.MinValue },
|
|
{ "7", BuiltInType.Enumeration, 7 },
|
|
// Unsigned integers
|
|
{ "255", BuiltInType.Byte, (byte)255 },
|
|
{ "65535", BuiltInType.UInt16, (ushort)65535 },
|
|
{ "4294967295", BuiltInType.UInt32, uint.MaxValue },
|
|
{ 5, BuiltInType.UInt32, 5u },
|
|
{ "18446744073709551615", BuiltInType.UInt64, ulong.MaxValue },
|
|
// Floating point (invariant culture: '.' is the decimal separator)
|
|
{ "42.5", BuiltInType.Double, 42.5 },
|
|
{ "-1.5e3", BuiltInType.Double, -1500.0 },
|
|
{ 42, BuiltInType.Double, 42.0 },
|
|
{ 1.25f, BuiltInType.Double, 1.25 },
|
|
{ "Infinity", BuiltInType.Double, double.PositiveInfinity },
|
|
{ "42.5", BuiltInType.Float, 42.5f },
|
|
{ 2.5, BuiltInType.Float, 2.5f },
|
|
// String
|
|
{ "hello", BuiltInType.String, "hello" },
|
|
{ 42.5, BuiltInType.String, "42.5" },
|
|
{ true, BuiltInType.String, "True" },
|
|
// Unmapped target types pass through untouched
|
|
{ "abc", BuiltInType.Variant, "abc" },
|
|
{ "abc", BuiltInType.ExtensionObject, "abc" },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Successes))]
|
|
public void Coerces_to_the_node_type(object input, BuiltInType target, object expected)
|
|
{
|
|
var result = OpcUaWriteCoercion.Coerce(input, target);
|
|
|
|
Assert.True(result.Success, result.Error);
|
|
Assert.Equal(expected.GetType(), result.Value!.GetType());
|
|
Assert.Equal(expected, result.Value);
|
|
}
|
|
|
|
public static TheoryData<object, BuiltInType> Failures => new()
|
|
{
|
|
// Invalid strings
|
|
{ "yes", BuiltInType.Boolean },
|
|
{ "2", BuiltInType.Boolean },
|
|
{ 2, BuiltInType.Boolean },
|
|
{ "abc", BuiltInType.Int32 },
|
|
{ "", BuiltInType.Int32 },
|
|
{ "abc", BuiltInType.Double },
|
|
{ "not-a-date", BuiltInType.DateTime },
|
|
{ "zzz", BuiltInType.Guid },
|
|
// Culture: a comma is not a decimal separator, and thousands separators are refused
|
|
{ "42,5", BuiltInType.Double },
|
|
{ "1,000", BuiltInType.Int32 },
|
|
// Fractional value to an integer type — never rounded
|
|
{ "42.5", BuiltInType.Int32 },
|
|
{ 42.5, BuiltInType.Int64 },
|
|
{ double.NaN, BuiltInType.Int32 },
|
|
// Overflow
|
|
{ "128", BuiltInType.SByte },
|
|
{ "256", BuiltInType.Byte },
|
|
{ "-1", BuiltInType.Byte },
|
|
{ "32768", BuiltInType.Int16 },
|
|
{ "-1", BuiltInType.UInt16 },
|
|
{ "2147483648", BuiltInType.Int32 },
|
|
{ 4294967296L, BuiltInType.UInt32 },
|
|
{ "-1", BuiltInType.UInt32 },
|
|
{ "9223372036854775808", BuiltInType.Int64 },
|
|
{ "18446744073709551616", BuiltInType.UInt64 },
|
|
{ -1, BuiltInType.UInt64 },
|
|
{ 1e30, BuiltInType.Int64 },
|
|
{ "1e999", BuiltInType.Double },
|
|
{ "1e39", BuiltInType.Float },
|
|
{ 1e39, BuiltInType.Float },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Failures))]
|
|
public void Rejects_values_that_do_not_fit_the_node_type(object input, BuiltInType target)
|
|
{
|
|
var result = OpcUaWriteCoercion.Coerce(input, target);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Null(result.Value);
|
|
Assert.Contains(target == BuiltInType.Enumeration ? "Int32" : target.ToString(), result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void Error_names_the_value_and_target_type()
|
|
{
|
|
var result = OpcUaWriteCoercion.Coerce("300", BuiltInType.Byte);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("\"300\"", result.Error);
|
|
Assert.Contains("Byte", result.Error);
|
|
Assert.Contains("out of range", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void DateTime_strings_parse_invariantly_as_utc()
|
|
{
|
|
var withOffset = OpcUaWriteCoercion.Coerce("2026-11-06T10:00:00+02:00", BuiltInType.DateTime);
|
|
var noOffset = OpcUaWriteCoercion.Coerce("2026-11-06T08:00:00", BuiltInType.DateTime);
|
|
|
|
var expected = new DateTime(2026, 11, 6, 8, 0, 0, DateTimeKind.Utc);
|
|
Assert.Equal(expected, withOffset.Value);
|
|
Assert.Equal(DateTimeKind.Utc, ((DateTime)withOffset.Value!).Kind);
|
|
Assert.Equal(expected, noOffset.Value);
|
|
Assert.Equal(DateTimeKind.Utc, ((DateTime)noOffset.Value!).Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void DateTime_to_string_uses_round_trip_format()
|
|
{
|
|
var dt = new DateTime(2026, 11, 6, 8, 0, 0, DateTimeKind.Utc);
|
|
|
|
Assert.Equal("2026-11-06T08:00:00.0000000Z", OpcUaWriteCoercion.Coerce(dt, BuiltInType.String).Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Parsing_ignores_the_current_culture()
|
|
{
|
|
var saved = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
// de-DE uses ',' as the decimal separator; the invariant parse must still read "42.5".
|
|
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
|
Assert.Equal(42.5, OpcUaWriteCoercion.Coerce("42.5", BuiltInType.Double).Value);
|
|
Assert.Equal("42.5", OpcUaWriteCoercion.Coerce(42.5, BuiltInType.String).Value);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = saved;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Guid_strings_parse()
|
|
{
|
|
var g = Guid.NewGuid();
|
|
Assert.Equal(g, OpcUaWriteCoercion.Coerce(g.ToString(), BuiltInType.Guid).Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Arrays_and_null_pass_through_untouched()
|
|
{
|
|
var list = new List<int> { 1, 2 };
|
|
var array = new[] { "1", "2" };
|
|
|
|
Assert.Same(list, OpcUaWriteCoercion.Coerce(list, BuiltInType.Double).Value);
|
|
Assert.Same(array, OpcUaWriteCoercion.Coerce(array, BuiltInType.Int32).Value);
|
|
Assert.True(OpcUaWriteCoercion.Coerce(null, BuiltInType.Int32).Success);
|
|
Assert.Null(OpcUaWriteCoercion.Coerce(null, BuiltInType.Int32).Value);
|
|
Assert.False(OpcUaWriteCoercion.IsCoercibleScalar(list));
|
|
Assert.True(OpcUaWriteCoercion.IsCoercibleScalar("abc"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Coerced_string_becomes_a_typed_variant()
|
|
{
|
|
// 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, OpcUaValueBoundary.ToVariant("True").TypeInfo.BuiltInType);
|
|
Assert.Equal(BuiltInType.Boolean, OpcUaValueBoundary.ToVariant(coerced).TypeInfo.BuiltInType);
|
|
Assert.Equal(ValueRanks.Scalar, OpcUaValueBoundary.ToVariant(coerced).TypeInfo.ValueRank);
|
|
}
|
|
}
|