test: preserve server SDK 2.0 value security and history contracts

This commit is contained in:
Joseph Doherty
2026-11-10 10:37:52 -05:00
parent ac5e9d84f0
commit 9cddc9306b
45 changed files with 1215 additions and 641 deletions
@@ -71,26 +71,26 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
fake.LastEnd.ShouldBe(end);
fake.LastMaxEvents.ShouldBe(50);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
history.Events.Count.ShouldBe(1);
var fields = history.Events[0].EventFields;
fields.Count.ShouldBe(6);
// EventId ⇒ ByteString (UTF-8 of "evt-42").
fields[0].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42"));
SdkValueBoundary.ToClr(fields[0])!.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42"));
// SourceName ⇒ string.
fields[1].Value.ShouldBe("Pump_001");
SdkValueBoundary.ToClr(fields[1])!.ShouldBe("Pump_001");
// Time ⇒ DateTime (event occurrence time).
fields[2].Value.ShouldBe(evtTime);
SdkValueBoundary.ToClr(fields[2])!.ShouldBe(evtTime);
// ReceiveTime ⇒ DateTime (server receipt time = ReceivedTimeUtc).
fields[3].Value.ShouldBe(rcvTime);
SdkValueBoundary.ToClr(fields[3])!.ShouldBe(rcvTime);
// Message ⇒ LocalizedText.
fields[4].Value.ShouldBeOfType<LocalizedText>().Text.ShouldBe("Pump tripped");
SdkValueBoundary.ToClr(fields[4])!.ShouldBeOfType<LocalizedText>().Text.ShouldBe("Pump tripped");
// Severity ⇒ UInt16.
fields[5].Value.ShouldBe((ushort)700);
SdkValueBoundary.ToClr(fields[5])!.ShouldBe((ushort)700);
await host.DisposeAsync();
}
@@ -126,7 +126,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
// The backend saw the unbounded cap, NOT the 0/default-cap sentinel.
fake.LastMaxEvents.ShouldBe(int.MaxValue);
@@ -163,14 +163,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
var fields = history.Events[0].EventFields;
fields.Count.ShouldBe(2);
// LocalTime ⇒ Variant.Null.
fields[0].Value.ShouldBeNull();
SdkValueBoundary.ToClr(fields[0])!.ShouldBeNull();
// EventId still projects to a ByteString.
fields[1].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
SdkValueBoundary.ToClr(fields[1])!.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
await host.DisposeAsync();
}
@@ -203,10 +203,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
history.Events.ShouldBeEmpty();
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code);
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
history.Events.Count.ShouldBe(0);
await host.DisposeAsync();
}
@@ -236,14 +236,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
};
var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId);
unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
var fake = new RecordingHistorianDataSource();
nm.HistorianDataSource = fake;
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
fake.EventsCalled.ShouldBeTrue();
fake.LastSourceName.ShouldBe(equipmentId);
@@ -282,8 +282,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
};
var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard");
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code, "set by the override's guard");
fake.EventsCalled.ShouldBeFalse();
await host.DisposeAsync();
@@ -327,7 +327,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
fake.EventsCalled.ShouldBeFalse();
await host.DisposeAsync();
@@ -365,10 +365,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
// Authoritative per-node signal: errors[0] must be Bad.
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication.Code : StatusCodes.BadHistoryOperationUnsupported.Code);
// The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding).
results[0].ShouldNotBeNull();
results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication.Code : StatusCodes.BadHistoryOperationUnsupported.Code);
// The source WAS entered (proving the override reached the bridge and the throw was swallowed).
fake.EventsEntered.ShouldBeTrue();
// ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path.
@@ -382,15 +382,17 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
private static EventFilter SelectFilter(params string[] leafFieldNames)
{
var filter = new EventFilter();
var clauses = new List<SimpleAttributeOperand>();
foreach (var name in leafFieldNames)
{
filter.SelectClauses.Add(
clauses.Add(
new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(name))
{
AttributeId = Attributes.Value,
});
}
filter.SelectClauses = clauses;
return filter;
}
@@ -400,7 +402,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId } };
var results = new List<SdkHistoryReadResult> { null! };