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"); bool certificate = string.Equals( Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase); // The certificate (TLS) transport and NegotiateAuthentication-based app auth are cross-platform; // the integrated (Windows SSPI transport-security) transport is Windows-only. Skip when the gate // is unconfigured, or when an integrated run is requested off Windows. if (string.IsNullOrWhiteSpace(host) || (!certificate && !OperatingSystem.IsWindows())) { return; } 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"); string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA"); bool bypassVersion = string.Equals( Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_NOVERSIONCHECK"), "1", StringComparison.Ordinal); int timeoutSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TIMEOUT_SEC"), out int ts) && ts > 0 ? ts : 30; string? connModeRaw = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_CONNMODE"); uint? connModeOverride = null; if (!string.IsNullOrWhiteSpace(connModeRaw)) { string hex = connModeRaw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? connModeRaw[2..] : connModeRaw; if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out uint cm)) { connModeOverride = cm; } } 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, ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via, VerifyServerInterfaceVersion = !bypassVersion, ConnectTimeout = TimeSpan.FromSeconds(timeoutSec), RequestTimeout = TimeSpan.FromSeconds(timeoutSec), EventReadConnectionModeOverride = connModeOverride, }; HistorianWcfEventOrchestrator orchestrator = new(options); DateTime endUtc = DateTime.UtcNow; DateTime startUtc = endUtc - TimeSpan.FromDays(days); int budgetSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_BUDGET_SEC"), out int bgs) && bgs > 0 ? bgs : 60; int observed = 0; bool hasFirstEvent = false; string outcome = "completed"; try { // Race the read against an overall budget. The chain is synchronous WCF, so a stuck call // can't be token-cancelled — but the orchestrator's static/instance diagnostics are set as it // progresses, so on a budget timeout we still dump them to see which phase (auth / CM_EVENT // registration / query) is hanging. The abandoned task keeps running in the background harness. using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSec)); Task readTask = Task.Run(async () => { int n = 0; await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, budget.Token)) { n++; _ = evt; // event identity intentionally NOT logged (sanitized) } return n; }); Task finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(budgetSec + 5))); if (finished == readTask) { observed = await readTask; hasFirstEvent = observed > 0; } else { outcome = $"TIMED OUT after {budgetSec}s (chain still running — see return codes for phase reached)"; } } 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}, connMode={(connModeOverride.HasValue ? "0x" + connModeOverride.Value.ToString("X") : "default-0x402")})"); _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"); } }