Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
T
Joseph Doherty 37390139fe fix(historian): #693 Gateway AtTime evaluated from raw reads with the shared AtTimeEvaluator
Gateway HistoryRead-AtTime passed AVEVA's own interpolation through: live it
answered the previous value as Good at a stored NULL's own time, null + Bad at
a Good sample right after a NULL, a drifted 99.99999999944 for a stored 100,
and no Interpolated bit. It is now served from raw gateway reads and evaluated
with Sql.Common's AtTimeEvaluator, as the SQL backends do (Part 11 §6.5.4).

GatewayAtTimeReader: one <=1,024-row forward window read [first-1ms, last+1ms]
for the batch, a walk-back (density-seeded, x4 growth, whole-remainder shortcut,
bisects dense history) for the first time's prev, and an 18-row probe for the
last time's next; truncated window reads continue or jump with a density-sized
margin. All bounds whole ms; a row counts only strictly inside its request
(AVEVA's boundary row never a sample); Bad rows value-less (#690); never past
now + 1 day; strict-gateway ceiling learned (#691.3) without re-probing.
10 dense times = 1 read (exact) / 3 (interpolated); 10 hourly = 12.

Kill switch ServerHistorian:GatewayAtTimeFromRaw (default true; false = the
old AVEVA passthrough). Known limit: the wire carries no data type, so numeric
tags interpolate as Double (Boolean/integer nodes are not stepped/rounded as on
the SQL backends).
2026-10-13 00:40:38 -04:00

135 lines
6.3 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// Read-cutover seam tests (T10). Both assert offline construction only — the package client builds
/// its <c>GrpcChannel</c> lazily, so neither the adapter ctor nor the factory dials the gateway. A
/// bogus/unreachable endpoint must therefore construct without throwing or performing network I/O.
/// </summary>
public sealed class HistorianGatewayClientAdapterTests
{
[Fact]
public void Adapter_constructs_from_options_without_dialing()
{
// Constructing the channel must not perform network I/O (lazy connect).
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
Assert.NotNull(adapter);
}
/// <summary>
/// A plaintext <c>h2c</c> gateway (an <c>http://</c> endpoint with <c>UseTls=false</c>) must
/// construct, using nothing but documented defaults.
/// </summary>
/// <remarks>
/// <para>
/// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
/// <c>docs/Historian.md</c> and CLAUDE.md both document <c>http://</c> as a supported
/// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
/// <b>unreachable</b>: this factory forwarded the TLS-only options unconditionally, and
/// <see cref="ServerHistorianOptions.AllowUntrustedServerCertificate"/> defaults to
/// <see langword="false"/>, so it always sent <c>RequireCertificateValidation=true</c> —
/// which the client rejects outright when <c>UseTls=false</c>
/// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
/// </para>
/// <para>
/// So <b>every</b> h2c deployment crashed at startup, and the only way to avoid it was to
/// set <c>AllowUntrustedServerCertificate=true</c> — asserting a certificate posture for a
/// connection that has no certificate at all.
/// </para>
/// </remarks>
[Fact]
public void Adapter_constructs_for_plaintext_h2c_endpoint()
{
var opts = new ServerHistorianOptions
{
Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_x_y", UseTls = false,
};
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
adapter.ShouldNotBeNull();
}
/// <summary>
/// A pinned CA path is likewise TLS-only, and must not leak into an h2c client — the same
/// rejection, reached by a different field.
/// </summary>
[Fact]
public void Adapter_ignores_tls_only_settings_on_a_plaintext_endpoint()
{
var opts = new ServerHistorianOptions
{
Enabled = true,
Endpoint = "http://localhost:5222",
ApiKey = "histgw_x_y",
UseTls = false,
CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem",
};
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
adapter.ShouldNotBeNull();
}
/// <summary>
/// archreview 06/S-11 — an enabled historian with an empty <c>Endpoint</c> must fail with a
/// named, config-key-carrying <see cref="InvalidOperationException"/> (defense-in-depth for any
/// caller that bypasses the Host's <c>ServerHistorianOptionsValidator</c>), not the raw
/// <see cref="UriFormatException"/> that <c>new Uri("")</c> throws deep in the factory. Current
/// code throws <see cref="UriFormatException"/> → this is the failing-first (RED) repro.
/// </summary>
[Fact]
public void Create_with_empty_endpoint_throws_named_config_error()
{
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" };
var ex = Should.Throw<InvalidOperationException>(
() => HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance));
ex.Message.ShouldContain("ServerHistorian:Endpoint");
}
/// <summary>
/// archreview 06/S-11 companion — a non-empty but unparseable / non-absolute <c>Endpoint</c> must
/// also fail with the same named <see cref="InvalidOperationException"/>, not a raw
/// <see cref="UriFormatException"/>. RED on current code.
/// </summary>
[Fact]
public void Create_with_malformed_endpoint_throws_named_config_error()
{
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "not a uri", ApiKey = "histgw_x_y" };
var ex = Should.Throw<InvalidOperationException>(
() => HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance));
ex.Message.ShouldContain("ServerHistorian:Endpoint");
}
[Fact]
public void Factory_builds_GatewayHistorianDataSource()
{
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
using var services = new ServiceCollection().BuildServiceProvider();
var dataSource = GatewayHistorian.CreateDataSource(opts, services);
Assert.IsType<GatewayHistorianDataSource>(dataSource);
}
[Theory] // #693: the ServerHistorian:GatewayAtTimeFromRaw kill switch reaches the data source (default on)
[InlineData(null, true)]
[InlineData(true, true)]
[InlineData(false, false)]
public void Factory_passes_the_AtTime_kill_switch(bool? configured, bool expected)
{
var opts = configured is { } c
? new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y", GatewayAtTimeFromRaw = c }
: new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
using var services = new ServiceCollection().BuildServiceProvider();
var dataSource = Assert.IsType<GatewayHistorianDataSource>(GatewayHistorian.CreateDataSource(opts, services));
dataSource.AtTimeFromRaw.ShouldBe(expected);
}
}