Files
histsdk/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs
T
Joseph Doherty 7992e43908 test(c2): make WCF spike transport-selectable (integrated|certificate) + opt-in verbose
The first live run used the wrong port (32568 direct vs the 42568 WCF tunnel) and
hardcoded RemoteTcpIntegrated; via the tunnel the error advanced from socket-RST
to ProtocolException (binding/security mismatch). Add HISTORIAN_WCF_EVENT_TRANSPORT
(certificate), _DNSID, _ALLOW_UNTRUSTED, and an opt-in _VERBOSE for live binding
diagnosis. Default output stays sanitized; still Windows-only, never fails the suite.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-25 20:18:23 -04:00

122 lines
7.3 KiB
C#

using System.Runtime.Versioning;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
using Xunit.Abstractions;
namespace AVEVA.Historian.Client.Tests;
/// <remarks>
/// C2 live diagnostic spike (pending.md C2): does the managed WCF event-read path return rows
/// against an event-bearing 2023 R2 historian? gRPC event reads are a proven server-side dead-end
/// (docs/reverse-engineering/grpc-event-query-capture.md); the WCF transport is C2's only listed
/// unblock but is itself unproven (the orchestrator documents native code 76 / 85 on this server).
///
/// Drives <see cref="HistorianWcfEventOrchestrator"/> directly over RemoteTcpIntegrated and dumps the
/// full native chain. It NEVER fails the suite (skip+log diagnostic) and is inert off Windows — the
/// GREEN/RED call is by reading the printed "events observed" count + native return codes.
///
/// Gated by <c>HISTORIAN_WCF_EVENT_HOST</c> (independent of the gRPC live vars so it never runs by
/// accident). Optional: <c>HISTORIAN_WCF_EVENT_PORT</c> (default 32568), <c>HISTORIAN_WCF_EVENT_USER</c>
/// + <c>HISTORIAN_WCF_EVENT_PASSWORD</c> (absent =&gt; IntegratedSecurity), <c>HISTORIAN_WCF_EVENT_SPN</c>
/// (Kerberos SPN override; the default is the LocalPipe identity and will not authenticate remotely),
/// <c>HISTORIAN_WCF_EVENT_DAYS</c> (lookback window, default 90 — the live event store held 71,332
/// events in -90d).
///
/// Run from the Windows capture rig over VPN:
/// dotnet test --filter "FullyQualifiedName~WcfEventReadSpike" -l "console;verbosity=detailed"
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class WcfEventReadSpikeTests
{
private readonly ITestOutputHelper _output;
public WcfEventReadSpikeTests(ITestOutputHelper output) => _output = output;
[Fact]
public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return; // inert without the dedicated gate / off Windows
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p)
? p : HistorianClientOptions.DefaultPort; // 32568
int days = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DAYS"), out int d)
? d : 90;
string? user = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_USER");
string? password = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PASSWORD");
string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN");
string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID");
bool certificate = string.Equals(
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase);
bool allowUntrusted = string.Equals(
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal);
// Certificate transport carries no Windows transport credential (cert-validated TLS channel);
// integrated derives one from the logged-in identity unless an explicit DOMAIN\user is supplied.
// App-level ValidateClientCredential still uses UserName/Password (or the process identity).
bool integrated = !certificate && string.IsNullOrEmpty(user);
HistorianClientOptions options = new()
{
Host = host,
Port = port,
Transport = certificate ? HistorianTransport.RemoteTcpCertificate : HistorianTransport.RemoteTcpIntegrated,
IntegratedSecurity = integrated,
UserName = user ?? string.Empty,
Password = password ?? string.Empty,
TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn,
ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId,
AllowUntrustedServerCertificate = allowUntrusted,
};
HistorianWcfEventOrchestrator orchestrator = new(options);
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(days);
int observed = 0;
bool hasFirstEvent = false;
string outcome = "completed";
try
{
await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, CancellationToken.None))
{
observed++;
hasFirstEvent = true;
_ = evt; // event identity intentionally NOT logged (sanitized)
}
}
catch (Exception ex)
{
// Default: type name only (sanitized). Opt into messages for live binding/protocol debugging
// via HISTORIAN_WCF_EVENT_VERBOSE=1 — binding/protocol errors may carry the endpoint host
// (already known to the operator) but never credentials; still off by default.
outcome = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VERBOSE"), "1", StringComparison.Ordinal)
? $"threw {ex.GetType().FullName} :: {ex.Message} | inner={ex.InnerException?.GetType().FullName} :: {ex.InnerException?.Message}"
: $"threw {ex.GetType().Name}";
}
// Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY.
_output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted})");
_output.WriteLine($"[C2 WCF spike] outcome: {outcome}");
_output.WriteLine($"[C2 WCF spike] events observed: {observed}");
_output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}");
_output.WriteLine($"[C2 WCF spike] LastUpdC3ReturnCode: {HistorianWcfEventOrchestrator.LastUpdC3ReturnCode}");
_output.WriteLine($"[C2 WCF spike] LastRTag2ReturnCode: {HistorianWcfEventOrchestrator.LastRTag2ReturnCode}");
_output.WriteLine($"[C2 WCF spike] LastAddReturnCode(EnsT2): {HistorianWcfEventOrchestrator.LastAddReturnCode}");
_output.WriteLine($"[C2 WCF spike] LastAddOutputLength: {HistorianWcfEventOrchestrator.LastAddOutputLength}");
_output.WriteLine($"[C2 WCF spike] LastEnsT2PayloadSha256: {HistorianWcfEventOrchestrator.LastEnsT2PayloadSha256}");
_output.WriteLine($"[C2 WCF spike] LastResultBufferLength: {orchestrator.LastResultBufferLength}");
// Contract-safe: LastErrorBufferDescription is DescribeNativeError's STRUCTURED formatting of the
// 5-byte native error buffer ("type=N code=M (0xHEX)" / "<short>") — never freeform server text,
// FQDN, SPN, or credentials. The code value (e.g. 76 / 85) is the RED-case signal this spike exists
// to capture, so it is dumped in full rather than reduced to a length.
_output.WriteLine($"[C2 WCF spike] LastErrorBufferDescription: {orchestrator.LastErrorBufferDescription}");
_output.WriteLine($"[C2 WCF spike] window days: {days}");
// Diagnostic: NEVER fails the suite. GREEN = observed > 0; RED = observed == 0 (read the output).
Assert.True(observed >= 0, "diagnostic always passes; the GREEN/RED signal is the printed count");
}
}