From 6888b8c55a9ed147bfae4c2cbe7c3a26edea95a0 Mon Sep 17 00:00:00 2001 From: dohertj2 Date: Mon, 4 May 2026 07:33:50 -0400 Subject: [PATCH] Wire SDK for remote-TCP end to end; live-verify RemoteTcpIntegrated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes docs/plans/tcp-connection-validation.md. Full read-only SDK surface now works against a remote AVEVA Historian over Net.TCP with Windows transport authentication. 124/124 tests pass; the +10 new live integration tests in RemoteTcpIntegrationTests.cs are gated by HISTORIAN_REMOTE_TCP_HOST + HISTORIAN_REMOTE_TCP_TAG. Two SDK bugs found while executing the plan: 1. Historian2020ProtocolDialect.ReadRawAsync / ReadAggregateAsync / ReadAtTimeAsync / ReadEventsAsync had explicit `if (_options.Transport != HistorianTransport.LocalPipe) return Missing` guards. These were a guardrail from before the orchestrators handled TCP; the orchestrators have always used CreateBindingPair(options) which dispatches on transport correctly. Gates removed. 2. HistorianWcfStatusClient and HistorianWcfEventOrchestrator hardcoded HistorianWcfBindingFactory.CreatePipeEndpointAddress for the auxiliary services (Stat, Trx, Retr). Worked for LocalPipe; for TCP it produced an EndpointAddress with scheme net.pipe attached to a TCP binding (channel factory rejected the URI). Worse, when only the endpoint was transport-aware, the binding still requested a Windows-transport- security upgrade that the Stat endpoint over TCP doesn't support (auxiliaries don't repeat the auth — the Hist session is already authenticated). Added two helpers: - HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, name) -> net.pipe for LocalPipe, net.tcp for remote - HistorianWcfBindingFactory.CreateAuxiliaryBinding(options) -> NamedPipe for LocalPipe, plain MdasNetTcpBinding for remote Both call sites updated. Live verification against the remote (probed previously in prior sessions; reachability re-confirmed today): - ProbeAsync over RemoteTcpIntegrated and RemoteTcpCertificate - ReadRawAsync (8 samples returned for SysTimeSec) - ReadAggregateAsync (TimeWeightedAverage, 1-min cycle, 10-min window) - ReadAtTimeAsync (3 timestamps) - BrowseTagNamesAsync (finds the test tag) - GetTagMetadataAsync (full metadata populated) - ReadEventsAsync (chain runs without throwing) - GetConnectionStatusAsync (ConnectedToServer=true) - GetSystemParameterAsync (HistorianVersion="20,0,000,000") The default 'NT SERVICE\aahClientAccessPoint' SPN turned out to work for the remote too — discovery workstream A (SPN-finding) was not needed in practice. README and the TCP plan doc updated to reflect the executed status. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 +- docs/plans/tcp-connection-validation.md | 28 ++- .../Protocol/Historian2020ProtocolDialect.cs | 28 +-- .../Wcf/HistorianWcfBindingFactory.cs | 38 +++ .../Wcf/HistorianWcfEventOrchestrator.cs | 7 +- .../Wcf/HistorianWcfStatusClient.cs | 5 +- .../RemoteTcpIntegrationTests.cs | 233 ++++++++++++++++++ 7 files changed, 316 insertions(+), 38 deletions(-) create mode 100644 tests/AVEVA.Historian.Client.Tests/RemoteTcpIntegrationTests.cs diff --git a/README.md b/README.md index 266bf29..8766e6d 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,10 @@ await foreach (HistorianSample sample in client.ReadRawAsync( ``` For a remote Historian over Net.TCP set `Transport = HistorianTransport.RemoteTcpIntegrated` -and `Host` to the server hostname. Remote-TCP plumbing exists but only `LocalPipe` -has live verification in this checkout. +and `Host` to the server hostname. Both `RemoteTcpIntegrated` (Windows transport +auth) and `RemoteTcpCertificate` (server-cert TLS) are now live-verified for +`ProbeAsync`; `RemoteTcpIntegrated` is additionally live-verified for the full +read / browse / metadata / event / status surface. ## Build & test @@ -158,6 +160,9 @@ property dictionary → Retr.EndEventQuery → Hist.Close2 ## Status -108 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`). -Full read-only SDK surface verified end-to-end against a local Historian. Remote-TCP -transports and explicit-credentials path are wired but await live verification. +124 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`). +Full read-only SDK surface verified end-to-end against both a local Historian +(`LocalPipe`) and a remote Historian (`RemoteTcpIntegrated` over Net.TCP with +Windows transport auth). `RemoteTcpCertificate` ProbeAsync is live-verified; +the other ops over the certificate transport plus the explicit-credentials +path await live verification. diff --git a/docs/plans/tcp-connection-validation.md b/docs/plans/tcp-connection-validation.md index d1a12e5..9e68ac1 100644 --- a/docs/plans/tcp-connection-validation.md +++ b/docs/plans/tcp-connection-validation.md @@ -1,9 +1,29 @@ # TCP Connection Validation Plan -Status: PLAN ONLY (no implementation yet). Scope is **live verification of -the existing remote-TCP transport plumbing**, not new wire-protocol -reverse-engineering — the wire format itself is the same MDAS-encoded SOAP -already verified end-to-end over `LocalPipe`. +Status: **EXECUTED on 2026-05-04**. RemoteTcpIntegrated transport is now +live-verified end-to-end against `10.100.0.48` (Historian InterfaceVersion=11) +for ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, +ReadEventsAsync, BrowseTagNamesAsync, GetTagMetadataAsync, +GetConnectionStatusAsync, GetSystemParameterAsync. RemoteTcpCertificate +verified for ProbeAsync only (full surface awaits a non-current-user +credential probe). Test count 114 → 124 (+10) per success criteria. + +Two SDK bugs were uncovered and fixed during execution: +1. `Historian2020ProtocolDialect` had explicit `if (Transport != LocalPipe) + return Missing` gates on Read/Aggregate/AtTime/ReadEvents that were a + leftover guardrail from before the orchestrators handled TCP. Removed — + the orchestrators already used `CreateBindingPair(options)` correctly. +2. `HistorianWcfStatusClient` and `HistorianWcfEventOrchestrator` hardcoded + `CreatePipeEndpointAddress` for auxiliary services (Stat, Trx, Retr). + Added `HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress` and + `CreateAuxiliaryBinding` helpers that dispatch on `Transport`; for TCP + the auxiliaries use plain `MdasNetTcpBinding` (no transport upgrade — + the Hist endpoint already authenticated the session). + +Original scope is **live verification of the existing remote-TCP transport +plumbing**, not new wire-protocol reverse-engineering — the wire format +itself is the same MDAS-encoded SOAP already verified end-to-end over +`LocalPipe`. Read together with: diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index 64ce5fb..38d64f4 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -15,14 +15,9 @@ internal sealed class Historian2020ProtocolDialect public IAsyncEnumerable ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken) { - if (_options.Transport != HistorianTransport.LocalPipe) - { - return Missing($"StartDataRetrievalQuery/Full over {_options.Transport}", cancellationToken); - } - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return Missing("StartDataRetrievalQuery/Full requires Windows for the LocalPipe + SSPI path", cancellationToken); + return Missing("StartDataRetrievalQuery/Full requires Windows for the SSPI path", cancellationToken); } return ReadRawWindowsAsync(tag, startUtc, endUtc, maxValues, cancellationToken); @@ -38,14 +33,9 @@ internal sealed class Historian2020ProtocolDialect public IAsyncEnumerable ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken) { - if (_options.Transport != HistorianTransport.LocalPipe) - { - return Missing($"StartDataRetrievalQuery/{mode} over {_options.Transport}", cancellationToken); - } - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return Missing($"StartDataRetrievalQuery/{mode} requires Windows for the LocalPipe + SSPI path", cancellationToken); + return Missing($"StartDataRetrievalQuery/{mode} requires Windows for the SSPI path", cancellationToken); } return ReadAggregateWindowsAsync(tag, startUtc, endUtc, mode, interval, cancellationToken); @@ -63,14 +53,9 @@ internal sealed class Historian2020ProtocolDialect { cancellationToken.ThrowIfCancellationRequested(); - if (_options.Transport != HistorianTransport.LocalPipe) - { - throw new ProtocolEvidenceMissingException($"StartDataRetrievalQuery/Interpolated at-time over {_options.Transport}"); - } - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - throw new ProtocolEvidenceMissingException("StartDataRetrievalQuery/Interpolated at-time requires Windows for the LocalPipe + SSPI path"); + throw new ProtocolEvidenceMissingException("StartDataRetrievalQuery/Interpolated at-time requires Windows for the SSPI path"); } #pragma warning disable CA1416 @@ -86,14 +71,9 @@ internal sealed class Historian2020ProtocolDialect public IAsyncEnumerable ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken) { - if (_options.Transport != HistorianTransport.LocalPipe) - { - return Missing($"StartEventDataRetrievalQuery over {_options.Transport}", cancellationToken); - } - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return Missing("StartEventDataRetrievalQuery requires Windows for the LocalPipe + SSPI path", cancellationToken); + return Missing("StartEventDataRetrievalQuery requires Windows for the SSPI path", cancellationToken); } return ReadEventsWindowsAsync(startUtc, endUtc, cancellationToken); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfBindingFactory.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfBindingFactory.cs index 1dcf1d3..dba9f31 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfBindingFactory.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfBindingFactory.cs @@ -164,4 +164,42 @@ internal static class HistorianWcfBindingFactory return new EndpointAddress($"net.pipe://{host}/{serviceName}"); } + + /// + /// Returns the appropriate endpoint address for an auxiliary service (Stat, Trx, etc.) + /// based on the transport — net.pipe for LocalPipe, net.tcp for the remote variants. + /// Use this rather than directly when the calling + /// code may run under any transport. + /// + public static EndpointAddress CreateAuxiliaryEndpointAddress(HistorianClientOptions options, string serviceName) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(serviceName); + + return options.Transport == HistorianTransport.LocalPipe + ? CreatePipeEndpointAddress(options.Host, serviceName) + : CreateEndpointAddress(options.Host, options.Port, serviceName); + } + + /// + /// Returns the appropriate binding for an auxiliary service (Stat, Trx, etc.) given the + /// transport. For LocalPipe, same NamedPipe binding as History. For remote TCP variants, + /// plain — auxiliaries don't repeat the Windows- + /// transport-security upgrade that the History service negotiates; the established session + /// authenticates the client already. + /// + [SupportedOSPlatform("windows")] + public static Binding CreateAuxiliaryBinding(HistorianClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + TimeSpan timeout = options.RequestTimeout; + return options.Transport switch + { + HistorianTransport.LocalPipe => CreateMdasNetNamedPipeBinding(timeout), + HistorianTransport.RemoteTcpIntegrated => CreateMdasNetTcpBinding(timeout), + HistorianTransport.RemoteTcpCertificate => CreateMdasNetTcpBinding(timeout), + _ => throw new NotSupportedException($"Transport {options.Transport} is not supported.") + }; + } } diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs index 147cb45..c4d34f6 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs @@ -92,13 +92,14 @@ internal sealed class HistorianWcfEventOrchestrator { Guid contextKey = Guid.NewGuid(); var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options); - EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Status); - EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Transaction); + Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options); + EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status); + EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction); uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection( _options, histBinding, histEndpoint, contextKey, cancellationToken, connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode, additionalSetup: (historyChannel, context) => - AddCmEventTagViaAddT(historyChannel, context, histBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint)); + AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint)); return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, cancellationToken); } diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfStatusClient.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfStatusClient.cs index ba6a069..591fa12 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfStatusClient.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfStatusClient.cs @@ -36,12 +36,13 @@ internal static class HistorianWcfStatusClient { Guid contextKey = Guid.NewGuid(); var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options); - EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.Status); + Binding statusBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(options); + EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, HistorianWcfServiceNames.Status); string? value = null; HistorianWcfAuthChainHelper.OpenAuthenticatedConnection( options, histBinding, histEndpoint, contextKey, CancellationToken.None, - additionalSetup: (_, context) => value = QuerySystemParameter(histBinding, statusEndpoint, context.ClientHandle, parameterName)); + additionalSetup: (_, context) => value = QuerySystemParameter(statusBinding, statusEndpoint, context.ClientHandle, parameterName)); return value; } diff --git a/tests/AVEVA.Historian.Client.Tests/RemoteTcpIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/RemoteTcpIntegrationTests.cs new file mode 100644 index 0000000..6855788 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/RemoteTcpIntegrationTests.cs @@ -0,0 +1,233 @@ +using System.Runtime.Versioning; +using AVEVA.Historian.Client.Models; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// Live verification of the RemoteTcpIntegrated and RemoteTcpCertificate transports +/// per docs/plans/tcp-connection-validation.md. Gated by env vars: +/// +/// HISTORIAN_REMOTE_TCP_HOST — hostname or IP of a reachable remote Historian. +/// HISTORIAN_REMOTE_TCP_TAG — tag with non-zero history rows. +/// HISTORIAN_REMOTE_TCP_SPN — optional Kerberos SPN override (default per HistorianClientOptions.TargetSpn). +/// HISTORIAN_REMOTE_TCPCERT_HOST + HISTORIAN_REMOTE_TCPCERT_DNS — for the certificate transport variant. +/// +/// All tests skip cleanly if the gating env var isn't set. +/// +[SupportedOSPlatform("windows")] +public sealed class RemoteTcpIntegrationTests +{ + private readonly ITestOutputHelper _output; + + public RemoteTcpIntegrationTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task ProbeAsync_RemoteTcpIntegrated_ReturnsTrue() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + bool reachable = await client.ProbeAsync(CancellationToken.None); + Assert.True(reachable, "ProbeAsync against remote-TCP host returned false"); + } + + [Fact] + public async Task ReadRawAsync_RemoteTcpIntegrated_ReturnsAtLeastOneRow() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromDays(7); + + List samples = []; + await foreach (HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None)) + { + samples.Add(sample); + } + + _output.WriteLine($"Returned {samples.Count} samples for {testTag}"); + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + + [Fact] + public async Task GetTagMetadataAsync_RemoteTcpIntegrated_PopulatesFields() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(testTag, CancellationToken.None); + Assert.NotNull(metadata); + Assert.Equal(testTag, metadata.Name); + } + + [Fact] + public async Task GetSystemParameterAsync_RemoteTcpIntegrated_ReturnsHistorianVersion() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None); + _output.WriteLine($"HistorianVersion: {value}"); + Assert.False(string.IsNullOrWhiteSpace(value)); + } + + [Fact] + public async Task ReadAggregateAsync_RemoteTcpIntegrated_ReturnsTimeWeightedRows() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromMinutes(10); + + List samples = []; + await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync( + testTag, startUtc, endUtc, RetrievalMode.TimeWeightedAverage, TimeSpan.FromMinutes(1), CancellationToken.None)) + { + samples.Add(sample); + } + + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + + [Fact] + public async Task ReadAtTimeAsync_RemoteTcpIntegrated_ReturnsTimestamps() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime now = DateTime.UtcNow; + DateTime[] timestamps = [now - TimeSpan.FromMinutes(5), now - TimeSpan.FromMinutes(2), now - TimeSpan.FromMinutes(1)]; + IReadOnlyList samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None); + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + + [Fact] + public async Task BrowseTagNamesAsync_RemoteTcpIntegrated_FindsTestTag() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + List names = []; + await foreach (string name in client.BrowseTagNamesAsync(testTag, CancellationToken.None)) + { + names.Add(name); + } + Assert.Contains(testTag, names); + } + + [Fact] + public async Task ReadEventsAsync_RemoteTcpIntegrated_DoesNotThrow() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromDays(1); + + // Empty result is acceptable — we're just verifying the chain doesn't throw over TCP. + List events = []; + await foreach (HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None)) + { + events.Add(evt); + } + Assert.NotNull(events); + } + + [Fact] + public async Task GetConnectionStatusAsync_RemoteTcpIntegrated_ReportsConnectedToServer() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None); + Assert.True(status.ConnectedToServer); + Assert.False(status.ErrorOccurred); + Assert.Equal(host, status.ServerName); + } + + [Fact] + public async Task ProbeAsync_RemoteTcpCertificate_ReturnsTrue() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCPCERT_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(new HistorianClientOptions + { + Host = host, + Port = HistorianClientOptions.DefaultPort, + IntegratedSecurity = false, + Transport = HistorianTransport.RemoteTcpCertificate, + }); + + bool reachable = await client.ProbeAsync(CancellationToken.None); + Assert.True(reachable, "ProbeAsync over RemoteTcpCertificate returned false"); + } + + private static HistorianClientOptions BuildIntegratedOptions(string host) + { + string? spn = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_SPN"); + return new HistorianClientOptions + { + Host = host, + Port = HistorianClientOptions.DefaultPort, + IntegratedSecurity = true, + Transport = HistorianTransport.RemoteTcpIntegrated, + // SPN default in HistorianClientOptions is "NT SERVICE\aahClientAccessPoint" which is the + // LocalPipe service identity; for remote TCP, override via env var if needed. + TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn, + }; + } +}