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
@@ -32,9 +32,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
try
{
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
r.Data!.DataValues.Single().Value.ShouldBeOfType<float>().ShouldBe(2f);
r.Data.ModificationInfos.Single().UserName.ShouldBe("alice");
r.Data.ModificationInfos.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace);
SdkValueBoundary.ToClr(r.Data!.DataValues.ToArray()!.Single().WrappedValue)!.ShouldBeOfType<float>().ShouldBe(2f);
r.Data.ModificationInfos.ToArray()!.Single().UserName.ShouldBe("alice");
r.Data.ModificationInfos.ToArray()!.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace);
}
finally { await host.DisposeAsync(); }
}
@@ -47,8 +47,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
{
src.Throw = new System.IO.IOException("connection lost");
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
}
finally { await host.DisposeAsync(); }
}
@@ -67,8 +67,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 100, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
r.Cp.ShouldBeNull();
src.LastTag.ShouldBe(Tag);
src.LastStart.ShouldBe(Epoch);
@@ -79,16 +79,16 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
var data = r.Data!;
data.DataValues.Count.ShouldBe(3);
data.ModificationInfos.Count.ShouldBe(3);
data.ModificationInfos.Select(m => m.UpdateType)
data.ModificationInfos.ToArray()!.Select(m => m.UpdateType)
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Replace, HistoryUpdateType.Delete]);
data.ModificationInfos.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]);
data.ModificationInfos.Select(m => m.ModificationTime)
data.ModificationInfos.ToArray()!.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]);
data.ModificationInfos.ToArray()!.Select(m => m.ModificationTime)
.ShouldBe([modifiedAt, modifiedAt.AddMinutes(1), modifiedAt.AddMinutes(2)]);
data.DataValues[0].Value.ShouldBeNull();
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData);
data.DataValues[1].Value.ShouldBe(1.5);
SdkValueBoundary.ToClr(data.DataValues[0].WrappedValue)!.ShouldBeNull();
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData.Code);
SdkValueBoundary.ToClr(data.DataValues[1].WrappedValue)!.ShouldBe(1.5);
data.DataValues[1].SourceTimestamp.ShouldBe(Epoch.AddSeconds(10));
data.DataValues[2].Value.ShouldBe(7.0);
SdkValueBoundary.ToClr(data.DataValues[2].WrappedValue)!.ShouldBe(7.0);
await host.DisposeAsync();
}
@@ -97,9 +97,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
{
var (host, nm, node, _) = await SetupAsync([]);
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
r.Data!.ModificationInfos.ShouldBeEmpty();
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code);
r.Data!.ModificationInfos.Count.ShouldBe(0);
await host.DisposeAsync();
}
@@ -172,7 +172,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
{
// Inserts have no prior value; their snapshot has no source timestamp. The cursor must not fall back to MinValue.
var trail = Enumerable.Range(1, 6).Select(i => new HistoryModifiedValue(
new DataValueSnapshot(null, StatusCodes.BadNoData, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i,
new DataValueSnapshot(null, StatusCodes.BadNoData.Code, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i,
Epoch.AddSeconds(i))).ToList();
var (host, nm, node, src) = await SetupAsync(trail);
@@ -214,20 +214,20 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
// A RAW cursor for the same tag is not a modified cursor.
var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState(Tag, Epoch, Epoch.AddDays(1), 0, 4));
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: raw).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
// A MODIFIED cursor presented to a raw read is invalid too.
var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
p1.Cp.ShouldNotBeNull();
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: p1.Cp, modified: false).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
// A modified cursor issued for one node is invalid on another node's tag.
var p2 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
nm.EnsureVariable("eq-1/other", null, "Other", "Double", false, historianTagname: "WW.Other", realm: AddressSpaceRealm.Uns);
var other = nm.TryGetVariable("eq-1/other")!.NodeId;
Read(nm, other, Epoch, Epoch.AddHours(1), max: 4, cp: p2.Cp).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
await host.DisposeAsync();
}
@@ -245,9 +245,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
var modified = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
var raw = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null, modified: false);
modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
modified.Data!.DataValues.Count.ShouldBe(1);
raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState);
raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState.Code);
src.Calls.ShouldBe(1);
consulted.ShouldBe(0);
await host.DisposeAsync();
@@ -259,7 +259,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
src.Throw = new InvalidOperationException("boom");
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
await host.DisposeAsync();
}
@@ -270,8 +270,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
{
var t = Epoch.AddSeconds(seconds);
var value = prior is null
? new DataValueSnapshot(null, StatusCodes.BadNoData, t, modifiedAt)
: new DataValueSnapshot(prior.Value, StatusCodes.Good, t, t);
? new DataValueSnapshot(null, StatusCodes.BadNoData.Code, t, modifiedAt)
: new DataValueSnapshot(prior.Value, StatusCodes.Good.Code, t, t);
return new HistoryModifiedValue(value, modifiedAt, type, user, id, t);
}
@@ -283,7 +283,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
do
{
var r = Read(nm, node, start, end, max, cp);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, r.Error.ToString());
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, r.Error.ToString());
ids.AddRange(r.Ids);
cp = r.Cp;
(++pages).ShouldBeLessThan(60, "paging must terminate");
@@ -295,25 +295,25 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
private static (ServiceResult Error, SdkHistoryReadResult Result, HistoryModifiedData? Data, byte[]? Cp, List<long> Ids) Read(
OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max, byte[]? cp, bool modified = true)
{
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
var results = new List<SdkHistoryReadResult> { null! };
var errors = new List<ServiceResult> { null! };
nm.HistoryRead(context,
new ReadRawModifiedDetails { ReturnBounds = false, StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified },
TimestampsToReturn.Both, false,
new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = cp } }, results, errors);
new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = ByteString.From(cp) } }, results, errors);
HistoryModifiedData? data = null;
var ids = new List<long>();
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)
&& ExtensionObject.ToEncodeable(ho) is HistoryModifiedData hm)
if (results[0]?.HistoryData is { } ho && !ho.IsNull
&& ExtensionObject.ToEncodeable(ho)! is HistoryModifiedData hm)
{
data = hm;
// The fake stores each modification's id in its (unique) server timestamp offset — see SqlLikeTrail.
ids.AddRange(hm.DataValues.Select(v => SqlLikeTrail.IdOf(v)));
ids.AddRange(hm.DataValues.ToArray()!.Select(v => SqlLikeTrail.IdOf(v)));
}
return (errors[0], results[0], data, results[0]?.ContinuationPoint, ids);
return (errors[0], results[0], data, results[0]?.ContinuationPoint.ToNullableArray(), ids);
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync(
@@ -355,7 +355,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
public int Calls { get; private set; }
public Exception? Throw { get; set; }
public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks;
public static long IdOf(DataValue v) => v.ServerTimestamp.ToDateTime().Ticks - Epoch.Ticks;
public IEnumerable<HistoryModifiedValue> Ordered(bool reverse) => reverse
? trail.OrderByDescending(m => m.SourceTimeUtc).ThenByDescending(m => m.ModifiedId)