using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// OPC UA HistoryUpdate over a real SDK client session (pluggable historian, Task 13): the encoding and /// dispatch the node-manager unit tests drive around. An in-memory updater and data source share ONE /// dictionary, so an insert is observable through a wire HistoryRead. /// /// /// The updater is installed BEFORE the historized node materialises — the HistoryWrite AccessLevel bit /// (which the SDK base requires to admit a node to HistoryUpdate) is fixed at creation. The control test /// pins the reverse order. /// public sealed class HistoryUpdateOverTheWireTests { private const string Tagname = "WW.Speed"; private const NodePermissions Editor = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.HistoryRead | NodePermissions.HistoryUpdate; private const NodePermissions ReaderOnly = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.HistoryRead; private static readonly DateTime T1 = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DateTime T2 = T1.AddMinutes(1); /// Insert x2, read back, duplicate insert refused, DeleteAtTime one, read back one. [Fact] public async Task Insert_read_duplicate_and_delete_round_trip_over_the_wire() { var ct = TestContext.Current.CancellationToken; var store = new SharedStore(); var rig = await StartAsync("HuWireRoundTrip", encrypted: false, ct); try { rig.Nm.HistorianDataUpdater = store; rig.Nm.HistorianDataSource = store; rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor)); var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); using var session = await rig.ConnectAsync(anonymous: false, ct); var node = RawNode(session, id); var insert = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.5), (T2, 2.5)); insert.StatusCode.Code.ShouldBe(StatusCodes.Good); insert.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted, StatusCodes.GoodEntryInserted]); var read = await ReadRawAsync(session, node, ct); read.Select(v => (v.SourceTimestamp, (double)v.Value)).ShouldBe([(T1, 1.5), (T2, 2.5)]); var dup = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 9.9)); dup.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadEntryExists]); (await ReadRawAsync(session, node, ct)).Count.ShouldBe(2, "the refused insert changed nothing"); var del = await DeleteAtTimeAsync(session, node, ct, T1); del.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.Good]); var after = await ReadRawAsync(session, node, ct); after.Select(v => (v.SourceTimestamp, (double)v.Value)).ShouldBe([(T2, 2.5)]); } finally { await rig.DisposeAsync(); } } /// /// ALLOW beside DENY: the same server, same node — the editor session is admitted, the session /// without the HistoryUpdate grant gets BadUserAccessDenied and the updater is never called. /// [Fact] public async Task A_session_without_the_grant_is_denied_and_the_updater_is_not_called() { var ct = TestContext.Current.CancellationToken; var store = new SharedStore(); var rig = await StartAsync("HuWireDeny", encrypted: false, ct); try { rig.Nm.HistorianDataUpdater = store; rig.Nm.HistorianDataSource = store; rig.Enforce( true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor), ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon", ReaderOnly)); var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); using var denied = await rig.ConnectAsync(anonymous: true, ct); var denial = await UpdateAsync(denied, RawNode(denied, id), PerformUpdateType.Insert, ct, (T1, 1.0)); denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); store.UpdateCalls.ShouldBe(0, "a denied session never reaches the updater"); store.Count.ShouldBe(0); using var allowed = await rig.ConnectAsync(anonymous: false, ct); var ok = await UpdateAsync(allowed, RawNode(allowed, id), PerformUpdateType.Insert, ct, (T1, 1.0)); ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); store.UpdateCalls.ShouldBe(1); } finally { await rig.DisposeAsync(); } } /// /// ACL flag OFF over the wire: a key's grants collapse into server-wide roles at activation (the production /// ). A key whose principal holds only a Cluster-scoped WriteOperate grant /// is denied — live-write authority never implies rewriting history — and the same key with a Cluster-scoped /// HistoryUpdate grant is admitted. The allow half keeps the deny from passing against a build that refuses /// every HistoryUpdate with the flag off. /// [Fact] public async Task Acl_off_a_cluster_history_update_grant_allows_and_write_operate_alone_is_denied() { var ct = TestContext.Current.CancellationToken; var store = new SharedStore(); var rig = await StartAsync("HuWireAclOff", encrypted: false, ct); try { rig.Nm.HistorianDataUpdater = store; rig.Nm.HistorianDataSource = store; var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); var writeOnly = ClusterGrant(KeyPrincipal, "acl-writer", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate); rig.Enforce(false, [Key()], writeOnly); rig.KeyRoles = ApiKeyInterimRoleMapper.Map([writeOnly], KeyPrincipal); rig.KeyRoles.ShouldNotContain(OpcUaDataPlaneRoles.HistoryUpdate); using (var writer = await rig.ConnectAsync(anonymous: false, ct)) { var denial = await UpdateAsync(writer, RawNode(writer, id), PerformUpdateType.Insert, ct, (T1, 1.0)); denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); } store.UpdateCalls.ShouldBe(0, "a denied session never reaches the updater"); var editor = ClusterGrant(KeyPrincipal, "acl-editor", NodePermissions.Browse | NodePermissions.Read | NodePermissions.HistoryUpdate); rig.Enforce(false, [Key()], editor); rig.KeyRoles = ApiKeyInterimRoleMapper.Map([editor], KeyPrincipal); using var allowed = await rig.ConnectAsync(anonymous: false, ct); var ok = await UpdateAsync(allowed, RawNode(allowed, id), PerformUpdateType.Insert, ct, (T1, 1.0)); ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); store.UpdateCalls.ShouldBe(1); } finally { await rig.DisposeAsync(); } } /// The HistoryWrite bit is visible on both AccessLevel and UserAccessLevel when an updater is wired. [Fact] public async Task The_history_write_bit_is_visible_over_the_wire() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("HuWireBit", encrypted: false, ct); try { rig.Nm.HistorianDataUpdater = new SharedStore(); rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor)); var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); using var session = await rig.ConnectAsync(anonymous: false, ct); var node = RawNode(session, id); (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) .ShouldBe(AccessLevels.HistoryWrite); (await ReadByteAsync(session, node, Attributes.UserAccessLevel, ct) & AccessLevels.HistoryWrite) .ShouldBe(AccessLevels.HistoryWrite); } finally { await rig.DisposeAsync(); } } /// /// Control (no retro-update): materialising the node BEFORE the updater is installed leaves it without /// the HistoryWrite bit, so the SDK base answers BadHistoryOperationUnsupported — even for an editor. /// [Fact] public async Task An_updater_installed_after_the_node_materialised_is_unsupported() { var ct = TestContext.Current.CancellationToken; var store = new SharedStore(); var rig = await StartAsync("HuWireLate", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor)); var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); rig.Nm.HistorianDataUpdater = store; using var session = await rig.ConnectAsync(anonymous: false, ct); var node = RawNode(session, id); (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) .ShouldBe(0, "the bit is fixed at materialisation"); var result = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0)); result.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); store.UpdateCalls.ShouldBe(0); } finally { await rig.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── private static NodeId RawNode(ISession s, string id) => new(id, (ushort)s.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); private static async Task ReadByteAsync(ISession session, NodeId node, uint attribute, CancellationToken ct) { var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection { new ReadValueId { NodeId = node, AttributeId = attribute }, }, ct); return (byte)response.Results.ShouldHaveSingleItem().Value; } private static async Task UpdateAsync( ISession session, NodeId node, PerformUpdateType mode, CancellationToken ct, params (DateTime Time, double Value)[] items) { var details = new UpdateDataDetails { NodeId = node, PerformInsertReplace = mode }; foreach (var (time, value) in items) details.UpdateValues.Add(new DataValue(new Variant(value), StatusCodes.Good, time, time)); return await SendAsync(session, details, ct); } private static Task DeleteAtTimeAsync( ISession session, NodeId node, CancellationToken ct, params DateTime[] times) { var details = new DeleteAtTimeDetails { NodeId = node }; foreach (var t in times) details.ReqTimes.Add(t); return SendAsync(session, details, ct); } private static async Task SendAsync(ISession session, object details, CancellationToken ct) { var response = await session.HistoryUpdateAsync( null, new ExtensionObjectCollection { new ExtensionObject(details) }, ct); return response.Results.ShouldHaveSingleItem(); } private static async Task> ReadRawAsync(ISession session, NodeId node, CancellationToken ct) { var details = new ReadRawModifiedDetails { StartTime = T1.AddHours(-1), EndTime = T1.AddHours(1), NumValuesPerNode = 100, IsReadModified = false, ReturnBounds = false, }; var response = await session.HistoryReadAsync( null, new ExtensionObject(details), TimestampsToReturn.Source, false, new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct); var result = response.Results.ShouldHaveSingleItem(); StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}"); var data = (HistoryData)ExtensionObject.ToEncodeable(result.HistoryData); return data.DataValues.ToList(); } /// /// One dictionary behind both seams, thread-safe and completing on a pool thread (as a real backend /// would), so the node manager's blocking bridge is exercised the way production runs it. /// private sealed class SharedStore : IHistorianDataUpdater, IHistorianDataSource { private readonly object _gate = new(); private readonly SortedDictionary _samples = []; private int _updateCalls; public int UpdateCalls => Volatile.Read(ref _updateCalls); public int Count { get { lock (_gate) return _samples.Count; } } public async Task> UpdateDataAsync( string tag, HistoryUpdateMode mode, IReadOnlyList values, CancellationToken ct) { Interlocked.Increment(ref _updateCalls); await Task.Delay(1, ct).ConfigureAwait(false); tag.ShouldBe(Tagname); mode.ShouldBe(HistoryUpdateMode.Insert); var codes = new List(); lock (_gate) { foreach (var v in values) { var t = v.TimestampUtc!.Value; if (_samples.ContainsKey(t)) { codes.Add(StatusCodes.BadEntryExists); continue; } v.Value.TryGetDouble(out var d).ShouldBeTrue(); _samples[t] = d; codes.Add(StatusCodes.GoodEntryInserted); } } return codes; } public Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) => throw new NotSupportedException(); public async Task> DeleteAtTimeAsync( string tag, IReadOnlyList timesUtc, CancellationToken ct) { await Task.Delay(1, ct).ConfigureAwait(false); lock (_gate) return timesUtc.Select(t => _samples.Remove(t) ? StatusCodes.Good : StatusCodes.BadNoEntryExists).ToList(); } public async Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) { await Task.Delay(1, cancellationToken).ConfigureAwait(false); fullReference.ShouldBe(Tagname); lock (_gate) { var rows = _samples.Where(kv => kv.Key >= startUtc && kv.Key <= endUtc) .Select(kv => new DataValueSnapshot(kv.Value, StatusCodes.Good, kv.Key, kv.Key)).ToList(); return new CoreHistoryReadResult(rows, null); } } public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => throw new NotSupportedException(); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } }