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; /// /// Env-gated fixture for the live validation suite that exercises the gateway-backed /// read / write / alarm paths against a real, running ZB.MOM.WW.HistorianGateway /// sidecar (typically wonder-sql-vd03 on the corporate VPN). Mirrors the /// HistorianGateway repo's GatewayIntegrationFixture env-gating convention and this /// repo's OpcPlcFixture reachability-probe pattern: the fixture is cheap at /// construction (reads env vars + one short TCP probe) and records a /// so tests call Assert.Skip(SkipReason) and report as Skipped (not Failed) /// when the suite is not configured or the gateway is unreachable. /// /// /// /// Env vars consumed (skip-gate + config source): /// /// HISTGW_GATEWAY_ENDPOINT — absolute gateway URI, e.g. https://wonder-sql-vd03:5222. Required; absent ⇒ all tests skip. /// HISTGW_GATEWAY_APIKEY — the histgw_<id>_<secret> key (must carry historian:read + historian:write scopes). Required; absent ⇒ all tests skip. /// HISTGW_TEST_TAG — an existing Galaxy / historian tag for the read round-trip. /// HISTGW_WRITE_SANDBOX_TAG — a Float sandbox tag the write round-trip may EnsureTags + write (e.g. HistGW.LiveTest.Sandbox). /// HISTGW_ALARM_SOURCE — a source name for the alarm SendEventReadEvents round-trip. /// HISTGW_GATEWAY_ALLOW_UNTRUSTEDtrue to accept a self-signed dev cert (optional). /// /// /// /// VPN-gated. wonder-sql-vd03 is reachable only on the VPN. When the endpoint /// is configured but the host does not accept a TCP connection within /// , is set to a message that prompts /// the operator to connect the VPN — the suite skips rather than hangs. /// /// /// Never connects from the fixture. 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. /// /// 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"; /// Bounded deadline for the TCP reachability probe (keeps an unreachable VPN from hanging the run). private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3); /// Short per-call deadline so a misconfigured / unreachable gateway fails fast instead of hanging. private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(20); private readonly string? _endpoint; private readonly string? _apiKey; private readonly bool _allowUntrusted; /// /// Reads the env config and runs one bounded TCP reachability probe. On any gap /// (missing endpoint/key, malformed URI, or unreachable host) is set /// and the suite skips cleanly. /// 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__ 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."; } /// Non-null when the suite must skip (unconfigured, malformed endpoint, or unreachable host). public string? SkipReason { get; } /// Convenience flag: true when env config is absent / malformed / unreachable. public bool NotConfigured => SkipReason is not null; /// The existing Galaxy / historian tag for the read round-trip (HISTGW_TEST_TAG); null when unset. public string? TestTag { get; } /// The Float sandbox tag for the write round-trip (HISTGW_WRITE_SANDBOX_TAG); null when unset. public string? WriteSandboxTag { get; } /// The source name for the alarm round-trip (HISTGW_ALARM_SOURCE); null when unset. public string? AlarmSource { get; } /// /// Builds the bound from env. Only valid when /// is false (the endpoint + key are non-null by then). /// 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, }; } /// /// 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). /// public HistorianGatewayClientAdapter CreateClient() => HistorianGatewayClientAdapter.Create(BuildOptions(), NullLoggerFactory.Instance); /// Creates a fresh real over its own adapter. public GatewayHistorianDataSource CreateDataSource() => new(CreateClient(), NullLogger.Instance); private static string? Trimmed(string envVar) { var value = Environment.GetEnvironmentVariable(envVar); return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } /// /// Bounded, never-throwing TCP connectivity probe to :. /// Returns true only on a connection accepted within . /// 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; } } }