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; /// /// OPC UA HistoryUpdate on (pluggable historian, spec §5), driven through /// the SDK's PUBLIC HistoryUpdate(OperationContext, Type, …) 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 HistoryWrite. 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. /// 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 ───────────────────────────── /// /// THE load-bearing ALLOW: ACL flag ON, a session carrying NO roles, a Cluster-scoped /// HistoryUpdate grant ⇒ the insert reaches the updater and the item reports /// GoodEntryInserted. A deny-only test would pass against a build that never consulted the ACL, /// because the role gate denies a roleless session too. /// [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(); } } /// ACL on: a grant without the HistoryUpdate bit (even one carrying write + history read) is refused. [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(); } } /// ACL off: the dedicated HistoryUpdate role admits the update. [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(); } } /// ACL off: WriteOperate is NOT reused — a write-only session is denied and nothing reaches the backend. [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(); } } /// /// ACL off, Gitea #563 shape: allow-then-deny on the same session — only the clock moves past the key's /// ExpiresUtc, and the HistoryUpdate role stops working. /// [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(); } } /// /// 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. /// [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 ───────────────────────────── /// /// No updater ⇒ the historized node is materialised WITHOUT the HistoryWrite bit, so the SDK base never /// admits it and HistoryUpdate reports BadHistoryOperationUnsupported. /// [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(); } } /// /// An updater removed AFTER materialisation (AccessLevel is not retro-updated) still yields unsupported /// rather than a null dereference — the override re-checks the updater per request. /// [Fact] public async Task An_updater_cleared_after_materialisation_is_unsupported() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.HistorianDataUpdater = new FakeUpdater(); WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); nm.HistorianDataUpdater = null; 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); } finally { await host.DisposeAsync(); } } /// With an updater wired, both the raw node and its UNS mirror carry HistoryWrite; a non-historized node does not. [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(); } } /// /// A surgical deploy that turns historization ON for an existing node (UpdateTagAttributes) grants the /// HistoryWrite bit when an updater is wired — the same bit EnsureVariable sets at materialise — so /// the newly historized node is updatable without a rebuild. /// [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(); } } /// An updater that throws is BadInternalError for that node, never an exception out of the service. [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(); } } /// /// DeleteRawModified needs both bounds (Part 11): a DateTime.MinValue ("unspecified") start or end is /// BadInvalidArgument and never reaches the backend. /// [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 ───────────────────────────── /// /// Per-item codes come back in request order: locally refused items (MinValue timestamp ⇒ /// BadInvalidTimestamp, an array value ⇒ BadTypeMismatch) never reach the backend, the /// rest are sent in one call and their codes stitched back in place. The node code stays Good. /// [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(); } } /// Replace / Update map to their modes; Remove is out of scope ⇒ unsupported with no backend call. [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(); } } /// A UNS mirror resolves to the SAME historian tagname as its raw node. [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(); } } /// DeleteRawModified with IsDeleteModified=false deletes the range; true is unsupported. [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(); } } /// /// #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). /// [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(); } } /// #668: an admitted anonymous session is recorded as the reserved anonymous principal, no key id. [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(); } } /// DeleteAtTime returns the updater's per-time codes in order. [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(); } } /// Event history updates are out of scope on a variable ⇒ unsupported. [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 ───────────────────────────── /// /// #674: an item whose SourceTimestamp lies beyond now + HistoryUpdateMaxFutureSkew is refused /// BadOutOfRange 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. /// [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(); } } /// #674: every mode is bounded, and a request whose items are ALL out of range sends nothing. [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(); } } /// #674: a non-positive skew disables the bound — a far-future item reaches the backend. [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 ───────────────────────────── /// /// #674: off-Primary, an authorized HistoryUpdate is refused BadInvalidState per node — on every arm /// (update data, delete raw, delete at time) — and NOTHING reaches the updater. /// [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(); } } /// /// #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). /// [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(); } } /// /// #674: the Primary gate runs AFTER authorization — an unauthorized caller is told BadUserAccessDenied /// (not the node's role) and the gate is never consulted. /// [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 ───────────────────────────── /// /// #674 review: a backend that cannot perform the update at all (the SqlServer updater on a v1 schema throws /// ) is a NODE-level BadHistoryOperationUnsupported on all three arms — /// not a per-item code under a Good node, and not BadInternalError. /// [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(); } } /// /// #674 review: a request this server can never serve is answered with the honest /// BadHistoryOperationUnsupported 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 /// BadInvalidState (the control: the gate is wired and closed). /// [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(); } } /// /// #674 review: a 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). /// [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 Results, List Errors) Insert( OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, params DataValue[] values) => Update(nm, identity, nodeId, PerformUpdateType.Insert, values); private static (List Results, List 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), }); /// Drives the SDK's PUBLIC HistoryUpdate entry point (base admission + Lock + dispatch). private static (List Results, List 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 { details }; var results = new List { null! }; var errors = new List { 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>()); nm.DataPlaneAuthority = state; nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock ?? new FakeClock(Start)); } /// Records every call; completes on a pool thread like a real backend round-trip. private sealed class FakeUpdater : IHistorianDataUpdater { public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList Values)> UpdateCalls { get; } = []; public List Callers { get; } = []; public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = []; public List> 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> UpdateDataAsync( string tag, HistoryUpdateMode mode, IReadOnlyList 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 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> DeleteAtTimeAsync( string tag, IReadOnlyList 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(); } } /// A hand-advanced clock; the authorizer reads it on every decision. private sealed class FakeClock(DateTime utcNow) : TimeProvider { private DateTimeOffset _now = new(utcNow, TimeSpan.Zero); /// public override DateTimeOffset GetUtcNow() => _now; /// Moves the clock forward. /// How far forward. 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.Instance), () => new OtOpcUaSdkServer(), Ct); return (started.Host, started.Server); } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } }