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
@@ -102,7 +102,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
Count("oracle refused");
continue;
}
truthError.StatusCode.Code.ShouldBe(StatusCodes.Good, $"oracle: {tag}");
truthError.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"oracle: {tag}");
truthCp.ShouldBeNull($"oracle (no cap, N = 0) is one page: {tag}");
if (!bounds)
Render(truth).ShouldBe(Render(Oracle(series, start, end, direction, resolution)), $"oracle vs series: {tag}");
@@ -124,8 +124,8 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
// A boundary tie cluster as large as the cap leaves a resumed page no room: the server steps past it with one
// empty page carrying a continuation point (the documented #694 ceiling behaviour) — the one empty page allowed.
var oversizedClusters = cap > 0
? truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
.GroupBy(v => v.SourceTimestamp.Ticks).Count(g => g.Count() >= Math.Max(2, cap - 1))
? truth.Where(v => !(SdkValueBoundary.ToClr(v.WrappedValue)! is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
.GroupBy(v => v.SourceTimestamp.ToDateTime().Ticks).Count(g => g.Count() >= Math.Max(2, cap - 1))
: 0;
// The read under test.
@@ -163,7 +163,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
continue;
}
status.Code.ShouldBe(StatusCodes.Good, $"{tag}");
status.Code.ShouldBe(StatusCodes.Good.Code, $"{tag}");
Render(collected).ShouldBe(Render(truth), $"paged ≠ unpaged: {tag} pages {Sizes(pages)}");
if (cap > 0)
{
@@ -179,8 +179,8 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
{
if (pages[i].Count > 0) continue;
// Allowed only as the step past a boundary tie cluster the cap cannot page within.
var previousLast = i > 0 && pages[i - 1].Count > 0 ? pages[i - 1][^1].SourceTimestamp.Ticks : (long?)null;
var clusterAtBoundary = previousLast is { } t ? truth.Count(v => v.SourceTimestamp.Ticks == t) : 0;
var previousLast = i > 0 && pages[i - 1].Count > 0 ? pages[i - 1][^1].SourceTimestamp.ToDateTime().Ticks : (long?)null;
var clusterAtBoundary = previousLast is { } t ? truth.Count(v => v.SourceTimestamp.ToDateTime().Ticks == t) : 0;
(cap > 0 && clusterAtBoundary >= Math.Max(2, cap - 1)).ShouldBeTrue(
$"an empty page with a continuation point (page {i}, boundary cluster {clusterAtBoundary}): {tag} pages {Sizes(pages)}");
Count("empty step over an oversized tie cluster");
@@ -259,7 +259,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
{
var ts = Trunc(new DateTime(t, DateTimeKind.Utc), resolution);
var status = rng.Next(12) == 0 ? StatusCodes.Bad : StatusCodes.Good;
list.Add(new DataValueSnapshot(status == StatusCodes.Bad ? null : (double)v, status, ts, ts));
list.Add(new DataValueSnapshot(status == StatusCodes.Bad.Code ? null : (double)v, status.Code, ts, ts));
v++;
}
}
@@ -302,13 +302,13 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
Direction.Reverse => series.Where(x => Ts(x) > e && Ts(x) <= s).Reverse(),
_ => series.Where(x => Ts(x) <= e).Reverse(),
};
return window.Select(x => new DataValue(new Variant(x.Value), new StatusCode(x.StatusCode), Ts(x), Ts(x))).ToList();
return window.Select(x => new DataValue(SdkValueBoundary.FromClr(x.Value), new StatusCode(x.StatusCode), Ts(x), Ts(x))).ToList();
}
/// <summary>The largest tie cluster among the returned stored values (a bound placeholder is not a stored value).</summary>
private static int LargestTieCluster(IReadOnlyList<DataValue> truth) =>
truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
.GroupBy(v => v.SourceTimestamp.Ticks).Select(g => g.Count()).DefaultIfEmpty(0).Max();
truth.Where(v => !(SdkValueBoundary.ToClr(v.WrappedValue)! is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
.GroupBy(v => v.SourceTimestamp.ToDateTime().Ticks).Select(g => g.Count()).DefaultIfEmpty(0).Max();
/// <summary>The most samples within one millisecond anywhere in the series (the walk-back's refusal rule).</summary>
private static int MillisecondMax(List<DataValueSnapshot> series)
@@ -334,24 +334,24 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
private static string Sizes(IEnumerable<IReadOnlyList<DataValue>> pages) => "[" + string.Join(",", pages.Select(p => p.Count)) + "]";
private static string Render(IEnumerable<DataValue> values) =>
string.Join(" | ", values.Select(v => $"{v.SourceTimestamp.Ticks}={v.Value ?? "null"}/{v.StatusCode.Code:X8}"));
string.Join(" | ", values.Select(v => $"{v.SourceTimestamp.ToDateTime().Ticks}={SdkValueBoundary.ToClr(v.WrappedValue)! ?? "null"}/{v.StatusCode.Code:X8}"));
private (IReadOnlyList<DataValue> Values, ServiceResult Error, byte[]? Cp) Read(DateTime start, DateTime end, uint max, bool bounds, byte[]? cp)
{
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 details = new ReadRawModifiedDetails
{
StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = false,
};
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = cp } };
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = ByteString.From(cp) } };
var results = new List<SdkHistoryReadResult> { null! };
var errors = new List<ServiceResult> { null! };
fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
var values = new List<DataValue>();
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues);
return (values, errors[0], results[0]?.ContinuationPoint);
if (results[0]?.HistoryData is { } ho && !ho.IsNull)
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)!).DataValues);
return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray());
}
/// <summary>