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(); } } /// 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(); } } // ───────────────────────────── 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<(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, CancellationToken ct) { await Task.Delay(1, ct).ConfigureAwait(false); if (Throw is not null) throw Throw; lock (UpdateCalls) UpdateCalls.Add((tag, mode, values.ToList())); return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted, values.Count).ToArray(); } public async Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) { await Task.Delay(1, ct).ConfigureAwait(false); lock (DeleteRawCalls) DeleteRawCalls.Add((tag, startUtc, endUtc)); return DeleteRawCode; } public async Task> DeleteAtTimeAsync( string tag, IReadOnlyList timesUtc, CancellationToken ct) { await Task.Delay(1, ct).ConfigureAwait(false); lock (DeleteAtTimeCalls) DeleteAtTimeCalls.Add(timesUtc.ToList()); 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 */ } } } }