cfd403710e
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.
722 lines
34 KiB
C#
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 */ }
|
|
}
|
|
}
|
|
}
|