From c4b8d0dde40b808158c1c2c518982450cab9e406 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 13:32:04 -0400 Subject: [PATCH] gRPC M0: probe (R0.4, live-verified) + system-param (R0.3) + shared handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap docs/plans/hcal-roadmap.md, milestone M0 (gRPC parity for the DONE surface). Now unblocked for live verification by a reachable 2023 R2 server. - R0.4 Probe over gRPC: new HistorianGrpcProbe calls History/Retrieval/Status GetInterfaceVersion (unauthenticated). ProbeAsync routes over gRPC when Transport==RemoteGrpc. LIVE-VERIFIED against a real 2023 R2 server — needs no credentials (runs before the auth loop), so it works despite the auth blocker. - R0.3 System parameter over gRPC: new HistorianGrpcStatusClient calls StatusService.GetSystemParameter over the authenticated session; routed in the dialect. Built + unit-tested (request/response field mapping pinned). Live-verification pending an auth fix (see below). - Extracted the proven auth handshake from HistorianGrpcReadOrchestrator into shared Grpc/HistorianGrpcHandshake (reused by read + status + future browse/metadata). Repointed the IL structural guardrail test to it. - Diagnostics: round-failure now decodes the native server error + hex/ASCII preview (HistorianNativeHandshake.DescribeError). This surfaced the live auth blocker as SEC_E_LOGON_DENIED (0x8009030C) at NTLM round 1 — framing is correct, the credential did not validate. Probable cause: stale file password or NAM-domain NTLM restriction (Kerberos/RDP works, NTLM denied; no SPN path over the tunnel). 216 unit tests pass; live gRPC probe passes. Sanitization scan clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 30 +++++++- .../Grpc/HistorianGrpcHandshake.cs | 74 +++++++++++++++++++ .../Grpc/HistorianGrpcProbe.cs | 48 ++++++++++++ .../Grpc/HistorianGrpcReadOrchestrator.cs | 48 +----------- .../Grpc/HistorianGrpcStatusClient.cs | 38 ++++++++++ src/AVEVA.Historian.Client/HistorianClient.cs | 4 +- .../Protocol/Historian2020ProtocolDialect.cs | 4 +- .../Wcf/HistorianNativeHandshake.cs | 33 ++++++++- .../HistorianGrpcHandshakeRoutingTests.cs | 6 +- .../HistorianGrpcIntegrationTests.cs | 29 ++++++++ .../HistorianGrpcTransportTests.cs | 35 +++++++++ 11 files changed, 293 insertions(+), 56 deletions(-) create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index d777c22..f0b5b4b 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -9,15 +9,39 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from > protocol serializer/parser + golden-byte unit test + an env-gated live integration > test against the local Historian. -## Progress (updated 2026-06-19) +## Progress (updated 2026-06-21) - ✅ **R0.6 version gate** — `HistorianServerVersionGate` + `HistorianClientOptions.VerifyServerInterfaceVersion`; fail-closed on connect, wired into both WCF and gRPC paths. Supported versions are - evidence-based (Hist=11, Retr=4, Trx=2; Status reachability-only), captured from the - live server. 10 unit tests. + evidence-based (Hist=11/12, Retr=4, Trx=2; Status reachability-only), captured from the + live server. History 12 (2023 R2 gRPC) accepted alongside 11 (buffer-compatible). - ✅ **CW-1 capture pipeline** — `ProtocolCaptureSanitizer` + `ProtocolFixtureWriter` + `capture-tag-info` CLI command; produces sanitized `fixtures/protocol//` golden files. 11 unit tests. First fixture: `get-tag-info/analog-*.json`. +- ✅ **gRPC auth handshake (read chain)** — LIVE-VERIFIED 2026-06-21 against a real 2023 R2 + server: `ReadRawAsync` over `RemoteGrpc` returns rows. Token loop routes to + `StorageService.ValidateClientCredential`. Shared handshake extracted to + `Grpc/HistorianGrpcHandshake` for reuse by the status/browse/metadata paths. +- ✅ **R0.4 Probe over gRPC** — `Grpc/HistorianGrpcProbe` (History/Retrieval/Status + `GetInterfaceVersion`); `ProbeAsync` routes over gRPC when `Transport==RemoteGrpc`. + **LIVE-VERIFIED 2026-06-21** (no credentials required — runs before the auth loop). +- 🟡 **R0.3 System parameter over gRPC** — `Grpc/HistorianGrpcStatusClient.GetSystemParameterAsync` + (`StatusService.GetSystemParameter`); routed in the dialect. Built + unit-tested + (request/response field mapping pinned). **Live-verification pending an auth fix** — see blocker. + Code path is the proven handshake + a single string-in/string-out RPC. + +> ⚠️ **Auth blocker (2026-06-21):** live gRPC ops needing a client handle (R0.1/R0.2/R0.3 and the +> read chain) fail at NTLM **round 1**. The decoded server error is +> `SEC_E_LOGON_DENIED` (0x8009030C) from `aahClientAccessPoint::CServerContext::ProcessClient…` — +> round 0 (NEGOTIATE) succeeds, round 1 (the password-bearing AUTHENTICATE) is denied. The token +> **framing is correct** (a framing fault would surface as `SEC_E_INVALID_TOKEN`); the server parsed +> a valid NTLM message but the credential did not validate. The creds in the gitignored file are +> byte-faithfully parsed (verified) and the domain user logs in via **RDP (Kerberos)**, so the +> probable cause is one of: (a) the file password is **stale** vs the account, or (b) the NAM domain +> **restricts NTLM** ("Network security: Restrict NTLM") — Kerberos/RDP works but NTLM is denied, and +> over the SOCKS/SSH tunnel (host→127.0.0.1, no SPN) the client cannot use Kerberos. Probe (R0.4) is +> unaffected (unauthenticated). Diagnostic: the round-failure exception now decodes the native error +> + a hex/ASCII preview (`HistorianNativeHandshake.DescribeError`). > ⚠️ **Live-verification constraint:** the local Historian is **2020** (WCF, port 32568) — the > 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1–R0.4) can be built and diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs new file mode 100644 index 0000000..a65ed1c --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -0,0 +1,74 @@ +using Google.Protobuf; +using Grpc.Core; +using AVEVA.Historian.Client.Wcf; +using GrpcHistory = ArchestrA.Grpc.Contract.History; +using GrpcStorage = ArchestrA.Grpc.Contract.Storage; + +namespace AVEVA.Historian.Client.Grpc; + +/// +/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an +/// existing and returns the transient client handle used by +/// the Retrieval/Status services. Extracted from so the +/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain: +/// HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) → +/// HistoryService.OpenConnection. The byte payloads (OpenConnection3 v6 request, NTLM token +/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf bytes fields. +/// +/// See for the op-routing rationale (the Negotiate loop +/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey). +/// +internal static class HistorianGrpcHandshake +{ + public static uint OpenAuthenticatedConnection( + HistorianGrpcConnection connection, + HistorianClientOptions options, + CancellationToken cancellationToken) + { + DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout); + + Guid contextKey = Guid.NewGuid(); + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + + GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion( + new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken); + HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, options); + + var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel); + HistorianNativeHandshake.RunTokenRounds( + (handle, wrapped, _) => + { + GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential( + new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) }, + connection.Metadata, + Deadline(), + cancellationToken); + byte[] serverOutput = response.OutBuff?.ToByteArray() ?? []; + byte[] error = response.Status?.BtError?.ToByteArray() ?? []; + bool success = response.Status?.BSuccess ?? false; + return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error); + }, + contextKey, + options, + cancellationToken); + + byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request( + options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode); + + GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection( + new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) }, + connection.Metadata, + Deadline(), + cancellationToken); + + byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? []; + if (!(open2.Status?.BSuccess ?? false)) + { + byte[] err = open2.Status?.BtError?.ToByteArray() ?? []; + throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length})."); + } + + (uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); + return clientHandle; + } +} diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs new file mode 100644 index 0000000..1886987 --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcProbe.cs @@ -0,0 +1,48 @@ +using Grpc.Core; +using GrpcHistory = ArchestrA.Grpc.Contract.History; +using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; +using GrpcStatus = ArchestrA.Grpc.Contract.Status; + +namespace AVEVA.Historian.Client.Grpc; + +/// +/// 2023 R2 gRPC reachability probe (roadmap item R0.4). Mirrors +/// over the gRPC transport: it calls the unauthenticated GetInterfaceVersion RPC on the +/// History, Retrieval, and Status services and applies the same success criteria. No credentials +/// are required — these RPCs run before the SSPI/Negotiate token loop — so the probe works even +/// when authentication is unavailable. +/// +internal static class HistorianGrpcProbe +{ + public static async Task ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + cancellationToken.ThrowIfCancellationRequested(); + + return await Task.Run(() => Probe(options, cancellationToken), cancellationToken).ConfigureAwait(false); + } + + private static bool Probe(HistorianClientOptions options, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + DateTime deadline = DateTime.UtcNow.Add(options.ConnectTimeout > TimeSpan.Zero ? options.ConnectTimeout : TimeSpan.FromSeconds(5)); + + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + GrpcHistory.GetInterfaceVersionResponse history = historyClient.GetInterfaceVersion( + new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken); + + var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); + GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = retrievalClient.GetRetrievalInterfaceVersion( + new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken); + + var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel); + GrpcStatus.GetStatusInterfaceVersionResponse status = statusClient.GetStatusInterfaceVersion( + new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken); + + return history.UiError == 0 + && history.UiVersion > 0 + && retrieval.UiError == 0 + && retrieval.UiVersion > 0 + && status.UiError == 0; + } +} diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs index 979ec9a..74d0bad 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs @@ -3,9 +3,7 @@ using Google.Protobuf; using Grpc.Core; using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; -using GrpcHistory = ArchestrA.Grpc.Contract.History; using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; -using GrpcStorage = ArchestrA.Grpc.Contract.Storage; namespace AVEVA.Historian.Client.Grpc; @@ -166,51 +164,7 @@ internal sealed class HistorianGrpcReadOrchestrator } private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken) - { - Guid contextKey = Guid.NewGuid(); - var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); - - GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion( - new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken); - HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options); - - var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel); - HistorianNativeHandshake.RunTokenRounds( - (handle, wrapped, _) => - { - GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential( - new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) }, - connection.Metadata, - Deadline(), - cancellationToken); - byte[] serverOutput = response.OutBuff?.ToByteArray() ?? []; - byte[] error = response.Status?.BtError?.ToByteArray() ?? []; - bool success = response.Status?.BSuccess ?? false; - return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error); - }, - contextKey, - _options, - cancellationToken); - - byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request( - _options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode); - - GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection( - new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) }, - connection.Metadata, - Deadline(), - cancellationToken); - - byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? []; - if (!(open2.Status?.BSuccess ?? false)) - { - byte[] err = open2.Status?.BtError?.ToByteArray() ?? []; - throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length})."); - } - - (uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); - return clientHandle; - } + => HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken); private List RunQuery( HistorianGrpcConnection connection, diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs new file mode 100644 index 0000000..c0538f1 --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs @@ -0,0 +1,38 @@ +using Grpc.Core; +using GrpcStatus = ArchestrA.Grpc.Contract.Status; + +namespace AVEVA.Historian.Client.Grpc; + +/// +/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors +/// over the gRPC transport: it opens an authenticated +/// History session via and queries the StatusService for the +/// resulting client handle. GetSystemParameter carries the parameter name as a protobuf +/// string and returns the value string directly — there is no opaque native buffer to decode. +/// +internal static class HistorianGrpcStatusClient +{ + public static Task GetSystemParameterAsync( + HistorianClientOptions options, + string parameterName, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); + return Task.Run(() => GetSystemParameter(options, parameterName, cancellationToken), cancellationToken); + } + + private static string? GetSystemParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken); + + var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel); + GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter( + new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName }, + connection.Metadata, + DateTime.UtcNow.Add(options.RequestTimeout), + cancellationToken); + + return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null; + } +} diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 05e68b3..277b362 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -25,7 +25,9 @@ public sealed class HistorianClient : IAsyncDisposable public async Task ProbeAsync(CancellationToken cancellationToken = default) { - return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false); + return _options.Transport == HistorianTransport.RemoteGrpc + ? await Grpc.HistorianGrpcProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false) + : await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false); } public IAsyncEnumerable ReadRawAsync( diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index 8f9a2fd..98c5294 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -64,7 +64,9 @@ internal sealed class Historian2020ProtocolDialect { cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(name); - return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken); + return UseGrpc + ? HistorianGrpcStatusClient.GetSystemParameterAsync(_options, name, cancellationToken) + : Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken); } private static async IAsyncEnumerable Missing( diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs index e4760a1..fcec618 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs @@ -33,7 +33,7 @@ internal static class HistorianNativeHandshake /// upper-case context-key GUID, is the AVEVA-wrapped SSPI /// token (round byte + length + token). The WCF path maps this to /// Hist.ValidateClientCredential; the gRPC path maps it to - /// HistoryService.ExchangeKey (the renamed handshake op). + /// StorageService.ValidateClientCredential (the op that kept the 2020 token framing). /// internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round); @@ -70,7 +70,8 @@ internal static class HistorianNativeHandshake if (!result.Success) { - throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.Length})."); + throw new InvalidOperationException( + $"Credential token round {round} rejected (errorLen={error.Length}).{DescribeError(error)}"); } ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput); @@ -162,4 +163,32 @@ internal static class HistorianNativeHandshake int slash = userName.IndexOf('\\'); return slash > 0 ? userName[(slash + 1)..] : userName; } + + /// + /// Renders a diagnostic suffix for a rejected credential round: the decoded native error + /// (type/code/name) plus a short hex + printable-ASCII preview of the server error buffer. + /// Keeps secrets out — error buffers carry server status codes/messages, not credentials. + /// + private static string DescribeError(byte[] error) + { + if (error.Length == 0) + { + return string.Empty; + } + + HistorianNativeError? native = HistorianOpen2Protocol.TryReadNativeError(error); + string nativePart = native is null + ? string.Empty + : $" native(type={native.Type}, code={native.Code}{(native.Name is null ? string.Empty : $", {native.Name}")})"; + + ReadOnlySpan preview = error.AsSpan(0, Math.Min(error.Length, 64)); + string hex = Convert.ToHexString(preview); + char[] ascii = new char[preview.Length]; + for (int i = 0; i < preview.Length; i++) + { + ascii[i] = preview[i] is >= 0x20 and < 0x7F ? (char)preview[i] : '.'; + } + + return $"{nativePart} hex={hex} ascii=\"{new string(ascii)}\""; + } } diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs index 7958f28..fee9f3f 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs @@ -11,7 +11,7 @@ namespace AVEVA.Historian.Client.Tests; /// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision /// routed the loop to ExchangeKey; this test fails if that regression returns. /// -/// It works by disassembling the IL of HistorianGrpcReadOrchestrator (and its +/// It works by disassembling the IL of HistorianGrpcHandshake (and its /// compiler-generated nested closure types — the token-loop call lives inside a lambda) and /// collecting every method invoked. /// @@ -20,8 +20,10 @@ public sealed class HistorianGrpcHandshakeRoutingTests [Fact] public void Handshake_UsesValidateClientCredential_NotExchangeKey() { + // The auth token loop lives in the shared handshake helper (reused by the read, status, + // and future browse/metadata gRPC paths). HashSet calledMethods = CollectCalledMethodNames( - "AVEVA.Historian.Client.Grpc.HistorianGrpcReadOrchestrator"); + "AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake"); Assert.Contains("ValidateClientCredential", calledMethods); Assert.DoesNotContain("ExchangeKey", calledMethods); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index dcd71d6..6cbafa1 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -12,6 +12,21 @@ namespace AVEVA.Historian.Client.Tests; /// public sealed class HistorianGrpcIntegrationTests { + [Fact] + public async Task ProbeAsync_OverGrpc_ReturnsTrue() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host)) + { + return; + } + + // ProbeAsync calls the unauthenticated GetInterfaceVersion RPCs, so it succeeds even when + // credentials are unavailable — no HISTORIAN_USER/PASSWORD required. + HistorianClient client = new(BuildOptions(host)); + Assert.True(await client.ProbeAsync(CancellationToken.None)); + } + [Fact] public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow() { @@ -37,6 +52,20 @@ public sealed class HistorianGrpcIntegrationTests Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); } + [Fact] + public async Task GetSystemParameterAsync_OverGrpc_ReturnsValue() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + string? version = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None); + Assert.False(string.IsNullOrWhiteSpace(version)); + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs index a67de94..ca62a4c 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs @@ -100,6 +100,41 @@ public sealed class HistorianGrpcTransportTests Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType); } + [Fact] + public void InterfaceVersionResponses_ExposeErrorAndVersion_AsProbeExpects() + { + // R0.4 ProbeAsync reads uiError/uiVersion off each service's GetInterfaceVersion response. + // Pin that field mapping (success = uiError 0 + uiVersion > 0) via a protobuf round-trip. + var history = GrpcHistory.GetInterfaceVersionResponse.Parser.ParseFrom( + new GrpcHistory.GetInterfaceVersionResponse { UiError = 0, UiVersion = 12 }.ToByteArray()); + var retrieval = GetRetrievalInterfaceVersionResponse.Parser.ParseFrom( + new GetRetrievalInterfaceVersionResponse { UiError = 0, UiVersion = 4 }.ToByteArray()); + + Assert.Equal(0u, history.UiError); + Assert.Equal(12u, history.UiVersion); + Assert.Equal(0u, retrieval.UiError); + Assert.Equal(4u, retrieval.UiVersion); + } + + [Fact] + public void GetSystemParameterMessages_CarryHandleNameAndValue_AsStatusClientExpects() + { + // R0.3 sends {uiHandle, strParameterName} and reads strParameterValue when status succeeds. + var request = ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest.Parser.ParseFrom( + new ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest { UiHandle = 9, StrParameterName = "HistorianVersion" }.ToByteArray()); + Assert.Equal(9u, request.UiHandle); + Assert.Equal("HistorianVersion", request.StrParameterName); + + var response = ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse.Parser.ParseFrom( + new ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse + { + Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true }, + StrParameterValue = "20.0.000" + }.ToByteArray()); + Assert.True(response.Status.BSuccess); + Assert.Equal("20.0.000", response.StrParameterValue); + } + [Fact] public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged() {