115 lines
5.3 KiB
C#
115 lines
5.3 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>Exercises the production session continuation store through actual HistoryRead requests.</summary>
|
|
public sealed class SessionHistoryContinuationLifecycleTests
|
|
{
|
|
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public async Task Tokens_are_single_use_and_cannot_cross_sessions()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartHistoryAsync(ct);
|
|
using var owner = await rig.ConnectAsync(anonymous: true, ct);
|
|
using var other = await rig.ConnectAsync(anonymous: true, ct);
|
|
var point = (await PageAsync(owner, default, false, ct)).ContinuationPoint;
|
|
point.IsEmpty.ShouldBeFalse();
|
|
|
|
(await PageAsync(other, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
var resumed = await PageAsync(owner, point, false, ct);
|
|
resumed.StatusCode.ShouldBe(StatusCodes.Good);
|
|
resumed.ContinuationPoint.IsEmpty.ShouldBeFalse();
|
|
(await PageAsync(owner, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Release_consumes_a_token_and_malformed_tokens_are_rejected()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartHistoryAsync(ct);
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var point = (await PageAsync(session, default, false, ct)).ContinuationPoint;
|
|
point.IsEmpty.ShouldBeFalse();
|
|
var released = await PageAsync(session, point, true, ct);
|
|
released.StatusCode.ShouldBe(StatusCodes.Good);
|
|
released.ContinuationPoint.IsEmpty.ShouldBeTrue();
|
|
(await PageAsync(session, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
(await PageAsync(session, ByteString.From(new byte[] { 1, 2, 3 }), false, ct))
|
|
.StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Session_capacity_evicts_the_oldest_point_and_keeps_the_newest()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartHistoryAsync(ct);
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var first = (await PageAsync(session, default, false, ct)).ContinuationPoint;
|
|
ByteString latest = default;
|
|
// The production host retains the SDK's cap of 100; issue 101 distinct initial reads.
|
|
for (var i = 0; i < 100; i++) latest = (await PageAsync(session, default, false, ct)).ContinuationPoint;
|
|
latest.IsEmpty.ShouldBeFalse();
|
|
(await PageAsync(session, first, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
(await PageAsync(session, latest, false, ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Closed_session_tokens_do_not_resume_on_a_new_session()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartHistoryAsync(ct);
|
|
ByteString point;
|
|
using (var old = await rig.ConnectAsync(anonymous: true, ct))
|
|
{
|
|
point = (await PageAsync(old, default, false, ct)).ContinuationPoint;
|
|
point.IsEmpty.ShouldBeFalse();
|
|
await old.CloseAsync(ct);
|
|
}
|
|
using var fresh = await rig.ConnectAsync(anonymous: true, ct);
|
|
(await PageAsync(fresh, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
}
|
|
|
|
private static async Task<ServerEventRig> StartHistoryAsync(CancellationToken ct)
|
|
{
|
|
var rig = await ServerEventRig.StartAsync("SessionHistoryLifecycle", encrypted: false, ct);
|
|
try
|
|
{
|
|
var events = Enumerable.Range(0, 25)
|
|
.Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i / 2), Epoch, "m", 500)).ToList();
|
|
rig.Nm.HistorianDataSource = new EventHistoryPagingOverTheWireTests.SqlLikeEventSource(events);
|
|
rig.MaterialiseNative();
|
|
rig.Enforce(false, [Key()]);
|
|
return rig;
|
|
}
|
|
catch
|
|
{
|
|
await rig.DisposeAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task<Opc.Ua.HistoryReadResult> PageAsync(ISession session, ByteString point, bool release, CancellationToken ct)
|
|
{
|
|
var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
var filter = new EventFilter
|
|
{
|
|
SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.EventId))],
|
|
};
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 10, Filter = filter,
|
|
};
|
|
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, release,
|
|
[new HistoryReadValueId { NodeId = node, ContinuationPoint = point }], ct);
|
|
return response.Results.ToList().ShouldHaveSingleItem();
|
|
}
|
|
}
|