Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs
T
Joseph Doherty cfd403710e feat(historian): Part 11 modified-value history on SqlServer + QuestDB (#668)
Every HistoryUpdate a SQL backend applies now leaves a modification trail
(prior value, principal, Part 11 update type, modification time), and
HistoryReadRaw with IsReadModified=true serves it as HistoryModifiedData.

- Core: HistoryUpdateContext threaded into all three IHistorianDataUpdater
  methods; IHistorianDataSource.ReadModifiedAsync (default NotSupported =>
  BadHistoryOperationUnsupported on Gateway/Null); HistoryModified* contracts.
- SqlServer: schema v2 (002_sample_modified.sql, RequiredVersion=2, docs copy);
  trail written by OUTPUT ... INTO on the same MERGE/DELETE statement, so a
  failed update leaves no audit row; retention purges SampleModified per tag on
  the source-time cutoff; ReadModifiedAsync keyed on (SourceTimeUtc, ModifiedId).
- QuestDB: append-only {prefix}sample_modified (TTL like samples), trail row
  written BEFORE the change (orphan-on-crash, never an untracked change);
  existence reads now carry the prior value; ReadModifiedAsync.
- Node manager: modified reads dispatch + page via the #669 continuation store
  (HistoryContinuationState gains HistoryCursorKind + LastModifiedId), gated by
  the existing HistoryRead decision; session principal / key id passed to the
  updater. IsDeleteModified stays unsupported.
- Client.CLI: historyread --modified.
- Tests: node-manager dispatch/paging/cursor binding/principal threading,
  over-the-wire HistoryModifiedData, shared ModifiedValueConformance on both
  backends, SqlServer atomicity (forced trail failure => no change) + v1->v2
  upgrade + retention trail purge.
- Docs: Historian.md (Modified-value history section, schema v2 upgrade note),
  security.md, Client.CLI.md, CLAUDE.md.
2026-10-11 09:26:36 -04:00

722 lines
34 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 (AccessLevel is not retro-updated) still yields unsupported
/// rather than a null dereference — the override re-checks the updater per request.
/// </summary>
[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(); }
}
/// <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(); }
}
// ───────────────────────────── 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);
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);
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 */ }
}
}
}