Files
dohertj2 6888b8c55a Wire SDK for remote-TCP end to end; live-verify RemoteTcpIntegrated
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<T>`
   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) <noreply@anthropic.com>
2026-05-04 07:33:50 -04:00

234 lines
9.3 KiB
C#

using System.Runtime.Versioning;
using AVEVA.Historian.Client.Models;
using Xunit.Abstractions;
namespace AVEVA.Historian.Client.Tests;
/// <remarks>
/// Live verification of the RemoteTcpIntegrated and RemoteTcpCertificate transports
/// per <c>docs/plans/tcp-connection-validation.md</c>. Gated by env vars:
/// <list type="bullet">
/// <item><c>HISTORIAN_REMOTE_TCP_HOST</c> — hostname or IP of a reachable remote Historian.</item>
/// <item><c>HISTORIAN_REMOTE_TCP_TAG</c> — tag with non-zero history rows.</item>
/// <item><c>HISTORIAN_REMOTE_TCP_SPN</c> — optional Kerberos SPN override (default per <c>HistorianClientOptions.TargetSpn</c>).</item>
/// <item><c>HISTORIAN_REMOTE_TCPCERT_HOST</c> + <c>HISTORIAN_REMOTE_TCPCERT_DNS</c> — for the certificate transport variant.</item>
/// </list>
/// All tests skip cleanly if the gating env var isn't set.
/// </remarks>
[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<HistorianSample> 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<HistorianAggregateSample> 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<HistorianSample> 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<string> 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<HistorianEvent> 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,
};
}
}