Merge #586: Server-object (i=2253) event items gated by the data-plane ACL; per-condition delivery with SDK audit guard + monitored bookkeeping

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 00:09:03 -04:00
5 changed files with 1109 additions and 30 deletions
@@ -42,13 +42,15 @@ 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";
private const string KeyId = "k-event-expiry";
// [0]=EventId, [1]=Message — the message is each transition's discriminator.
// [0]=EventId, [1]=Message — the message is each transition's discriminator; [2]=EventType.
private const int MessageFieldIndex = 1;
private const int EventTypeFieldIndex = 2;
private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
@@ -268,8 +270,601 @@ public sealed class AclEventSubscriptionEnforcementTests
finally { await rig.DisposeAsync(); }
}
// ─────────────────── Server-object (i=2253) subscriptions — Gitea #586 ───────────────────
/// <summary>
/// A session holding no grant receives nothing through a Server-object event item, while a granted
/// session's Server-object item receives the same transition.
/// </summary>
/// <remarks>
/// The Server object carries every alarm in the server — each condition reports once and its one
/// copy reaches every all-events item — so before #586 this was the one feed that ignored the ACL
/// entirely. The granted item is the positive control: it proves the transition was fired and
/// published before the denied item's silence is read as anything.
/// </remarks>
[Fact]
public async Task A_server_object_subscription_receives_only_the_alarms_its_session_is_granted()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclServerEvents", ct);
try
{
rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
rig.Materialise();
using var granted = await rig.ConnectAsync(anonymous: false, ct);
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
var grantedEvents = new EventCollector();
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
await grantedSubscription.ApplyChangesAsync(ct);
grantedItem.Status.Error.ShouldBeNull();
var ungrantedEvents = new EventCollector();
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
await ungrantedSubscription.ApplyChangesAsync(ct);
// Creation is not refused: an all-events item is legitimate for a session with partial grants,
// so what is withheld is each event the session may not see, not the item.
ungrantedItem.Status.Error.ShouldBeNull();
rig.Transition("t1", active: true);
await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse(
"a session with no grant must not receive alarms through the Server object");
grantedEvents.Count(grantedItem, "t1").ShouldBe(1, "the condition still reaches the Server object exactly once");
await ungrantedSubscription.DeleteAsync(true, ct);
await grantedSubscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// A Server-object item created <b>before</b> the alarm is deployed is gated the same way, and still
/// receives exactly one copy when granted.
/// </summary>
/// <remarks>
/// The ordering a long-lived consumer actually sees — it subscribes once, deployments come later. The
/// SDK never attaches such an item to a root notifier registered after it (its <c>AddRootNotifier</c>
/// subscribes existing all-events items with <c>unsubscribe: true</c>), so before #586 these items were
/// served only by the root notifier's forward to the core Server object — a path this manager never
/// saw, and the one now dropped. This proves the replacement delivery covers it.
/// </remarks>
[Fact]
public async Task A_server_object_subscription_created_before_the_deployment_is_gated_and_served()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclServerEventsEarly", ct);
try
{
rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
using var granted = await rig.ConnectAsync(anonymous: false, ct);
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
var grantedEvents = new EventCollector();
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
await grantedSubscription.ApplyChangesAsync(ct);
var ungrantedEvents = new EventCollector();
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
await ungrantedSubscription.ApplyChangesAsync(ct);
rig.Materialise();
rig.Transition("t1", active: true);
await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse();
grantedEvents.Count(grantedItem, "t1").ShouldBe(1);
await ungrantedSubscription.DeleteAsync(true, ct);
await grantedSubscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// The deployment axis on a Server-object item: an equipment grant admits the alarm, a deployment that
/// revokes it stops the feed, and one that restores it resumes the feed with no client action.
/// </summary>
/// <remarks>
/// The grant is an <b>Equipment</b> grant on a UNS folder the condition is wired to, not a grant on the
/// raw condition itself — the decision has to match the per-folder path, where that grant admits the
/// same alarm through the equipment's notifier. A cluster-granted key session is the positive control.
/// </remarks>
[Fact]
public async Task Revoking_then_restoring_a_grant_stops_then_resumes_a_server_object_alarm_feed()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclServerEventsRevoke", ct);
try
{
var key = UnexpiringKey();
var state = rig.Enforce([key], EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key"));
rig.Materialise();
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("t1", active: true);
await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1"));
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
rig.Transition("t2", active: false);
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment");
state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
rig.Transition("t3", active: true);
await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3"));
events.Has(item, "t2").ShouldBeFalse("nothing withheld while revoked is replayed on restore");
await subscription.DeleteAsync(true, ct);
await controlSubscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// The clock axis on a Server-object item: once the session's key expires the alarm feed stops on the
/// very next event — with no deployment and no revalidation tick — while an independent session keeps
/// receiving the same event.
/// </summary>
/// <remarks>
/// Server-object events are decided per event at delivery, and the authorizer re-checks key expiry on
/// every call, so revalidation is implicit rather than driven by either trigger. Deliberately no
/// <c>TickRevalidation()</c> here: a build that only stood items down on the tick would pass a test
/// that ticked.
/// </remarks>
[Fact]
public async Task An_expired_key_stops_a_server_object_alarm_feed_on_the_next_event()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclServerEventsExpiry", ct);
try
{
var key = UnexpiringKey(expiresUtc: Start.AddMinutes(10));
var state = rig.Enforce(
[key], ClusterGrant(KeyPrincipal, "acl-key"), ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon"));
rig.Materialise();
using var keySession = await rig.ConnectAsync(anonymous: false, ct);
using var controlSession = await rig.ConnectAsync(anonymous: true, ct);
var keyEvents = new EventCollector();
var keySubscription = await CreateSubscriptionAsync(keySession, keyEvents, ct);
var keyItem = AddEventItem(keySubscription, ObjectIds.Server);
await keySubscription.ApplyChangesAsync(ct);
var controlEvents = new EventCollector();
var controlSubscription = await CreateSubscriptionAsync(controlSession, controlEvents, ct);
var controlItem = AddEventItem(controlSubscription, ObjectIds.Server);
await controlSubscription.ApplyChangesAsync(ct);
rig.Transition("t1", active: true);
await WaitUntilAsync(() => keyEvents.Has(keyItem, "t1") && controlEvents.Has(controlItem, "t1"));
rig.Advance(TimeSpan.FromMinutes(20));
rig.Transition("t2", active: false);
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
keyEvents.Has(keyItem, "t2").ShouldBeFalse("the expired key's Server-object feed must stop");
state.Current.GenerationId.ShouldBe(1);
await controlSubscription.DeleteAsync(true, ct);
await keySubscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// With <c>Security:Acl:Enabled</c> false the Server object stays ungated: a session with no grant
/// receives the alarm exactly as a granted one does.
/// </summary>
/// <remarks>
/// Without this the tests above also pass on a build that withholds every Server-object event from
/// every session — the flag-off posture leaves reads and subscriptions ungated, and the alarm consumers
/// of every fleet that has not opted in depend on it.
/// </remarks>
[Fact]
public async Task With_acl_enforcement_off_a_server_object_subscription_receives_every_alarm()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclServerEventsOff", ct);
try
{
rig.Enforce(enabled: false, [UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
rig.Materialise();
using var granted = await rig.ConnectAsync(anonymous: false, ct);
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
var grantedEvents = new EventCollector();
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
await grantedSubscription.ApplyChangesAsync(ct);
var ungrantedEvents = new EventCollector();
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
await ungrantedSubscription.ApplyChangesAsync(ct);
rig.Transition("t1", active: true);
await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1") && ungrantedEvents.Has(ungrantedItem, "t1"));
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
ungrantedEvents.Count(ungrantedItem, "t1").ShouldBe(1);
await ungrantedSubscription.DeleteAsync(true, ct);
await grantedSubscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// A <c>ConditionRefresh</c> on a Server-object subscription replays only the conditions the session is
/// granted: the granted session gets the current state, the ungranted one gets the refresh brackets
/// and nothing between them.
/// </summary>
/// <remarks>
/// Refresh is the other way a Server-object item learns about a condition — it is how a client
/// connecting after a transition gets the current alarm picture — and it walks every condition in the
/// server. The ungranted session's <c>RefreshEndEvent</c> is the positive control on its own item: it
/// proves the refresh ran for that subscription and completed, so the missing condition is withheld,
/// not late.
/// </remarks>
[Fact]
public async Task A_condition_refresh_on_the_server_object_replays_only_granted_conditions()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclServerRefresh", ct);
try
{
rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
rig.Materialise();
// The transition precedes every subscription, so only a refresh can deliver it.
rig.Transition("before", active: true);
using var granted = await rig.ConnectAsync(anonymous: false, ct);
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
var grantedEvents = new EventCollector();
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
await grantedSubscription.ApplyChangesAsync(ct);
var ungrantedEvents = new EventCollector();
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
await ungrantedSubscription.ApplyChangesAsync(ct);
await RefreshAsync(granted, grantedSubscription, ct);
await RefreshAsync(ungranted, ungrantedSubscription, ct);
await WaitUntilAsync(() =>
grantedEvents.Has(grantedItem, "before")
&& ungrantedEvents.HasType(ungrantedItem, ObjectTypeIds.RefreshEndEventType));
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
ungrantedEvents.Has(ungrantedItem, "before").ShouldBeFalse(
"a refresh must not replay a condition the session is not granted");
await ungrantedSubscription.DeleteAsync(true, ct);
await grantedSubscription.DeleteAsync(true, ct);
}
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,
Name = KeyId,
SecretHash = "hash",
Principal = KeyPrincipal,
Enabled = true,
CreatedUtc = Start.AddDays(-1),
ExpiresUtc = expiresUtc,
};
private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) =>
await session.CallAsync(
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
private static NodeAcl EquipmentGrant(string equipment) => new()
{
NodeAclId = $"acl-{equipment}",
@@ -311,6 +906,7 @@ public sealed class AclEventSubscriptionEnforcementTests
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
var item = new MonitoredItem(subscription.DefaultItem)
{
@@ -335,11 +931,13 @@ public sealed class AclEventSubscriptionEnforcementTests
condition().ShouldBeTrue("condition not met within timeout");
}
/// <summary>Records which transition messages reached which monitored item.</summary>
/// <summary>Records which transition messages, and which event types, reached which monitored item.</summary>
private sealed class EventCollector
{
private readonly object _gate = new();
private readonly HashSet<(uint Handle, string Message)> _seen = [];
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)
{
@@ -351,14 +949,33 @@ 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
&& e.EventFields[EventTypeFieldIndex].Value is NodeId type)
{
_types.Add((e.ClientHandle, type));
}
}
}
}
public bool Has(MonitoredItem item, string message)
public bool Has(MonitoredItem item, string message) => Count(item, message) > 0;
public int Count(MonitoredItem item, string message)
{
lock (_gate) return _seen.Contains((item.ClientHandle, message));
lock (_gate) return _seen.Count(s => s.Handle == item.ClientHandle && s.Message == message);
}
public bool HasType(MonitoredItem item, NodeId type)
{
lock (_gate) return _types.Contains((item.ClientHandle, type));
}
public byte[] EventIdOf(MonitoredItem item, string message)
{
lock (_gate) return _eventIds[(item.ClientHandle, message)];
}
}
@@ -408,14 +1025,20 @@ public sealed class AclEventSubscriptionEnforcementTests
/// Switches enforcement on, wiring the authorizer, the authority source (the deployment trigger)
/// and the host's revalidation timer (the clock trigger) over one hand-advanced clock.
/// </summary>
public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants)
public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants) => Enforce(true, keys, grants);
/// <summary>
/// As <see cref="Enforce(ApiKey[], NodeAcl[])"/>, with <paramref name="enabled"/> standing in for
/// <c>Security:Acl:Enabled</c> — false wires the same grants with enforcement switched off.
/// </summary>
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock);
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, _clock);
// A long interval: the tick is driven by hand through the same component the host arms.
_timer = new AclRevalidationTimer(
@@ -437,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(