fe92ff4c34
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
1198 lines
57 KiB
C#
1198 lines
57 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #562 over the wire — an <b>alarm</b> (event) subscription is re-authorized by both revalidation
|
|
/// triggers, exactly as a data-change subscription is: a deployment that removes its grant, and the host's
|
|
/// clock-driven <see cref="AclRevalidationTimer"/> once the session's key expires.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Before #562 an event monitored item was gated at creation and never again, so an alarm feed
|
|
/// survived the revocation of the authority it was admitted under until the client disconnected.
|
|
/// </para>
|
|
/// <para>
|
|
/// Over the wire for the reason <c>AclSubscriptionEnforcementTests</c> gives: whether an event item
|
|
/// can recover the identity of the session that created it, and whether detaching it from the SDK's
|
|
/// event fan-out really stops delivery (and re-attaching really resumes it), are assumptions about SDK
|
|
/// behaviour that fail silently if wrong.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every absence assertion is paired with a <b>positive control on the same event</b>: one condition is
|
|
/// wired to two equipment notifiers (or watched by two sessions), so a single transition must reach the
|
|
/// still-authorized item before the denied one's silence counts for anything.
|
|
/// </para>
|
|
/// </remarks>
|
|
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; [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);
|
|
|
|
/// <summary>
|
|
/// The deployment axis, end to end: revoke → the alarm feed stops (while the still-granted control
|
|
/// item receives the same event) → restore → the feed resumes with no client action.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The restore leg is the reason the stand-down is a detach rather than
|
|
/// <c>MonitoringMode.Disabled</c>: a disabled item can only be re-enabled by the client, and a machine
|
|
/// consumer never will.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Revoking_then_restoring_a_grant_stops_then_resumes_a_live_alarm_subscription()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await Rig.StartAsync("AclEventRevoke", ct);
|
|
try
|
|
{
|
|
var state = rig.Enforce(EquipmentGrant(EqA), EquipmentGrant(EqB));
|
|
rig.Materialise();
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var collector = new EventCollector();
|
|
var subscription = await CreateSubscriptionAsync(session, collector, ct);
|
|
var onA = AddEventItem(subscription, rig.UnsNodeId(session, EqA));
|
|
var onB = AddEventItem(subscription, rig.UnsNodeId(session, EqB));
|
|
await subscription.ApplyChangesAsync(ct);
|
|
onA.Status.Error.ShouldBeNull();
|
|
onB.Status.Error.ShouldBeNull();
|
|
|
|
// Baseline: both items receive the transition.
|
|
rig.Transition("t1", active: true);
|
|
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
|
|
|
|
// The next deployment carries only EqB's grant.
|
|
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
|
|
rig.AttachedEventItems.ShouldBe(1);
|
|
|
|
rig.Transition("t2", active: false);
|
|
// Positive control: the same event reached the still-granted item, so it was fired and published…
|
|
await WaitUntilAsync(() => collector.Has(onB, "t2"));
|
|
// …and settle past a publishing interval so a late copy for EqA would have arrived too.
|
|
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
|
collector.Has(onA, "t2").ShouldBeFalse("the revoked item must not receive events after the deployment");
|
|
|
|
// A deployment that restores the grant brings the feed back on its own.
|
|
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
|
|
rig.AttachedEventItems.ShouldBe(2);
|
|
|
|
rig.Transition("t3", active: true);
|
|
await WaitUntilAsync(() => collector.Has(onA, "t3") && collector.Has(onB, "t3"));
|
|
collector.Has(onA, "t2").ShouldBeFalse("nothing withheld while stood down is replayed on restore");
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A client may delete an item while it is stood down: the delete succeeds, and a later deployment
|
|
/// that would have restored it does not resurrect it onto the notifier's fan-out.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The SDK's own unsubscribe would try to remove an item that is no longer attached, so the node
|
|
/// manager short-circuits it. Asserted over the wire because the failure — an exception inside the
|
|
/// SDK's delete path, or a ghost item still receiving events — is only observable there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_stood_down_alarm_item_can_be_deleted_and_is_not_resurrected()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await Rig.StartAsync("AclEventDelete", ct);
|
|
try
|
|
{
|
|
var state = rig.Enforce(EquipmentGrant(EqA), EquipmentGrant(EqB));
|
|
rig.Materialise();
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var collector = new EventCollector();
|
|
var subscription = await CreateSubscriptionAsync(session, collector, ct);
|
|
var onA = AddEventItem(subscription, rig.UnsNodeId(session, EqA));
|
|
var onB = AddEventItem(subscription, rig.UnsNodeId(session, EqB));
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Transition("t1", active: true);
|
|
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
|
|
|
|
rig.AttachedEventItems.ShouldBe(2);
|
|
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
|
|
rig.AttachedEventItems.ShouldBe(1);
|
|
|
|
subscription.RemoveItem(onA);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
onA.Status.Error.ShouldBeNull();
|
|
|
|
// A restoring deployment must not re-attach the deleted item. The client could never see that
|
|
// happen — it has deleted the item — so the server-side fan-out is what is asserted.
|
|
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
|
|
rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out");
|
|
|
|
rig.Transition("t2", active: false);
|
|
await WaitUntilAsync(() => collector.Has(onB, "t2"));
|
|
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
|
collector.Has(onA, "t2").ShouldBeFalse();
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// The clock axis: the key behind a session's alarm subscription expires, one revalidation tick runs
|
|
/// through the host's own <see cref="AclRevalidationTimer"/>, and that session stops receiving alarms
|
|
/// — with no deployment anywhere in the run — while an independent session on the same notifier keeps
|
|
/// receiving the same event.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_expired_key_stops_a_live_alarm_subscription_with_no_deploy()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await Rig.StartAsync("AclEventExpiry", ct);
|
|
try
|
|
{
|
|
var key = new ApiKey
|
|
{
|
|
ApiKeyId = KeyId,
|
|
Name = KeyId,
|
|
SecretHash = "hash",
|
|
Principal = KeyPrincipal,
|
|
Enabled = true,
|
|
CreatedUtc = Start.AddDays(-1),
|
|
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, rig.UnsNodeId(keySession, EqA));
|
|
await keySubscription.ApplyChangesAsync(ct);
|
|
keyItem.Status.Error.ShouldBeNull();
|
|
|
|
var controlEvents = new EventCollector();
|
|
var controlSubscription = await CreateSubscriptionAsync(controlSession, controlEvents, ct);
|
|
var controlItem = AddEventItem(controlSubscription, rig.UnsNodeId(controlSession, EqA));
|
|
await controlSubscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Transition("t1", active: true);
|
|
await WaitUntilAsync(() => keyEvents.Has(keyItem, "t1") && controlEvents.Has(controlItem, "t1"));
|
|
|
|
// Only the clock moves.
|
|
rig.Advance(TimeSpan.FromMinutes(20));
|
|
rig.TickRevalidation();
|
|
|
|
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 alarm feed must stop on the tick");
|
|
state.Current.GenerationId.ShouldBe(1);
|
|
|
|
await controlSubscription.DeleteAsync(true, ct);
|
|
await keySubscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// The negative control for the clock axis: the same run with the clock advanced but not past
|
|
/// <c>ExpiresUtc</c> keeps the alarm feed live across a tick.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Without it the test above also passes on a build whose tick stands down every event item it sees,
|
|
/// which would silence every alarm consumer in the fleet once a minute.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task An_unexpired_key_keeps_its_alarm_subscription_across_a_tick()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await Rig.StartAsync("AclEventLive", ct);
|
|
try
|
|
{
|
|
var key = new ApiKey
|
|
{
|
|
ApiKeyId = KeyId,
|
|
Name = KeyId,
|
|
SecretHash = "hash",
|
|
Principal = KeyPrincipal,
|
|
Enabled = true,
|
|
CreatedUtc = Start.AddDays(-1),
|
|
ExpiresUtc = Start.AddHours(4),
|
|
};
|
|
rig.Enforce([key], ClusterGrant(KeyPrincipal, "acl-key"));
|
|
rig.Materialise();
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new EventCollector();
|
|
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
|
var item = AddEventItem(subscription, rig.UnsNodeId(session, EqA));
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Transition("t1", active: true);
|
|
await WaitUntilAsync(() => events.Has(item, "t1"));
|
|
|
|
rig.Advance(TimeSpan.FromMinutes(20));
|
|
rig.TickRevalidation();
|
|
|
|
rig.Transition("t2", active: false);
|
|
await WaitUntilAsync(() => events.Has(item, "t2"));
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
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}",
|
|
ClusterId = Cluster,
|
|
Principal = OpcUaPrincipals.Anonymous,
|
|
ScopeKind = NodeAclScopeKind.Equipment,
|
|
ScopeId = $"eq-{equipment}",
|
|
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
|
|
};
|
|
|
|
private static NodeAcl ClusterGrant(string principal, string id) => new()
|
|
{
|
|
NodeAclId = id,
|
|
ClusterId = Cluster,
|
|
Principal = principal,
|
|
ScopeKind = NodeAclScopeKind.Cluster,
|
|
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
|
|
};
|
|
|
|
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
|
{
|
|
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])],
|
|
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])],
|
|
};
|
|
|
|
private static async Task<Subscription> CreateSubscriptionAsync(
|
|
ISession session, EventCollector collector, CancellationToken ct)
|
|
{
|
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
|
subscription.FastEventCallback = collector.OnEvents;
|
|
session.AddSubscription(subscription);
|
|
await subscription.CreateAsync(ct);
|
|
return subscription;
|
|
}
|
|
|
|
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
|
|
{
|
|
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)
|
|
{
|
|
StartNodeId = source,
|
|
AttributeId = Attributes.EventNotifier,
|
|
Filter = filter,
|
|
QueueSize = 100,
|
|
SamplingInterval = 0,
|
|
};
|
|
subscription.AddItem(item);
|
|
return item;
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition)
|
|
{
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (condition()) return;
|
|
await Task.Delay(50);
|
|
}
|
|
condition().ShouldBeTrue("condition not met within timeout");
|
|
}
|
|
|
|
/// <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 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)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
foreach (var e in notification.Events)
|
|
{
|
|
if (e.EventFields.Count > MessageFieldIndex
|
|
&& 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) => Count(item, message) > 0;
|
|
|
|
public int Count(MonitoredItem item, string 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)];
|
|
}
|
|
}
|
|
|
|
/// <summary>A booted server with ACL enforcement, one native alarm wired to two equipment notifiers.</summary>
|
|
private sealed class Rig : IAsyncDisposable
|
|
{
|
|
private readonly string _pkiRoot;
|
|
private readonly FakeClock _clock = new(Start);
|
|
private OpcUaApplicationHost _host = null!;
|
|
private OtOpcUaSdkServer _server = null!;
|
|
private SdkAddressSpaceSink _sink = null!;
|
|
private AclRevalidationTimer? _timer;
|
|
private int _port;
|
|
|
|
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
|
|
|
|
private OtOpcUaNodeManager Nm => _server.NodeManager!;
|
|
|
|
public static async Task<Rig> StartAsync(string name, CancellationToken ct)
|
|
{
|
|
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
|
|
rig._port = AllocateFreePort();
|
|
rig._server = new OtOpcUaSdkServer();
|
|
rig._host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = $"OtOpcUa.{name}",
|
|
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
|
|
OpcUaPort = rig._port,
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = rig._pkiRoot,
|
|
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance,
|
|
new KeyPresentingAuthenticator());
|
|
|
|
await rig._host.StartAsync(rig._server, ct);
|
|
rig._sink = new SdkAddressSpaceSink(rig.Nm);
|
|
return rig;
|
|
}
|
|
|
|
/// <summary>Switches enforcement on with the given grants and no keys.</summary>
|
|
public DataPlaneAuthorityState Enforce(params NodeAcl[] grants) => Enforce([], grants);
|
|
|
|
/// <summary>
|
|
/// 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) => 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, NullLogger.Instance, _clock);
|
|
|
|
// A long interval: the tick is driven by hand through the same component the host arms.
|
|
_timer = new AclRevalidationTimer(
|
|
() => _server.NodeManager?.RevalidateMonitoredItems(),
|
|
TimeSpan.FromHours(1),
|
|
TimeProvider.System,
|
|
NullLogger.Instance);
|
|
return state;
|
|
}
|
|
|
|
/// <summary>One native condition on a raw device, wired as an event source of two equipment folders.</summary>
|
|
public void Materialise()
|
|
{
|
|
_sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
_sink.MaterialiseAlarmCondition(
|
|
RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
|
|
_sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns);
|
|
_sink.EnsureFolder(EqB, parentNodeId: null, displayName: "B", realm: AddressSpaceRealm.Uns);
|
|
_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(
|
|
RawAlarmPath,
|
|
new AlarmConditionSnapshot(
|
|
Active: active, Acknowledged: !active, Confirmed: true, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message),
|
|
DateTime.UtcNow,
|
|
AddressSpaceRealm.Raw);
|
|
|
|
public NodeId UnsNodeId(ISession session, string id)
|
|
{
|
|
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
|
ns.ShouldBeGreaterThan((ushort)0);
|
|
return new NodeId(id, ns);
|
|
}
|
|
|
|
/// <summary>Event items attached to the fan-out of the manager's own notifiers.</summary>
|
|
public int AttachedEventItems => Nm.AttachedEventItemCount;
|
|
|
|
public void Advance(TimeSpan by) => _clock.Advance(by);
|
|
|
|
public void TickRevalidation() => _timer!.Tick();
|
|
|
|
public async Task<ISession> ConnectAsync(bool anonymous, CancellationToken ct)
|
|
{
|
|
var appConfig = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUa.AclEventClient",
|
|
ApplicationUri = $"urn:OtOpcUa.AclEventClient.{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
};
|
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
|
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
|
|
|
var identity = anonymous
|
|
? new UserIdentity(new AnonymousIdentityToken())
|
|
: new UserIdentity(new UserNameIdentityToken
|
|
{
|
|
UserName = "apikey",
|
|
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
|
|
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
|
|
});
|
|
|
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
|
appConfig,
|
|
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
|
updateBeforeConnect: false,
|
|
checkDomain: false,
|
|
sessionName: "AclEventSubscriptionEnforcementTests",
|
|
sessionTimeout: 60_000,
|
|
identity: identity,
|
|
preferredLocales: null,
|
|
ct: ct);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_timer?.Dispose();
|
|
await _host.DisposeAsync();
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch (IOException) { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
private static int AllocateFreePort()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Admits the fixed <c>apikey</c> username as <see cref="KeyPrincipal"/> carrying
|
|
/// <see cref="KeyId"/>; anonymous falls through to the interface default (the
|
|
/// <c>anonymous</c> principal). Credential verification itself is covered in the Host suite.
|
|
/// </summary>
|
|
private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
|
string username, string password, CancellationToken ct) =>
|
|
Task.FromResult(
|
|
string.Equals(username, "apikey", StringComparison.Ordinal)
|
|
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
|
|
? OpcUaUserAuthResult.AllowKey(KeyPrincipal, [], KeyId)
|
|
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
|
}
|
|
|
|
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
|
|
private sealed class FakeClock(DateTime utcNow) : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
|
|
|
/// <inheritdoc />
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
}
|
|
}
|