Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AttributeEventHistoryWireTests.cs
T
Joseph Doherty da948e5fb5
v2-ci / unit-tests (push) Has been cancelled
fix(history): a folder's event history includes its object's attribute alarms (#882)
HistorianGateway answers a per-source ReadEvents (Source_Object = DevWinPlatform) with the
object's own events (SourceName DevWinPlatform) AND its attribute alarms, which it reports as
<Object>.<Attribute> (Source_ProcessVariable, e.g. DevWinPlatform.CPULoadAvg). The Gateway
backend's defensive client-side source filter compared SourceName for equality, so it dropped
the attribute alarms: 70 of the 72 DevWinPlatform events on vd03.

The filter (and the past-the-cap "more events" signal) now uses EventSourceMatch: the source
itself, or <source>.<anything> on the '.' boundary, ordinal. DevWinPlatform never matches
DevWinPlatform2 or DevWinPlatform2.X. This needs no wire change and still makes one request.
Each event keeps its own SourceName. SourceNode stays the condition its stored alarm id names,
which is null for AVEVA rows. The attribute events ride the notifier's own read, so the
per-folder HistoryRead verdict (#826) governs them: a denied folder answers
BadUserAccessDenied before any backend call. The SqlServer and QuestDB backends still match
exactly; they hold only OtOpcUa's own events.

Tests:
- GatewayAttributeEventSourceTests (16): the boundary theory, attribute-in/sibling-out through
  the data source, the drain signal, and an exact attribute-named read.
- AttributeEventHistoryWireTests (2, over a real SDK session with the production Gateway
  backend on an AVEVA-shaped deterministic gateway): object + attribute events with their own
  SourceName, a prefix sibling excluded, paging, and the ACL-on deny with no backend call plus a
  granted positive control.
- Mutation-checked: exact match fails both wire tests; dropping the '.' boundary fails the
  wire sibling check and 6 unit tests.
- Live AVEVA harness re-run against vd03 HistorianGateway ea9f21c (read-only, no WinRM):
  folder DevWinPlatform 2 -> 72 events (whole, 72 1-event pages, reverse); the new #882 check
  passes; 221 of 225 shared cases are identical to the #801 SDK2 baseline, and the 4 that
  differ are exactly the DevWinPlatform event cases. Recorded in aveva-history-801.md.

Follow-up #886: production folders name RawPaths/EquipmentIds, not Galaxy objects, plus the AVEVA
severity/condition property mapping.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 10:34:29 -05:00

186 lines
10 KiB
C#

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;
/// <summary>
/// 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 <c>&lt;Object&gt;.&lt;Attribute&gt;</c>), never a sibling that merely shares the name's prefix, and the
/// attribute events are governed by the folder's own HistoryRead verdict.
/// </summary>
/// <remarks>
/// The rig's equipment folders <c>EqA</c> / <c>EqB</c> stand in for Galaxy objects: a folder's event source is its
/// NodeId identifier, so the fixture's AVEVA objects are named <c>EqA</c> / <c>EqB</c>, with a prefix sibling
/// <c>EqA2</c>. The fake gateway filters a per-source read more loosely than the real one (<c>Source_Object</c>
/// 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.
/// </remarks>
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<GatewayHistorianDataSource>.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<GatewayHistorianDataSource>.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<EventRead> 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<Row>();
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<Row> Rows, int Pages);
/// <summary>
/// A deterministic gateway answering ReadEvents with AVEVA-shaped rows (<c>SourceName</c> =
/// <c>Source_ProcessVariable</c>, a <c>source_object</c> property), filtered loosely on <c>source_object</c>.
/// Every other member is unused here and throws.
/// </summary>
private sealed class AvevaShapedGateway : IHistorianGatewayClient
{
private readonly List<string?> _requested = [];
private readonly List<string> _yielded = [];
public IReadOnlyList<string?> Requested { get { lock (_requested) return _requested.ToList(); } }
public IReadOnlyList<string> Yielded { get { lock (_yielded) return _yielded.ToList(); } }
public async IAsyncEnumerable<HistorianEvent> 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<bool> TagExistsAsync(string tag, CancellationToken ct) => throw new NotSupportedException();
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) =>
throw new NotSupportedException();
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc,
RetrievalMode mode, TimeSpan interval, CancellationToken ct) => throw new NotSupportedException();
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct) =>
throw new NotSupportedException();
public Task<WriteAck> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorianLiveValue> values, CancellationToken ct) =>
throw new NotSupportedException();
public Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct) => throw new NotSupportedException();
public Task<TagOperationResults> EnsureTagsAsync(IReadOnlyList<HistorianTagDefinition> definitions, CancellationToken ct) =>
throw new NotSupportedException();
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
public Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct) => Task.FromResult(new ConnectionStatus());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}