test(historian-gateway): env-gated live validation vs wonder-sql-vd03 (read/write/alarm round-trips)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
+166
@@ -0,0 +1,166 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live;
|
||||
|
||||
/// <summary>
|
||||
/// Env-gated fixture for the live validation suite that exercises the gateway-backed
|
||||
/// read / write / alarm paths against a real, running <c>ZB.MOM.WW.HistorianGateway</c>
|
||||
/// sidecar (typically <c>wonder-sql-vd03</c> on the corporate VPN). Mirrors the
|
||||
/// HistorianGateway repo's <c>GatewayIntegrationFixture</c> env-gating convention and this
|
||||
/// repo's <c>OpcPlcFixture</c> reachability-probe pattern: the fixture is <em>cheap</em> at
|
||||
/// construction (reads env vars + one short TCP probe) and records a <see cref="SkipReason"/>
|
||||
/// so tests call <c>Assert.Skip(SkipReason)</c> and report as <b>Skipped</b> (not Failed)
|
||||
/// when the suite is not configured or the gateway is unreachable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Env vars consumed</b> (skip-gate + config source):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>HISTGW_GATEWAY_ENDPOINT</c> — absolute gateway URI, e.g. <c>https://wonder-sql-vd03:5222</c>. Required; absent ⇒ all tests skip.</item>
|
||||
/// <item><c>HISTGW_GATEWAY_APIKEY</c> — the <c>histgw_<id>_<secret></c> key (must carry <c>historian:read</c> + <c>historian:write</c> scopes). Required; absent ⇒ all tests skip.</item>
|
||||
/// <item><c>HISTGW_TEST_TAG</c> — an existing Galaxy / historian tag for the read round-trip.</item>
|
||||
/// <item><c>HISTGW_WRITE_SANDBOX_TAG</c> — a Float sandbox tag the write round-trip may <c>EnsureTags</c> + write (e.g. <c>HistGW.LiveTest.Sandbox</c>).</item>
|
||||
/// <item><c>HISTGW_ALARM_SOURCE</c> — a source name for the alarm <c>SendEvent</c> → <c>ReadEvents</c> round-trip.</item>
|
||||
/// <item><c>HISTGW_GATEWAY_ALLOW_UNTRUSTED</c> — <c>true</c> to accept a self-signed dev cert (optional).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>VPN-gated.</b> <c>wonder-sql-vd03</c> is reachable only on the VPN. When the endpoint
|
||||
/// is configured but the host does not accept a TCP connection within
|
||||
/// <see cref="ProbeTimeout"/>, <see cref="SkipReason"/> is set to a message that prompts
|
||||
/// the operator to connect the VPN — the suite skips rather than hangs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Never connects from the fixture.</b> The gRPC channel is built lazily by the package
|
||||
/// client, so constructing an adapter performs no network I/O. The fixture's only network
|
||||
/// touch is the bounded TCP reachability probe.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GatewayLiveFixture
|
||||
{
|
||||
private const string EnvEndpoint = "HISTGW_GATEWAY_ENDPOINT";
|
||||
private const string EnvApiKey = "HISTGW_GATEWAY_APIKEY";
|
||||
private const string EnvTestTag = "HISTGW_TEST_TAG";
|
||||
private const string EnvWriteSandboxTag = "HISTGW_WRITE_SANDBOX_TAG";
|
||||
private const string EnvAlarmSource = "HISTGW_ALARM_SOURCE";
|
||||
private const string EnvAllowUntrusted = "HISTGW_GATEWAY_ALLOW_UNTRUSTED";
|
||||
|
||||
/// <summary>Bounded deadline for the TCP reachability probe (keeps an unreachable VPN from hanging the run).</summary>
|
||||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>Short per-call deadline so a misconfigured / unreachable gateway fails fast instead of hanging.</summary>
|
||||
private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly string? _endpoint;
|
||||
private readonly string? _apiKey;
|
||||
private readonly bool _allowUntrusted;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the env config and runs one bounded TCP reachability probe. On any gap
|
||||
/// (missing endpoint/key, malformed URI, or unreachable host) <see cref="SkipReason"/> is set
|
||||
/// and the suite skips cleanly.
|
||||
/// </summary>
|
||||
public GatewayLiveFixture()
|
||||
{
|
||||
_endpoint = Trimmed(EnvEndpoint);
|
||||
_apiKey = Trimmed(EnvApiKey);
|
||||
TestTag = Trimmed(EnvTestTag);
|
||||
WriteSandboxTag = Trimmed(EnvWriteSandboxTag);
|
||||
AlarmSource = Trimmed(EnvAlarmSource);
|
||||
_allowUntrusted = string.Equals(
|
||||
Trimmed(EnvAllowUntrusted), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (_endpoint is null || _apiKey is null)
|
||||
{
|
||||
SkipReason =
|
||||
$"Skipped: set {EnvEndpoint} (e.g. https://wonder-sql-vd03:5222) and {EnvApiKey} " +
|
||||
$"(a histgw_<id>_<secret> key with historian:read + historian:write scopes) to run the live validation suite.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(_endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
SkipReason = $"Skipped: {EnvEndpoint}='{_endpoint}' is not an absolute URI (expected e.g. https://wonder-sql-vd03:5222).";
|
||||
return;
|
||||
}
|
||||
|
||||
// Bounded TCP probe — a configured-but-unreachable gateway (VPN down) skips, never hangs.
|
||||
SkipReason = ProbeReachable(uri.Host, uri.Port)
|
||||
? null
|
||||
: $"Skipped: gateway {uri.Host}:{uri.Port} (from {EnvEndpoint}) did not accept a TCP connection within " +
|
||||
$"{ProbeTimeout.TotalSeconds:0}s. It is reachable only on the corporate VPN — connect the VPN (host wonder-sql-vd03) and re-run.";
|
||||
}
|
||||
|
||||
/// <summary>Non-null when the suite must skip (unconfigured, malformed endpoint, or unreachable host).</summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
/// <summary>Convenience flag: true when env config is absent / malformed / unreachable.</summary>
|
||||
public bool NotConfigured => SkipReason is not null;
|
||||
|
||||
/// <summary>The existing Galaxy / historian tag for the read round-trip (<c>HISTGW_TEST_TAG</c>); null when unset.</summary>
|
||||
public string? TestTag { get; }
|
||||
|
||||
/// <summary>The Float sandbox tag for the write round-trip (<c>HISTGW_WRITE_SANDBOX_TAG</c>); null when unset.</summary>
|
||||
public string? WriteSandboxTag { get; }
|
||||
|
||||
/// <summary>The source name for the alarm round-trip (<c>HISTGW_ALARM_SOURCE</c>); null when unset.</summary>
|
||||
public string? AlarmSource { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds the bound <see cref="ServerHistorianOptions"/> from env. Only valid when
|
||||
/// <see cref="NotConfigured"/> is false (the endpoint + key are non-null by then).
|
||||
/// </summary>
|
||||
public ServerHistorianOptions BuildOptions()
|
||||
{
|
||||
var useTls = Uri.TryCreate(_endpoint, UriKind.Absolute, out var uri)
|
||||
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = _endpoint!,
|
||||
ApiKey = _apiKey!,
|
||||
UseTls = useTls,
|
||||
AllowUntrustedServerCertificate = _allowUntrusted,
|
||||
CallTimeout = CallTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh real adapter over the package gateway client. Each caller owns and disposes
|
||||
/// its own adapter (the data source / writers each take exclusive ownership of their client).
|
||||
/// </summary>
|
||||
public HistorianGatewayClientAdapter CreateClient() =>
|
||||
HistorianGatewayClientAdapter.Create(BuildOptions(), NullLoggerFactory.Instance);
|
||||
|
||||
/// <summary>Creates a fresh real <see cref="GatewayHistorianDataSource"/> over its own adapter.</summary>
|
||||
public GatewayHistorianDataSource CreateDataSource() =>
|
||||
new(CreateClient(), NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
|
||||
private static string? Trimmed(string envVar)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(envVar);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded, never-throwing TCP connectivity probe to <paramref name="host"/>:<paramref name="port"/>.
|
||||
/// Returns true only on a connection accepted within <see cref="ProbeTimeout"/>.
|
||||
/// </summary>
|
||||
private static bool ProbeReachable(string host, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(host, port);
|
||||
return connect.Wait(ProbeTimeout) && client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Timeout, connection refused, DNS failure, … → unreachable (skip, never fail/hang).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user