Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs
T
Joseph Doherty a2e9d71824 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
2026-11-12 22:20:53 -05:00

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);
}
}