diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs
index df3328d6..de7f3bd7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs
@@ -28,12 +28,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
/// Quality is a documented-dead input on the WriteLiveValues SQL path
/// (HistorianGateway 0.2.0 Contracts C-002): HistorianLiveValue.Quality /
/// QualityDetail are server-managed columns — any value sent is accepted on the wire and
-/// then silently discarded. We populate Quality anyway (see the mapping below): proto3
-/// transmits 0 when a field is left unset, and 0 is "Bad" in OPC-DA terms, so
-/// not setting it would send a wrong quality to any future gateway version that
-/// starts honoring the field — whereas the recorder's 192 ("Good") is the correct value.
-/// Do NOT extend the recorder to capture real node quality expecting it to persist — it
-/// will not, until the gateway honors the field.
+/// then silently discarded. We populate Quality anyway: proto3 transmits 0 when a
+/// field is left unset, and 0 is "Bad" in OPC-DA terms, so not setting it would send a
+/// wrong quality to any future gateway version that starts honoring the field. The recorder
+/// now carries each sample's real OPC UA status, which
+/// maps to the DA byte (Good ⇒ 192, as before). That status does not persist on this
+/// backend: the gateway discards the field, so AVEVA still stores every sample Good — use the
+/// SqlServer backend where the stored status must be the real one.
+///
+///
+/// Numeric only. Values whose kind has no double form (String, DateTime, Empty) are skipped,
+/// never thrown; a batch with none left is acked without calling the gateway. The recorder already drops
+/// those kinds at capture for this backend, so the skip is a backstop.
///
///
public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncDisposable
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 107303bd..57f249c2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -5295,6 +5295,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (details.IsDeleteModified) return null;
var start = details.StartTime;
var end = details.EndTime;
+ // Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse
+ // it here rather than let a backend read MinValue as "from the beginning of time".
+ if (start == DateTime.MinValue || end == DateTime.MinValue)
+ return (_, _, _) => Task.FromResult(new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidArgument });
return async (updater, tagname, ct) =>
{
var code = await updater.DeleteRawAsync(tagname, start, end, ct).ConfigureAwait(false);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs
index 6814f20f..4a6617cd 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs
@@ -166,6 +166,35 @@ public sealed class HistoryUpdateTests : IDisposable
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 ─────────────────────────────
///
@@ -241,6 +270,82 @@ public sealed class HistoryUpdateTests : IDisposable
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 ─────────────────────────────
///
@@ -441,8 +546,9 @@ public sealed class HistoryUpdateTests : IDisposable
nm.HistoryUpdate(context, detailsType, nodesToUpdate, results, errors);
- // Unadmitted (no HistoryWrite bit) slots are left untouched by the base and later filled by the
- // master node manager; report them the way the client would see them.
+ // 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);
@@ -451,6 +557,9 @@ public sealed class HistoryUpdateTests : IDisposable
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)
@@ -489,11 +598,13 @@ public sealed class HistoryUpdateTests : IDisposable
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();
}