167a48bffa
#587 — a client Acknowledge/Confirm/AddComment/Shelve/Enable/Disable emitted no condition event: the SDK's ReportStateChange is gated on AutoReportStateChanges (never set), and the engine re-projection found the node already in the new state and was suppressed by the delta-gate. WireMethodTransitionReporting now wraps each method's SDK handler and, on Good, reports exactly once via ReportConditionEvent, under Lock so the routed re-projection cannot race in ahead of the SDK applying the state. Independent of the SDK's imprecise AreEventsMonitored bookkeeping. The shelve methods' state is applied by the wrapper (the SDK never applies it). Native conditions' Confirmed/Shelving are method-owned (NativeAlarmProjector's constants would re-report every ack and undo every shelve). MaxTimeShelved set to int.MaxValue ms at materialise — Create() left it 0 and every TimedShelve failed BadShelvingTimeOutOfRange. Wrong "SDK auto-fires (E2)" comments fixed. #588 — with ACL on, GeneralModelChange and AuditWriteUpdate events no longer go to Server.ReportEvent (ungated to every i=2253 item): ReportToServerObject delivers them per item, a model change narrowed to the Affected nodes the session may Browse (dropped when none), an audit write only with Read on its source plus the SDK audit/encryption guard. Flag off: unchanged. #586 review: materialise raises only the new condition's monitored unit (was a full O(N) reconcile per condition ⇒ O(N²) deploys under Lock). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
1199 lines
57 KiB
C#
1199 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 audit
|
|
/// events. 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: method-driven state-change
|
|
/// events no longer depend on it (Gitea #587, <c>ConditionMethodEventTests</c>), and audit events only
|
|
/// leave the server while it is auditing.
|
|
/// </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);
|
|
}
|
|
}
|
|
}
|