diff --git a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs new file mode 100644 index 0000000..3db9017 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs @@ -0,0 +1,105 @@ +using System.Runtime.Versioning; +using AVEVA.Historian.Client.Models; +using AVEVA.Historian.Client.Wcf; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// 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 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 HISTORIAN_WCF_EVENT_HOST (independent of the gRPC live vars so it never runs by +/// accident). Optional: HISTORIAN_WCF_EVENT_PORT (default 32568), HISTORIAN_WCF_EVENT_USER +/// + HISTORIAN_WCF_EVENT_PASSWORD (absent => IntegratedSecurity), HISTORIAN_WCF_EVENT_SPN +/// (Kerberos SPN override; the default is the LocalPipe identity and will not authenticate remotely), +/// HISTORIAN_WCF_EVENT_DAYS (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" +/// +[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"); + bool integrated = string.IsNullOrEmpty(user); + + HistorianClientOptions options = new() + { + Host = host, + Port = port, + Transport = HistorianTransport.RemoteTcpIntegrated, + IntegratedSecurity = integrated, + UserName = user ?? string.Empty, + Password = password ?? string.Empty, + TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn, + }; + + 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) + { + outcome = $"threw {ex.GetType().Name}"; // message omitted — may carry host/credential text + } + + // Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY. + _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)" / "") — 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"); + } +}