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