using System.Runtime.CompilerServices; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #882 over a real SDK session, with the production Gateway backend over a deterministic AVEVA-shaped gateway: /// an equipment folder's event history carries the object's own events AND its attribute alarms (which the gateway /// reports as <Object>.<Attribute>), never a sibling that merely shares the name's prefix, and the /// attribute events are governed by the folder's own HistoryRead verdict. /// /// /// The rig's equipment folders EqA / EqB stand in for Galaxy objects: a folder's event source is its /// NodeId identifier, so the fixture's AVEVA objects are named EqA / EqB, with a prefix sibling /// EqA2. The fake gateway filters a per-source read more loosely than the real one (Source_Object /// begins with, not equals, the source), so the sibling's rows reach OtOpcUa and their absence is OtOpcUa's filter at /// work, not the fixture's. /// public sealed class AttributeEventHistoryWireTests { private static readonly DateTime Epoch = new(2026, 11, 10, 11, 0, 0, DateTimeKind.Utc); private static readonly (string Id, string Source, string Object, string Type, int Second)[] Stored = [ ("a-stop", EqA, EqA, "System.Stop", 0), ("a2-stop", "EqA2", "EqA2", "System.Stop", 1), ("a-set", "EqA.CPULoadAvg", EqA, "Alarm.Set", 2), ("a2-set", "EqA2.CPULoadAvg", "EqA2", "Alarm.Set", 3), ("b-set", "EqB.Temp", EqB, "Alarm.Set", 4), ("a-clear", "EqA.CPULoadAvg", EqA, "Alarm.Clear", 5), ("a-start", EqA, EqA, "System.Start", 6), ]; private static readonly string[] EqAEvents = ["a-stop", "a-set", "a-clear", "a-start"]; [Fact] public async Task A_folder_serves_its_objects_attribute_events_with_their_own_source_and_no_prefix_sibling() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("AttrEventHistory", encrypted: false, ct); var gateway = new AvevaShapedGateway(); rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); rig.MaterialiseNative(); rig.Enforce(false, [Key()]); using var session = await rig.ConnectAsync(anonymous: true, ct); var all = await ReadAsync(session, EqA, page: 0, ct); all.Status.ShouldBe(StatusCodes.Good); all.Rows.Select(r => r.Id).ShouldBe(EqAEvents); all.Rows.Select(r => r.Source).ShouldBe(new[] { EqA, "EqA.CPULoadAvg", "EqA.CPULoadAvg", EqA }, "each event keeps the source the historian reported, the attribute's for an attribute alarm"); all.Rows.ShouldAllBe(r => r.SourceNodeIsNull, "no materialised condition is named by an AVEVA row"); gateway.Requested.ShouldAllBe(s => s == EqA, "the object's events and its attributes' come from one request on the object"); gateway.Yielded.ShouldContain("a2-set", "the sibling's rows reached OtOpcUa, so their absence is its filter's doing"); // Paging one event a page walks the same four, attribute events included, and nothing else. var paged = await ReadAsync(session, EqA, page: 1, ct); paged.Rows.Select(r => r.Id).ShouldBe(EqAEvents); paged.Pages.ShouldBeGreaterThanOrEqualTo(4); } [Fact] public async Task With_the_acl_on_a_denied_folder_returns_none_of_its_attribute_events_and_never_reaches_the_backend() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("AttrEventHistoryAcl", encrypted: false, ct); var gateway = new AvevaShapedGateway(); rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); rig.MaterialiseNative(); // Anonymous is granted EqB only: EqA (whose object has attribute alarms) is denied. rig.Enforce(true, [Key()], EquipmentGrant(EqB, NodePermissions.ReadOnly)); using var session = await rig.ConnectAsync(anonymous: true, ct); var denied = await ReadAsync(session, EqA, page: 0, ct); denied.Status.ShouldBe(StatusCodes.BadUserAccessDenied); denied.HistoryDataIsNull.ShouldBeTrue("a denied folder discloses none of its events"); denied.Rows.ShouldBeEmpty(); gateway.Requested.ShouldBeEmpty("the folder verdict is decided before any backend read (fail closed)"); // Positive control: the same session, on the folder it IS granted, gets that object's attribute alarm. var granted = await ReadAsync(session, EqB, page: 0, ct); granted.Status.ShouldBe(StatusCodes.Good); granted.Rows.Select(r => (r.Id, r.Source)).ShouldBe(new (string, string?)[] { ("b-set", "EqB.Temp") }); gateway.Requested.ShouldBe(new[] { EqB }); } private static async Task ReadAsync(ISession session, string folder, uint page, CancellationToken ct) { var node = new NodeId(folder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri)); var filter = new EventFilter(); foreach (var leaf in new[] { "EventId", "SourceName", "SourceNode" }) filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value }; var details = new ReadEventDetails { StartTime = Epoch.AddMinutes(-1), EndTime = Epoch.AddMinutes(1), NumValuesPerNode = page, Filter = filter, }; var rows = new List(); ByteString point = default; var pages = 0; StatusCode first = StatusCodes.Good; var historyDataIsNull = true; do { var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false, new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = point } }, ct); var result = response.Results.ToList().ShouldHaveSingleItem(); if (pages++ == 0) { first = result.StatusCode; historyDataIsNull = result.HistoryData.IsNull; } if (StatusCode.IsBad(result.StatusCode)) break; if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he) foreach (var e in he.Events.ToList()) { var f = e.EventFields.ToList(); rows.Add(new Row(System.Text.Encoding.UTF8.GetString(f[0].GetByteString().Span), f[1].GetString(), f[2].IsNull)); } point = result.ContinuationPoint; } while (!point.IsEmpty && pages < 50); return new EventRead(first, historyDataIsNull, rows, pages); } private sealed record Row(string Id, string? Source, bool SourceNodeIsNull); private sealed record EventRead(StatusCode Status, bool HistoryDataIsNull, List Rows, int Pages); /// /// A deterministic gateway answering ReadEvents with AVEVA-shaped rows (SourceName = /// Source_ProcessVariable, a source_object property), filtered loosely on source_object. /// Every other member is unused here and throws. /// private sealed class AvevaShapedGateway : IHistorianGatewayClient { private readonly List _requested = []; private readonly List _yielded = []; public IReadOnlyList Requested { get { lock (_requested) return _requested.ToList(); } } public IReadOnlyList Yielded { get { lock (_yielded) return _yielded.ToList(); } } public async IAsyncEnumerable ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, [EnumeratorCancellation] CancellationToken ct) { lock (_requested) _requested.Add(sourceName); await Task.Yield(); foreach (var s in Stored.OrderBy(s => s.Second)) { var at = Epoch.AddSeconds(s.Second); if (at < startUtc || at > endUtc) continue; // the gateway's end is inclusive if (sourceName is not null && !s.Object.StartsWith(sourceName, StringComparison.Ordinal)) continue; lock (_yielded) _yielded.Add(s.Id); var ts = Timestamp.FromDateTime(at); var e = new HistorianEvent { Id = s.Id, SourceName = s.Source, Type = s.Type, EventTime = ts, ReceivedTime = ts }; e.Properties["source_object"] = s.Object; e.Properties["source_processvariable"] = s.Source; yield return e; } } public Task TagExistsAsync(string tag, CancellationToken ct) => throw new NotSupportedException(); public IAsyncEnumerable ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) => throw new NotSupportedException(); public IAsyncEnumerable ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken ct) => throw new NotSupportedException(); public Task> ReadAtTimeAsync(string tag, IReadOnlyList timestampsUtc, CancellationToken ct) => throw new NotSupportedException(); public Task WriteLiveValuesAsync(string tag, IReadOnlyList values, CancellationToken ct) => throw new NotSupportedException(); public Task SendEventAsync(HistorianEvent evt, CancellationToken ct) => throw new NotSupportedException(); public Task EnsureTagsAsync(IReadOnlyList definitions, CancellationToken ct) => throw new NotSupportedException(); public Task ProbeAsync(CancellationToken ct) => Task.FromResult(true); public Task GetConnectionStatusAsync(CancellationToken ct) => Task.FromResult(new ConnectionStatus()); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } }