fix(acl): keep the SDK's audit guard and EventsMonitored bookkeeping on Server-object delivery (#586)
Review of 75e559f6:
- OnConditionReportEvent now mirrors MonitoredNode2's AuditEventState guard (only while Server.Auditing,
over SignAndEncrypt / https-uabinary). Conditions report their Part 9 method audit events through
themselves, so the first cut queued AuditConditionAcknowledge (i=8944) etc. to every i=2253 item with
auditing off -- the removed folder->Server forward used to drop them.
- ReconcileAllEventsMonitoring keeps one AreEventsMonitored unit on every materialised condition while any
i=2253 item exists (called from SubscribeToAllEvents, materialise, removal, rebuild), so
ConditionState.EventsMonitored() is true on a server whose only subscribers are Server-object items.
A protected SubscribeToEvents override no-ops all-events items, which stops the SDK's AddRootNotifier
(unsubscribe: true bug) taking that unit back from conditions rebuilt under a live item.
Flag-off over-the-wire coverage: Acknowledge/AddComment with and without a folder co-subscriber (no audit
event, transitions once each), EventsMonitored on/after-rebuild/off, rebuild survival with one copy per
event, native + scripted conditions once each, refresh once per condition with RefreshStart/End; plus an
ACL-on native-vs-scripted notifier-folder decision. Falsified: removing the audit guard reds both audit
cases; removing the reconcile reds the monitored test.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+308
@@ -42,6 +42,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
private const string Cluster = "SITE-A";
|
||||
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||
private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi";
|
||||
private const string ScriptedAlarmId = "scripted-eqa-hi";
|
||||
private const string EqA = "EqA";
|
||||
private const string EqB = "EqB";
|
||||
private const string KeyPrincipal = "expiry-probe";
|
||||
@@ -572,8 +573,283 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ──────── Server-object delivery with the flag OFF — #586 review: no regression vs. the SDK path ────────
|
||||
|
||||
/// <summary>
|
||||
/// Part 9 method calls (Acknowledge, AddComment) on a condition put no audit event in front of a
|
||||
/// Server-object item while the server is not auditing — with and without a folder co-subscriber —
|
||||
/// and the item still receives the condition's own transitions exactly once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A condition reports its method audit events (<c>AuditConditionAcknowledgeEventType</c>,
|
||||
/// <c>AuditConditionCommentEventType</c>) through its own <c>ReportEvent</c>, which since #586 is
|
||||
/// where Server-object delivery happens. The SDK path it replaced dropped them unless auditing was
|
||||
/// on over an encrypted channel; the replacement must too. Found in review: the first cut queued
|
||||
/// <c>i=8944</c> to every Server-object item.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both shapes matter. With a folder co-subscriber the condition counts as monitored through the
|
||||
/// folder; with none, only the Server-object item makes it so — the shape the second review
|
||||
/// finding was about, where nothing at all was reported before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The positive control is the next transition: the method calls queue synchronously, so an audit
|
||||
/// event would be ahead of <c>t2</c> in the same item's queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Method_audit_events_do_not_reach_a_server_object_item_while_not_auditing(bool folderCoSubscriber)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync($"AclServerAudit{folderCoSubscriber}", ct);
|
||||
try
|
||||
{
|
||||
rig.Materialise();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
var serverItem = AddEventItem(subscription, ObjectIds.Server);
|
||||
var folderItem = folderCoSubscriber ? AddEventItem(subscription, rig.UnsNodeId(session, EqA)) : null;
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => events.Has(serverItem, "t1"));
|
||||
var eventId = events.EventIdOf(serverItem, "t1");
|
||||
|
||||
var condition = rig.RawConditionNodeId(session);
|
||||
await CallIgnoringStatusAsync(
|
||||
session, condition, MethodIds.AcknowledgeableConditionType_Acknowledge, ct, eventId, new LocalizedText("ack"));
|
||||
await CallIgnoringStatusAsync(
|
||||
session, condition, MethodIds.ConditionType_AddComment, ct, eventId, new LocalizedText("note"));
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => events.Has(serverItem, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
|
||||
events.HasType(serverItem, ObjectTypeIds.AuditConditionAcknowledgeEventType).ShouldBeFalse();
|
||||
events.HasType(serverItem, ObjectTypeIds.AuditConditionCommentEventType).ShouldBeFalse();
|
||||
events.Count(serverItem, "t1").ShouldBe(1);
|
||||
events.Count(serverItem, "t2").ShouldBe(1);
|
||||
if (folderItem is not null)
|
||||
{
|
||||
events.HasType(folderItem, ObjectTypeIds.AuditConditionAcknowledgeEventType).ShouldBeFalse();
|
||||
events.Count(folderItem, "t2").ShouldBe(1);
|
||||
}
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Server-object item alone marks every condition as monitored, and deleting it un-marks them —
|
||||
/// including a condition rebuilt by a redeploy while the item was live.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ConditionState.EventsMonitored()</c> gates whether the SDK reports a condition's method-driven
|
||||
/// state changes at all. The SDK base raised it by attaching the item to every root notifier; #586
|
||||
/// attaches to nothing, so the first cut left it false on a server whose only subscribers are
|
||||
/// Server-object items — the machine-consumer default. Asserted server-side because the consequence on
|
||||
/// the wire depends on a separate, still-open reporting gap.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_server_object_item_alone_marks_conditions_monitored_until_it_is_deleted()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerMonitored", ct);
|
||||
try
|
||||
{
|
||||
rig.Materialise();
|
||||
rig.NativeConditionEventsMonitored.ShouldBeFalse("baseline: nobody is listening yet");
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.NativeConditionEventsMonitored.ShouldBeTrue();
|
||||
|
||||
rig.Rebuild();
|
||||
rig.Materialise();
|
||||
rig.NativeConditionEventsMonitored.ShouldBeTrue("a condition rebuilt under a live item is monitored too");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
rig.NativeConditionEventsMonitored.ShouldBeFalse("the last item's delete must give the unit back");
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Server-object item outlives a full address-space rebuild and receives exactly one copy of each
|
||||
/// transition on the rebuilt condition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing is attached to the discarded folders, so there is no fan-out to repair; this pins that the
|
||||
/// rebuilt condition's delivery is wired, and that the rebuild did not leave a second path behind.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_server_object_item_survives_a_rebuild_with_one_copy_per_event()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerRebuild", ct);
|
||||
try
|
||||
{
|
||||
rig.Materialise();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
var item = AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t1"));
|
||||
|
||||
rig.Rebuild();
|
||||
rig.Materialise();
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => events.Has(item, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
events.Count(item, "t1").ShouldBe(1);
|
||||
events.Count(item, "t2").ShouldBe(1);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With several conditions — a native one wired to two equipment folders and a scripted one under a
|
||||
/// UNS equipment folder — a Server-object item receives each condition's transition exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_server_object_item_receives_native_and_scripted_conditions_once_each()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerMulti", ct);
|
||||
try
|
||||
{
|
||||
rig.Materialise();
|
||||
rig.MaterialiseScripted();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
var item = AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("n1", active: true);
|
||||
rig.TransitionScripted("s1", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "n1") && events.Has(item, "s1"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
events.Count(item, "n1").ShouldBe(1);
|
||||
events.Count(item, "s1").ShouldBe(1);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With enforcement on, the decision follows the notifier a condition reports through: an equipment
|
||||
/// grant on <see cref="EqB"/> admits the native condition wired to it and withholds the scripted
|
||||
/// condition under <see cref="EqA"/>; a cluster-granted session receives both (the positive control).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The scripted condition's NodeId is its alarm id, not a path under its equipment, so it is admitted
|
||||
/// only through its parent notifier folder — the branch of the check this pins.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Server_object_delivery_follows_the_notifier_folders_for_native_and_scripted_conditions()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerScripted", ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce([UnexpiringKey()], EquipmentGrant(EqB), ClusterGrant(KeyPrincipal, "acl-key"));
|
||||
rig.Materialise();
|
||||
rig.MaterialiseScripted();
|
||||
|
||||
using var control = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var controlEvents = new EventCollector();
|
||||
var controlSubscription = await CreateSubscriptionAsync(control, controlEvents, ct);
|
||||
var controlItem = AddEventItem(controlSubscription, ObjectIds.Server);
|
||||
await controlSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
var item = AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("n1", active: true);
|
||||
rig.TransitionScripted("s1", active: true);
|
||||
await WaitUntilAsync(() =>
|
||||
controlEvents.Has(controlItem, "n1") && controlEvents.Has(controlItem, "s1") && events.Has(item, "n1"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
|
||||
events.Has(item, "s1").ShouldBeFalse("EqB's grant does not reach the scripted condition under EqA");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
await controlSubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With the flag off, a <c>ConditionRefresh</c> on a Server-object item replays each condition exactly
|
||||
/// once — not once per notifier it is wired to — bracketed by RefreshStart and RefreshEnd.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_condition_refresh_on_the_server_object_replays_each_condition_once()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerRefreshOff", ct);
|
||||
try
|
||||
{
|
||||
rig.Materialise();
|
||||
rig.MaterialiseScripted();
|
||||
rig.Transition("native-before", active: true);
|
||||
rig.TransitionScripted("scripted-before", active: true);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
var item = AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
await RefreshAsync(session, subscription, ct);
|
||||
|
||||
await WaitUntilAsync(() => events.HasType(item, ObjectTypeIds.RefreshEndEventType));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
events.HasType(item, ObjectTypeIds.RefreshStartEventType).ShouldBeTrue();
|
||||
events.Count(item, "native-before").ShouldBe(1, "the native condition is wired to three notifiers");
|
||||
events.Count(item, "scripted-before").ShouldBe(1);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Calls a condition method, ignoring its outcome: the test asserts what the call puts on the event
|
||||
/// stream, which the SDK reports whether or not the call itself is accepted.
|
||||
/// </summary>
|
||||
private static async Task CallIgnoringStatusAsync(
|
||||
ISession session, NodeId objectId, NodeId methodId, CancellationToken ct, params object[] args)
|
||||
{
|
||||
try { await session.CallAsync(objectId, methodId, ct, args); }
|
||||
catch (ServiceResultException) { /* the outcome is not under test */ }
|
||||
}
|
||||
|
||||
private static ApiKey UnexpiringKey(DateTime? expiresUtc = null) => new()
|
||||
{
|
||||
ApiKeyId = KeyId,
|
||||
@@ -660,6 +936,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<(uint Handle, string Message)> _seen = [];
|
||||
private readonly Dictionary<(uint Handle, string Message), byte[]> _eventIds = [];
|
||||
private readonly HashSet<(uint Handle, NodeId Type)> _types = [];
|
||||
|
||||
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||
@@ -672,6 +949,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
&& e.EventFields[MessageFieldIndex].Value is LocalizedText { Text: { } text })
|
||||
{
|
||||
_seen.Add((e.ClientHandle, text));
|
||||
if (e.EventFields[0].Value is byte[] eventId) _eventIds[(e.ClientHandle, text)] = eventId;
|
||||
}
|
||||
|
||||
if (e.EventFields.Count > EventTypeFieldIndex
|
||||
@@ -694,6 +972,11 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
{
|
||||
lock (_gate) return _types.Contains((item.ClientHandle, type));
|
||||
}
|
||||
|
||||
public byte[] EventIdOf(MonitoredItem item, string message)
|
||||
{
|
||||
lock (_gate) return _eventIds[(item.ClientHandle, message)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A booted server with ACL enforcement, one native alarm wired to two equipment notifiers.</summary>
|
||||
@@ -777,6 +1060,31 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
_sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, [EqA, EqB], AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
/// <summary>A scripted condition under equipment folder <see cref="EqA"/> (UNS realm, no extra notifiers).</summary>
|
||||
public void MaterialiseScripted() =>
|
||||
_sink.MaterialiseAlarmCondition(
|
||||
ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false);
|
||||
|
||||
/// <summary>Fires one transition on the scripted condition, carrying <paramref name="message"/>.</summary>
|
||||
public void TransitionScripted(string message, bool active) =>
|
||||
_sink.WriteAlarmCondition(
|
||||
ScriptedAlarmId,
|
||||
new AlarmConditionSnapshot(
|
||||
Active: active, Acknowledged: !active, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 500, Message: message),
|
||||
DateTime.UtcNow,
|
||||
AddressSpaceRealm.Uns);
|
||||
|
||||
/// <summary>A full address-space rebuild, as a redeploy that is not surgical performs.</summary>
|
||||
public void Rebuild() => _sink.RebuildAddressSpace();
|
||||
|
||||
/// <summary>Whether the native condition reports itself monitored (<c>ConditionState.EventsMonitored()</c>).</summary>
|
||||
public bool NativeConditionEventsMonitored => Nm.ConditionEventsMonitored(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
|
||||
/// <summary>The native condition's NodeId in the session's raw namespace.</summary>
|
||||
public NodeId RawConditionNodeId(ISession session) =>
|
||||
new(RawAlarmPath, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
|
||||
/// <summary>Fires one genuine condition transition, carrying <paramref name="message"/>.</summary>
|
||||
public void Transition(string message, bool active) =>
|
||||
_sink.WriteAlarmCondition(
|
||||
|
||||
Reference in New Issue
Block a user