da948e5fb5
v2-ci / unit-tests (push) Has been cancelled
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
186 lines
10 KiB
C#
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><Object>.<Attribute></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;
|
|
}
|
|
}
|