test(717): deferred-event boundary, ACL-filtered promotion refresh, native-ack refusal, effective-URI XML load; docs + T13 overlay
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
- Wire: an event reported while Active but deferred (#838) is delivered after demotion; the promotion refresh withholds an ACL-denied condition (positive control alongside); ConditionRefresh2 and model-change events on the Standby; a native Acknowledge is refused and never routed; Warm validates and advertises the EFFECTIVE ApplicationUri from an ApplicationConfigPath XML load. - docs/Redundancy.md: Warm section, the Unset/None i=11314 behaviour (orphan readable by NodeId under Unset, deleted under None), instruments; OpcUaServer.md, Configuration.md, security.md, cluster-topology rule. - docker-dev/docker-compose.warm.yml for the T13 gate (central pair, Mode=Warm). - Plan: implementation status and the test gaps left for T13/follow-up. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -44,6 +44,9 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
|
||||
public string EndpointUrl => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
|
||||
|
||||
/// <summary>The SDK application configuration the host built (or loaded).</summary>
|
||||
internal ApplicationConfiguration HostConfiguration => _host.ApplicationInstance!.ApplicationConfiguration!;
|
||||
|
||||
/// <summary>The configured <c>OpcUa:ApplicationUri</c> (the effective one: the rig loads no XML).</summary>
|
||||
public string ApplicationUri => _hostOptions.ApplicationUri;
|
||||
|
||||
|
||||
+162
-2
@@ -293,12 +293,45 @@ public sealed class WarmRedundancyWireTests
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadEventIdAsync(ISession session, CancellationToken ct)
|
||||
private static async Task<byte[]> ReadEventIdAsync(ISession session, CancellationToken ct, bool native = false)
|
||||
{
|
||||
var eventId = await TranslateAsync(session, ConditionNodeId(session, native: false), BrowseNames.EventId, ct);
|
||||
var eventId = await TranslateAsync(session, ConditionNodeId(session, native), BrowseNames.EventId, ct);
|
||||
return ((ByteString)(await ReadAsync(session, eventId, ct)).WrappedValue.AsBoxedObject()!).ToArray()!;
|
||||
}
|
||||
|
||||
/// <summary>Owner answer Q4 for a NATIVE condition: refused on the Warm Standby ahead of the native-ack Primary gate,
|
||||
/// nothing routed to the driver; accepted once Active (positive control).</summary>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task A_native_Acknowledge_on_a_Warm_standby_is_refused_and_never_routed(bool standby)
|
||||
{
|
||||
var ct = Deadline();
|
||||
await using var rig = await StartModeAsync($"Warm717NativeAck{standby}", "Warm", ct);
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
var routed = 0;
|
||||
rig.Nm.NativeAlarmAckRouter = _ => Interlocked.Increment(ref routed);
|
||||
var projection = rig.Server.CreateStandbyProjection()!;
|
||||
Promote(projection);
|
||||
rig.Project(native: true, ConditionMethodEventTests.PreState("Acknowledge", native: true) with { Message = "base" });
|
||||
if (standby) Demote(projection);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var eventId = await ReadEventIdAsync(session, ct, native: true);
|
||||
var call = ConditionMethodEventTests.CallAsync(session, ConditionNodeId(session, native: true), "Acknowledge", eventId, ct);
|
||||
if (standby)
|
||||
{
|
||||
(await Should.ThrowAsync<ServiceResultException>(call)).StatusCode.ShouldBe(StatusCodes.BadInvalidState);
|
||||
routed.ShouldBe(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
await call;
|
||||
routed.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<NodeId> TranslateAsync(ISession session, NodeId start, string path, CancellationToken ct)
|
||||
{
|
||||
var elements = path.Split('/').Select(name => new RelativePathElement
|
||||
@@ -349,6 +382,16 @@ public sealed class WarmRedundancyWireTests
|
||||
events.Has(item, "flash").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Model-change events are not alarm events: still delivered on the Standby.
|
||||
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.GeneralModelChangeEventType));
|
||||
|
||||
// D5: ConditionRefresh2 on one item, then ConditionRefresh on the subscription, on the Standby: Start, the
|
||||
// retained condition, End.
|
||||
await session.CallAsync(ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh2, ct,
|
||||
subscription.Id, folderItem.Status.Id);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.HasType(folderItem, ObjectTypeIds.RefreshEndEventType));
|
||||
events.Has(folderItem, "while-standby").ShouldBeTrue("ConditionRefresh2 on the Standby returns the retained condition");
|
||||
// D5: a client ConditionRefresh on the Standby returns Start, the retained condition, End.
|
||||
await session.CallAsync(ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.RefreshEndEventType));
|
||||
@@ -397,6 +440,123 @@ public sealed class WarmRedundancyWireTests
|
||||
lock (routed) routed.ShouldHaveSingleItem().Operation.ShouldBe("Acknowledge");
|
||||
}
|
||||
|
||||
/// <summary>Spec §4.5 boundary: an event REPORTED while Active but still deferred (#838) at demotion is delivered.</summary>
|
||||
[Fact]
|
||||
public async Task An_event_reported_while_Active_but_deferred_is_still_delivered_after_demotion()
|
||||
{
|
||||
var ct = Deadline();
|
||||
await using var rig = await StartModeAsync("Warm717Deferred", "Warm", ct);
|
||||
var projection = rig.Server.CreateStandbyProjection()!;
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
Promote(projection);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var onA = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "seed"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "seed"));
|
||||
|
||||
using (var stall = NotifierStall.Hold(rig.Nm, EqA))
|
||||
{
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 710, Message: "reported-active"));
|
||||
rig.Nm.DeferredEventReports.ShouldBeGreaterThan(0, "the report must actually be deferred");
|
||||
Demote(projection);
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 720, Message: "reported-standby"));
|
||||
stall.Release();
|
||||
}
|
||||
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "reported-active"));
|
||||
await Task.Delay(500, ct);
|
||||
events.Has(onA, "reported-standby").ShouldBeFalse("a report made on the Standby is suppressed");
|
||||
}
|
||||
|
||||
/// <summary>The promotion refresh goes through the per-item ACL decision: a condition the session may not see is
|
||||
/// not replayed, while one it may see is (positive control).</summary>
|
||||
[Fact]
|
||||
public async Task The_promotion_refresh_withholds_an_ACL_denied_condition()
|
||||
{
|
||||
var ct = Deadline();
|
||||
await using var rig = await StartModeAsync("Warm717AclRefresh", "Warm", ct);
|
||||
var projection = rig.Server.CreateStandbyProjection()!;
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
rig.Enforce(true, [Key()], EquipmentGrant(EqB,
|
||||
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead));
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "native-visible"));
|
||||
rig.Project(native: false, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 500, Message: "scripted-denied"));
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
Promote(projection);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.RefreshEndEventType));
|
||||
events.Has(serverItem, "native-visible").ShouldBeTrue("positive control: a visible retained condition is replayed");
|
||||
events.Has(serverItem, "scripted-denied").ShouldBeFalse("an ACL-denied condition is withheld from the promotion refresh");
|
||||
}
|
||||
|
||||
/// <summary>T1: the redundancy check uses the EFFECTIVE ApplicationUri an ApplicationConfigPath XML load produces, not
|
||||
/// the options value (c7787): listing the options URI as a peer is fine, listing the XML URI fails start.</summary>
|
||||
[Fact]
|
||||
public async Task The_effective_application_uri_from_a_loaded_xml_configuration_is_what_Warm_validates_and_advertises()
|
||||
{
|
||||
var ct = Deadline();
|
||||
var seed = await ServerEventRig.StartAsync("Warm717XmlSeed", encrypted: false, ct);
|
||||
var saved = seed.HostConfiguration;
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"warm717-xml-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
var file = Path.Combine(dir, "OtOpcUa.Config.xml");
|
||||
var xmlUri = saved.ApplicationUri;
|
||||
try
|
||||
{
|
||||
var port = OpcUaHostStart.AllocateFreePort();
|
||||
saved.ServerConfiguration!.BaseAddresses = [$"opc.tcp://127.0.0.1:{port}/OtOpcUa"];
|
||||
saved.SaveToFile(file);
|
||||
|
||||
OpcUaApplicationHostOptions Options(string peer) => new()
|
||||
{
|
||||
ApplicationName = saved.ApplicationName ?? "OtOpcUa.Warm717Xml",
|
||||
ApplicationUri = "urn:OtOpcUa:options-value",
|
||||
ApplicationConfigPath = file,
|
||||
PublicHostname = "127.0.0.1",
|
||||
OpcUaPort = port,
|
||||
PkiStoreRoot = dir,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
PeerApplicationUris = [peer],
|
||||
Redundancy = new OpcUaRedundancyOptions { Mode = "Warm" },
|
||||
};
|
||||
|
||||
var refused = new OpcUaApplicationHost(Options(xmlUri!),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
(await Should.ThrowAsync<InvalidOperationException>(refused.StartAsync(new OtOpcUaSdkServer(), ct)))
|
||||
.Message.ShouldContain(xmlUri!);
|
||||
|
||||
await using var host = new OpcUaApplicationHost(Options("urn:OtOpcUa:options-value"),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, ct);
|
||||
var redundancy = server.CurrentInstance!.ServerObject.ServerRedundancy.ShouldBeOfType<NonTransparentRedundancyState>();
|
||||
var uris = redundancy.ServerUriArray!.Value.ToArray()!;
|
||||
uris.ShouldBe(new[] { xmlUri!, "urn:OtOpcUa:options-value" }, "self is the effective (XML) URI");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The seed stays up until here: the saved XML points at its PKI store (and certificate thumbprint).
|
||||
await seed.DisposeAsync();
|
||||
try { Directory.Delete(dir, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ T8 live items on transition
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user