f966efcfad
C1 (critical): B6 computes the plan off the pinned thread while B1 made writes non-creating, so a value/status/alarm write for a node the in-flight plan adds was skipped, and change-driven drivers never republish it (BadWaitingForInitialData indefinitely; on a full rebuild every re-subscribe value landed on discarded nodes). OpcUaPublishActor now applies writes immediately AND remembers the latest per (realm, id) while a plan is in flight or queued (value+status compose; alarm state/quality coalesce to the latest, which the node's delta gate makes inert on an existing condition), replays it after each apply before answering, and drops it once the pipeline is idle. Bounded (250k keys by default; beyond it new keys are not held, logged once). I2: the data-plane-authority parse runs in its own try inside the plan task; a throw is logged and never degrades the address-space apply. I3: the SDK's DeleteMonitoredItems releases an item's component-cache reference on a Good delete; a tombstoned item's reference was already released, so the delete took one from the re-created node's entry. Released items are remembered and the orphan delete takes one back first. Event items never held a reference (EventManager + SubscribeToEvents), so the three event-item releases are gone. I4: direct tests for the HistoryUpdate override's per-request updater check and the HistoryReadEvents override's source guard (both reached by restoring the bit after a reset - the B7 race window); fixed the false remark. Minors: the MaterialiseAlarmCondition kind-swap tombstones the dropped instance; a bytes-less rebuild arriving mid-plan queues behind it (replies in order); a PlanReady/PlanFailed from a previous actor incarnation is ignored and its requester answered degraded; the tie-cluster end bound of an uncapped read fits against the backend ceiling (defence in depth - guarded by a sweep test); the lock-time benchmark stays in the default run, headroom noted. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
1065 lines
51 KiB
C#
1065 lines
51 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.History;
|
|
|
|
/// <summary>
|
|
/// OPC UA HistoryUpdate on <see cref="OtOpcUaNodeManager"/> (pluggable historian, spec §5), driven through
|
|
/// the SDK's PUBLIC <c>HistoryUpdate(OperationContext, Type, …)</c> entry point of a real booted server —
|
|
/// so every test also exercises the base's admission check, which admits a variable only when its
|
|
/// AccessLevel carries <c>HistoryWrite</c>. A test that called the protected arms directly would pass
|
|
/// against a node manager that never set that bit and therefore never served a single update.
|
|
/// </summary>
|
|
public sealed class HistoryUpdateTests : IDisposable
|
|
{
|
|
private const string Cluster = "SITE-A";
|
|
private const string Principal = "historian-editor";
|
|
private const string KeyId = "k-hu";
|
|
private const string RawPath = "Plant/Modbus/dev1/Speed";
|
|
private const string Tagname = "WW.Speed";
|
|
|
|
private static readonly DateTime Start = new(2026, 10, 9, 12, 0, 0, DateTimeKind.Utc);
|
|
private static readonly DateTime T1 = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
private static readonly DateTime T2 = T1.AddMinutes(1);
|
|
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-hu-{Guid.NewGuid():N}");
|
|
|
|
// ───────────────────────────── authorization ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// THE load-bearing ALLOW: ACL flag ON, a session carrying NO roles, a Cluster-scoped
|
|
/// <c>HistoryUpdate</c> grant ⇒ the insert reaches the updater and the item reports
|
|
/// <c>GoodEntryInserted</c>. A deny-only test would pass against a build that never consulted the ACL,
|
|
/// because the role gate denies a roleless session too.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Acl_on_a_roleless_session_with_a_scoped_grant_inserts()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: true, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, errors) = Insert(nm, KeyIdentity(), nodeId, Value(T1, 42.5));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
|
var call = updater.UpdateCalls.ShouldHaveSingleItem();
|
|
call.Tag.ShouldBe(Tagname);
|
|
call.Mode.ShouldBe(HistoryUpdateMode.Insert);
|
|
var sent = call.Values.ShouldHaveSingleItem();
|
|
sent.TimestampUtc.ShouldBe(T1);
|
|
sent.Value.ShouldBe(HistorianValue.FromDouble(42.5));
|
|
sent.StatusCode.ShouldBe(StatusCodes.Good);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>ACL on: a grant without the HistoryUpdate bit (even one carrying write + history read) is refused.</summary>
|
|
[Fact]
|
|
public async Task Acl_on_a_grant_without_the_bit_is_denied()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: true, NodePermissions.WriteOperate | NodePermissions.HistoryRead);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>ACL off: the dedicated <c>HistoryUpdate</c> role admits the update.</summary>
|
|
[Fact]
|
|
public async Task Acl_off_the_history_update_role_is_allowed()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
|
updater.UpdateCalls.Count.ShouldBe(1);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>ACL off: <c>WriteOperate</c> is NOT reused — a write-only session is denied and nothing reaches the backend.</summary>
|
|
[Fact]
|
|
public async Task Acl_off_write_operate_alone_is_denied()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.WriteOperate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// ACL off, Gitea #563 shape: allow-then-deny on the same session — only the clock moves past the key's
|
|
/// <c>ExpiresUtc</c>, and the HistoryUpdate role stops working.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Acl_off_an_expired_key_loses_the_role()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
var clock = new FakeClock(Start);
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate, clock, expiresUtc: Start.AddMinutes(10));
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
Insert(nm, identity, nodeId, Value(T1, 1d)).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(20));
|
|
|
|
Insert(nm, identity, nodeId, Value(T2, 2d)).Errors[0].StatusCode.Code
|
|
.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
updater.UpdateCalls.Count.ShouldBe(1);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// ACL off: Anonymous is an ordinary principal, not a bypass — an anonymous session WITHOUT the HistoryUpdate
|
|
/// role (even one carrying WriteOperate) is denied, while one whose grants confer the role is admitted. The
|
|
/// allow half keeps the deny from passing against a build that denies anonymous unconditionally.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Acl_off_anonymous_without_the_role_is_denied_and_with_it_is_allowed()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var denied = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
|
denied.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
denied.Results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
|
|
var allowed = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T2, 2d));
|
|
allowed.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
updater.UpdateCalls.Count.ShouldBe(1);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── availability + AccessLevel ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// No updater ⇒ the historized node is materialised WITHOUT the HistoryWrite bit, so the SDK base never
|
|
/// admits it and HistoryUpdate reports <c>BadHistoryOperationUnsupported</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task No_updater_means_no_history_write_bit_and_unsupported()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.HistorianDataUpdater = null;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var variable = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!;
|
|
(variable.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0);
|
|
(variable.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0);
|
|
|
|
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// An updater removed AFTER materialisation retracts the node's HistoryWrite bit (review 01 U3 / B7), so
|
|
/// the SDK base refuses the insert with BadHistoryOperationUnsupported before our override runs and the
|
|
/// old updater is never reached. (Only <c>errors</c> is asserted: on the SDK-base refusal path the
|
|
/// per-node result is left for the master node manager to fill from it.)
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_updater_cleared_after_materialisation_is_unsupported()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
|
|
.ShouldNotBe((byte)0, "granted while the updater was wired (non-vacuity)");
|
|
|
|
nm.HistorianDataUpdater = null;
|
|
|
|
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
|
|
.ShouldBe((byte)0, "clearing the updater retracts the bit");
|
|
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review fix I4 — the override's OWN per-request updater check, which the bit retraction above now hides.
|
|
/// It is the B7 race window: a request the SDK base admitted on the HistoryWrite bit just before the updater
|
|
/// was cleared. Reconstructed by restoring the bit after the clear, so the request reaches the override with
|
|
/// no updater; it must answer unsupported on both slots (the override sets results itself) and never reach the
|
|
/// old updater.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_override_rechecks_the_updater_per_request_when_the_bit_is_still_set()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
nm.HistorianDataUpdater = null;
|
|
var variable = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!;
|
|
variable.AccessLevel |= AccessLevels.HistoryWrite;
|
|
variable.UserAccessLevel |= AccessLevels.HistoryWrite;
|
|
|
|
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override, not left for the master node manager");
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>With an updater wired, both the raw node and its UNS mirror carry HistoryWrite; a non-historized node does not.</summary>
|
|
[Fact]
|
|
public async Task An_updater_grants_history_write_to_raw_and_uns_historized_nodes_only()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.HistorianDataUpdater = new FakeUpdater();
|
|
Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
Historized(nm, AddressSpaceRealm.Uns, "Area/Line/Eq/Speed");
|
|
nm.EnsureVariable("Plant/Modbus/dev1/Plain", parentFolderNodeId: null, displayName: "Plain",
|
|
dataType: "Float", writable: true, historianTagname: null, realm: AddressSpaceRealm.Raw);
|
|
|
|
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
|
|
.ShouldNotBe((byte)0);
|
|
(nm.TryGetVariable("Area/Line/Eq/Speed", AddressSpaceRealm.Uns)!.AccessLevel & AccessLevels.HistoryWrite)
|
|
.ShouldNotBe((byte)0);
|
|
(nm.TryGetVariable("Plant/Modbus/dev1/Plain", AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
|
|
.ShouldBe((byte)0);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A surgical deploy that turns historization ON for an existing node (<c>UpdateTagAttributes</c>) grants the
|
|
/// HistoryWrite bit when an updater is wired — the same bit <c>EnsureVariable</c> sets at materialise — so
|
|
/// the newly historized node is updatable without a rebuild.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Update_tag_attributes_turning_historization_on_sets_history_write()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.HistorianDataUpdater = new FakeUpdater();
|
|
nm.EnsureVariable(RawPath, parentFolderNodeId: null, displayName: RawPath, dataType: "Float",
|
|
writable: false, historianTagname: null, realm: AddressSpaceRealm.Raw);
|
|
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0);
|
|
|
|
nm.UpdateTagAttributes(RawPath, writable: false, historianTagname: Tagname, dataType: "Float",
|
|
isArray: false, arrayLength: null, realm: AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
|
|
var access = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel;
|
|
(access & AccessLevels.HistoryWrite).ShouldNotBe((byte)0);
|
|
(access & AccessLevels.HistoryRead).ShouldNotBe((byte)0);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>An updater that throws is <c>BadInternalError</c> for that node, never an exception out of the service.</summary>
|
|
[Fact]
|
|
public async Task An_updater_that_throws_is_bad_internal_error()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.HistorianDataUpdater = new FakeUpdater { Throw = new InvalidOperationException("backend down") };
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// DeleteRawModified needs both bounds (Part 11): a <c>DateTime.MinValue</c> ("unspecified") start or end is
|
|
/// <c>BadInvalidArgument</c> and never reaches the backend.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Delete_raw_with_an_unspecified_bound_is_bad_invalid_argument()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
foreach (var (start, end) in new[] { (DateTime.MinValue, DateTime.MinValue), (DateTime.MinValue, T2), (T1, DateTime.MinValue) })
|
|
{
|
|
var (results, _) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
|
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = start, EndTime = end, IsDeleteModified = false });
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument);
|
|
}
|
|
|
|
updater.DeleteRawCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── semantics ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Per-item codes come back in request order: locally refused items (MinValue timestamp ⇒
|
|
/// <c>BadInvalidTimestamp</c>, an array value ⇒ <c>BadTypeMismatch</c>) never reach the backend, the
|
|
/// rest are sent in one call and their codes stitched back in place. The node code stays Good.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Item_codes_pass_through_in_request_order()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater
|
|
{
|
|
UpdateCodes = [StatusCodes.GoodEntryInserted, StatusCodes.BadEntryExists],
|
|
};
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var bad = new DataValue(new Variant(new[] { 1d, 2d })) { SourceTimestamp = T2 };
|
|
var noTime = new DataValue(new Variant(7d)); // SourceTimestamp defaults to DateTime.MinValue
|
|
var uncertain = new DataValue(new Variant(3)) { SourceTimestamp = T2, StatusCode = StatusCodes.UncertainLastUsableValue };
|
|
|
|
var (results, errors) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId,
|
|
PerformUpdateType.Insert, Value(T1, 1d), noTime, bad, uncertain);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe(
|
|
[
|
|
StatusCodes.GoodEntryInserted, StatusCodes.BadInvalidTimestamp, StatusCodes.BadTypeMismatch,
|
|
StatusCodes.BadEntryExists,
|
|
]);
|
|
var call = updater.UpdateCalls.ShouldHaveSingleItem();
|
|
call.Values.Count.ShouldBe(2);
|
|
call.Values[1].Value.ShouldBe(HistorianValue.FromInt64(3));
|
|
call.Values[1].StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>Replace / Update map to their modes; Remove is out of scope ⇒ unsupported with no backend call.</summary>
|
|
[Fact]
|
|
public async Task Perform_update_types_map_and_remove_is_unsupported()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
Update(nm, identity, nodeId, PerformUpdateType.Replace, Value(T1, 1d));
|
|
Update(nm, identity, nodeId, PerformUpdateType.Update, Value(T1, 1d));
|
|
var (_, removeErrors) = Update(nm, identity, nodeId, PerformUpdateType.Remove, Value(T1, 1d));
|
|
|
|
updater.UpdateCalls.Select(c => c.Mode).ShouldBe([HistoryUpdateMode.Replace, HistoryUpdateMode.Update]);
|
|
removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>A UNS mirror resolves to the SAME historian tagname as its raw node.</summary>
|
|
[Fact]
|
|
public async Task A_uns_node_id_updates_the_raw_tags_series()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var unsNodeId = Historized(nm, AddressSpaceRealm.Uns, "Area/Line/Eq/Speed");
|
|
|
|
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), unsNodeId, Value(T1, 5d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>DeleteRawModified with IsDeleteModified=false deletes the range; true is unsupported.</summary>
|
|
[Fact]
|
|
public async Task Delete_raw_routes_to_the_updater_and_delete_modified_is_unsupported()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater { DeleteRawCode = StatusCodes.BadNoData };
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
var (results, errors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
|
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
|
|
var (_, modifiedErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
|
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true });
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData);
|
|
updater.DeleteRawCalls.ShouldHaveSingleItem().ShouldBe((Tagname, T1, T2));
|
|
modifiedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #668: the session's API-key principal and key id reach EVERY updater call (they become the Part 11
|
|
/// ModificationInfo.UserName of the trail rows the backend records).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_key_principal_and_key_id_are_threaded_to_every_updater_call()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
Update(nm, identity, nodeId, PerformUpdateType.Update, Value(T1, 1d));
|
|
Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
|
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
|
|
Call(nm, identity, typeof(DeleteAtTimeDetails),
|
|
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
|
|
|
updater.Callers.Count.ShouldBe(3);
|
|
updater.Callers.ShouldAllBe(c => c == new HistoryUpdateContext(Principal, KeyId));
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>#668: an admitted anonymous session is recorded as the reserved <c>anonymous</c> principal, no key id.</summary>
|
|
[Fact]
|
|
public async Task An_anonymous_session_is_recorded_as_the_anonymous_principal()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (_, errors) = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
updater.Callers.ShouldHaveSingleItem().ShouldBe(new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null));
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>DeleteAtTime returns the updater's per-time codes in order.</summary>
|
|
[Fact]
|
|
public async Task Delete_at_time_passes_per_time_codes_through()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater { DeleteAtTimeCodes = [StatusCodes.Good, StatusCodes.BadNoEntryExists] };
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(DeleteAtTimeDetails),
|
|
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1, T2 } });
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.Good, StatusCodes.BadNoEntryExists]);
|
|
updater.DeleteAtTimeCalls.ShouldHaveSingleItem().ShouldBe([T1, T2]);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>Event history updates are out of scope on a variable ⇒ unsupported.</summary>
|
|
[Fact]
|
|
public async Task Event_details_are_unsupported()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (_, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(UpdateEventDetails),
|
|
new UpdateEventDetails { NodeId = nodeId, PerformInsertReplace = PerformUpdateType.Insert });
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── #674 future-skew bound ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// #674: an item whose SourceTimestamp lies beyond <c>now + HistoryUpdateMaxFutureSkew</c> is refused
|
|
/// <c>BadOutOfRange</c> by the node manager and never reaches the backend; an item exactly AT the bound and one in
|
|
/// the past are sent. Judged against the injected clock, not the wall clock.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_source_time_beyond_the_future_skew_is_bad_out_of_range_and_never_sent()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
nm.Clock = new FakeClock(Start);
|
|
nm.HistoryUpdateMaxFutureSkew = TimeSpan.FromHours(1);
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var atBound = Start.AddHours(1);
|
|
|
|
var (results, errors) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId,
|
|
PerformUpdateType.Update, Value(T1, 1d), Value(atBound.AddTicks(1), 2d), Value(atBound, 3d),
|
|
Value(new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc), 4d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe(
|
|
[
|
|
StatusCodes.GoodEntryInserted, StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted,
|
|
StatusCodes.BadOutOfRange,
|
|
]);
|
|
updater.UpdateCalls.ShouldHaveSingleItem().Values.Select(v => v.TimestampUtc).ShouldBe([T1, atBound]);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>#674: every mode is bounded, and a request whose items are ALL out of range sends nothing.</summary>
|
|
[Theory]
|
|
[InlineData(PerformUpdateType.Insert)]
|
|
[InlineData(PerformUpdateType.Replace)]
|
|
[InlineData(PerformUpdateType.Update)]
|
|
public async Task Every_update_mode_is_bounded_and_an_all_refused_request_sends_nothing(PerformUpdateType type)
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
nm.Clock = new FakeClock(Start);
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
// Default skew: one day.
|
|
var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, type,
|
|
Value(Start.AddDays(1).AddSeconds(1), 1d));
|
|
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadOutOfRange]);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>#674: a non-positive skew disables the bound — a far-future item reaches the backend.</summary>
|
|
[Fact]
|
|
public async Task A_non_positive_skew_disables_the_bound()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
nm.Clock = new FakeClock(Start);
|
|
nm.HistoryUpdateMaxFutureSkew = TimeSpan.Zero;
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var farFuture = new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(farFuture, 1d));
|
|
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
|
updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem().TimestampUtc.ShouldBe(farFuture);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── #674 Primary gate ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// #674: off-Primary, an authorized HistoryUpdate is refused <c>BadInvalidState</c> per node — on every arm
|
|
/// (update data, delete raw, delete at time) — and NOTHING reaches the updater.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Off_primary_every_arm_is_bad_invalid_state_and_nothing_is_sent()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
var consulted = 0;
|
|
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
var (results, errors) = Insert(nm, identity, nodeId, Value(T1, 1d));
|
|
var (rawResults, rawErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
|
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
|
|
var (atResults, atErrors) = Call(nm, identity, typeof(DeleteAtTimeDetails),
|
|
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
|
|
|
foreach (var (r, e) in new[] { (results, errors), (rawResults, rawErrors), (atResults, atErrors) })
|
|
{
|
|
e[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
|
r[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
|
}
|
|
|
|
consulted.ShouldBe(3);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
updater.DeleteRawCalls.ShouldBeEmpty();
|
|
updater.DeleteAtTimeCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #674: on the Primary the gate allows and the update REACHES the updater — the allow leg that makes the deny
|
|
/// leg meaningful (a gate that refused everything, or one never consulted, fails one of the two).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task On_primary_the_update_reaches_the_updater()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
var consulted = 0;
|
|
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return true; };
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
|
updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname);
|
|
consulted.ShouldBe(1);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #674: the Primary gate runs AFTER authorization — an unauthorized caller is told <c>BadUserAccessDenied</c>
|
|
/// (not the node's role) and the gate is never consulted.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_primary_gate_runs_after_authorization()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
var consulted = 0;
|
|
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
|
WireAcl(nm, enabled: false, NodePermissions.WriteOperate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
|
|
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
|
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
consulted.ShouldBe(0);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── #674 review follow-ups ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// #674 review: a backend that cannot perform the update at all (the SqlServer updater on a v1 schema throws
|
|
/// <see cref="NotSupportedException"/>) is a NODE-level <c>BadHistoryOperationUnsupported</c> on all three arms —
|
|
/// not a per-item code under a Good node, and not <c>BadInternalError</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_not_supported_backend_is_node_level_unsupported_on_every_arm()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.HistorianDataUpdater = new FakeUpdater { Throw = new NotSupportedException("schema v1: no trail") };
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
var update = Insert(nm, identity, nodeId, Value(T1, 1d));
|
|
var deleteRaw = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
|
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
|
|
var deleteAt = Call(nm, identity, typeof(DeleteAtTimeDetails),
|
|
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
|
|
|
foreach (var (results, errors) in new[] { update, deleteRaw, deleteAt })
|
|
{
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
(results[0].OperationResults?.Count ?? 0).ShouldBe(0);
|
|
}
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #674 review: a request this server can never serve is answered with the honest
|
|
/// <c>BadHistoryOperationUnsupported</c> even off-Primary — the plan is evaluated BEFORE the Primary gate, which is
|
|
/// not even consulted when nothing in the request is servable. A servable node in the same situation still gets
|
|
/// <c>BadInvalidState</c> (the control: the gate is wired and closed).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Off_primary_an_unservable_request_is_unsupported_not_invalid_state()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
var consulted = 0;
|
|
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
|
|
var (removeResults, removeErrors) = Update(nm, identity, nodeId, PerformUpdateType.Remove, Value(T1, 1d));
|
|
var (modResults, modErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
|
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true });
|
|
|
|
removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
removeResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
modErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
modResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
consulted.ShouldBe(0);
|
|
|
|
var (_, servableErrors) = Insert(nm, identity, nodeId, Value(T1, 1d));
|
|
servableErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
|
consulted.ShouldBe(1);
|
|
updater.UpdateCalls.ShouldBeEmpty();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #674 review: a <see cref="DateTimeKind.Local"/> SourceTimestamp is converted to UTC before the future-skew
|
|
/// compare (and before it is sent). Two items one tick either side of the bound, expressed in local time: the one
|
|
/// past the bound is refused and the one inside it is sent as UTC. Comparing raw ticks would get exactly one of
|
|
/// them wrong in any non-UTC zone (which one depends on the sign of the offset).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_local_kind_source_time_is_judged_and_sent_as_utc()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var updater = new FakeUpdater();
|
|
nm.HistorianDataUpdater = updater;
|
|
nm.Clock = new FakeClock(Start);
|
|
nm.HistoryUpdateMaxFutureSkew = TimeSpan.FromHours(1);
|
|
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
|
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
|
var bound = Start.AddHours(1);
|
|
var justPast = bound.AddTicks(1).ToLocalTime();
|
|
var justInside = bound.AddTicks(-1).ToLocalTime();
|
|
justPast.Kind.ShouldBe(DateTimeKind.Local);
|
|
|
|
var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, PerformUpdateType.Update,
|
|
LocalValue(justPast, 1d), LocalValue(justInside, 2d));
|
|
|
|
results[0].OperationResults.Select(c => c.Code)
|
|
.ShouldBe([StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted]);
|
|
var sent = updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem();
|
|
sent.TimestampUtc.ShouldBe(bound.AddTicks(-1));
|
|
sent.TimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
private static DataValue LocalValue(DateTime local, double value)
|
|
{
|
|
var dv = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good };
|
|
dv.SourceTimestamp = local;
|
|
return dv;
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static DataValue Value(DateTime timestamp, object value) =>
|
|
new(new Variant(value)) { SourceTimestamp = timestamp, StatusCode = StatusCodes.Good };
|
|
|
|
private static NodeId Historized(OtOpcUaNodeManager nm, AddressSpaceRealm realm, string id)
|
|
{
|
|
nm.EnsureVariable(id, parentFolderNodeId: null, displayName: id, dataType: "Float",
|
|
writable: false, historianTagname: Tagname, realm: realm);
|
|
return nm.TryGetVariable(id, realm)!.NodeId;
|
|
}
|
|
|
|
private static (List<HistoryUpdateResult> Results, List<ServiceResult> Errors) Insert(
|
|
OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, params DataValue[] values) =>
|
|
Update(nm, identity, nodeId, PerformUpdateType.Insert, values);
|
|
|
|
private static (List<HistoryUpdateResult> Results, List<ServiceResult> Errors) Update(
|
|
OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, PerformUpdateType type,
|
|
params DataValue[] values) =>
|
|
Call(nm, identity, typeof(UpdateDataDetails), new UpdateDataDetails
|
|
{
|
|
NodeId = nodeId,
|
|
PerformInsertReplace = type,
|
|
UpdateValues = new DataValueCollection(values),
|
|
});
|
|
|
|
/// <summary>Drives the SDK's PUBLIC HistoryUpdate entry point (base admission + Lock + dispatch).</summary>
|
|
private static (List<HistoryUpdateResult> Results, List<ServiceResult> Errors) Call(
|
|
OtOpcUaNodeManager nm, IUserIdentity identity, Type detailsType, HistoryUpdateDetails details)
|
|
{
|
|
var context = new OperationContext(
|
|
new RequestHeader(), secureChannelContext: null, RequestType.HistoryUpdate, identity);
|
|
var nodesToUpdate = new List<HistoryUpdateDetails> { details };
|
|
var results = new List<HistoryUpdateResult> { null! };
|
|
var errors = new List<ServiceResult> { null! };
|
|
|
|
nm.HistoryUpdate(context, detailsType, nodesToUpdate, results, errors);
|
|
|
|
// The base seeds every node THIS manager owns — with or without the HistoryWrite bit — with
|
|
// BadHistoryOperationUnsupported. A slot is only still null when GetManagerHandle found no node here
|
|
// (the master node manager would answer it); report that the way the client would see it.
|
|
if (results[0] is null) results[0] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
|
errors[0] ??= new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
|
|
return (results, errors);
|
|
}
|
|
|
|
private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) =>
|
|
new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId);
|
|
|
|
private static RoleCarryingUserIdentity AnonymousIdentity(params string[] roles) =>
|
|
new(new AnonymousIdentityToken(), roles, OpcUaPrincipals.Anonymous, apiKeyId: null);
|
|
|
|
private static void WireAcl(
|
|
OtOpcUaNodeManager nm, bool enabled, NodePermissions flags, TimeProvider? clock = null,
|
|
DateTime? expiresUtc = null)
|
|
{
|
|
var key = new ApiKey
|
|
{
|
|
ApiKeyId = KeyId,
|
|
Name = KeyId,
|
|
SecretHash = "hash",
|
|
Principal = Principal,
|
|
Enabled = true,
|
|
CreatedUtc = Start.AddDays(-1),
|
|
ExpiresUtc = expiresUtc,
|
|
};
|
|
var grant = new NodeAcl
|
|
{
|
|
NodeAclId = "acl-cluster",
|
|
ClusterId = Cluster,
|
|
Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.Cluster,
|
|
PermissionFlags = flags,
|
|
};
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply(Cluster, "rev-1", [key], [grant], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
|
nm.DataPlaneAuthority = state;
|
|
nm.Authorizer = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock ?? new FakeClock(Start));
|
|
}
|
|
|
|
/// <summary>Records every call; completes on a pool thread like a real backend round-trip.</summary>
|
|
private sealed class FakeUpdater : IHistorianDataUpdater
|
|
{
|
|
public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList<HistorizationValue> Values)> UpdateCalls { get; } = [];
|
|
public List<HistoryUpdateContext> Callers { get; } = [];
|
|
public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = [];
|
|
public List<IReadOnlyList<DateTime>> DeleteAtTimeCalls { get; } = [];
|
|
public uint[]? UpdateCodes { get; init; }
|
|
public uint DeleteRawCode { get; init; } = StatusCodes.Good;
|
|
public uint[]? DeleteAtTimeCodes { get; init; }
|
|
public Exception? Throw { get; init; }
|
|
|
|
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
|
|
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context,
|
|
CancellationToken ct)
|
|
{
|
|
await Task.Delay(1, ct).ConfigureAwait(false);
|
|
if (Throw is not null) throw Throw;
|
|
lock (UpdateCalls) { UpdateCalls.Add((tag, mode, values.ToList())); Callers.Add(context); }
|
|
return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted, values.Count).ToArray();
|
|
}
|
|
|
|
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
|
|
CancellationToken ct)
|
|
{
|
|
await Task.Delay(1, ct).ConfigureAwait(false);
|
|
if (Throw is not null) throw Throw;
|
|
lock (DeleteRawCalls) { DeleteRawCalls.Add((tag, startUtc, endUtc)); lock (UpdateCalls) Callers.Add(context); }
|
|
return DeleteRawCode;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(
|
|
string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct)
|
|
{
|
|
await Task.Delay(1, ct).ConfigureAwait(false);
|
|
if (Throw is not null) throw Throw;
|
|
lock (DeleteAtTimeCalls) { DeleteAtTimeCalls.Add(timesUtc.ToList()); lock (UpdateCalls) Callers.Add(context); }
|
|
return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good, timesUtc.Count).ToArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
|
|
private sealed class FakeClock(DateTime utcNow) : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
|
|
|
/// <inheritdoc />
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
/// <summary>Moves the clock forward.</summary>
|
|
/// <param name="by">How far forward.</param>
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
|
{
|
|
var applicationUri = $"urn:OtOpcUa.HistoryUpdateTest:{Guid.NewGuid():N}";
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.HistoryUpdateTest",
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
return (started.Host, started.Server);
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch (IOException) { /* best effort */ }
|
|
}
|
|
}
|
|
}
|