Files
histsdk/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs
T
Joseph Doherty 85ff1b48df R0.1 browse over gRPC SHIPPED — QueryTag cracked, M0 gRPC parity complete
Wires HistorianClient.BrowseTagNamesAsync over gRPC (Transport==RemoteGrpc) via
Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync: StartTagQuery(OData) -> paged
QueryTag -> EndTagQuery. Live-verified against a real 2023 R2 server (returns Sys* tags).

QueryTag packet-id recovered WITHOUT native disassembly: a .rdata packet-descriptor
table in aahClientManaged.dll lists {0x6751,1}=StartTagQuery immediately followed by
{0x6752,1}=QueryTag (found via pefile byte-scan of .rdata), confirmed live.

Wire format (live-verified):
- request btRequest = u16 0x6752 + u16 version(1) + u16 queryType(1=names) + u32 startIndex + u32 count
- response btResonse = u32 count + per-name(u32 charCount + UTF-16LE) + trailer (NextIndex/metadata, ignored)
- new HistorianTagQueryProtocol.ParseTagNameQueryPage tolerates the trailer
- GlobToODataFilter translates the SDK glob filter to OData (Pre*->startswith, *suf->endswith,
  *mid*->contains, exact->eq); the 2023 R2 metadata-server parses filters as OData.

Replaces the earlier RE probe helpers with the shipped browse path. Adds golden-byte
(BuildQueryTagRequest) + 8 glob-translation unit tests + gated live browse test.
226 unit tests pass; 5/5 live gRPC tests pass (read, probe, system-param, metadata, browse).

Milestone 0 (full gRPC parity) is complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
2026-06-21 16:01:15 -04:00

197 lines
8.5 KiB
C#

using AVEVA.Historian.Client.Grpc;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
using Google.Protobuf;
using ArchestrA.Grpc.Contract.Retrieval;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
namespace AVEVA.Historian.Client.Tests;
/// <summary>
/// Unit coverage for the 2023 R2 RemoteGrpc transport — the parts that do not require a live
/// server: channel address/port resolution, metadata, transport routing, and the invariant that
/// gRPC request messages carry the same native byte buffers the WCF path uses.
/// </summary>
public sealed class HistorianGrpcTransportTests
{
private static HistorianClientOptions Options(
string host = "histserver",
int port = HistorianClientOptions.DefaultPort,
bool tls = false,
string? dnsIdentity = null,
bool compression = false) => new()
{
Host = host,
Port = port,
Transport = HistorianTransport.RemoteGrpc,
GrpcUseTls = tls,
ServerDnsIdentity = dnsIdentity,
Compression = compression,
IntegratedSecurity = true
};
[Fact]
public void ResolvePort_DefaultWcfPort_SubstitutesGrpcDefault()
{
Assert.Equal(HistorianClientOptions.DefaultGrpcPort, HistorianGrpcChannelFactory.ResolvePort(Options(port: HistorianClientOptions.DefaultPort)));
}
[Fact]
public void ResolvePort_ExplicitPort_IsHonoured()
{
Assert.Equal(443, HistorianGrpcChannelFactory.ResolvePort(Options(port: 443)));
}
[Fact]
public void ResolveAddress_Plaintext_UsesHttpAndHost()
{
Assert.Equal("http://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options()));
}
[Fact]
public void ResolveAddress_Tls_UsesHttpsAndHostWhenNoDnsIdentity()
{
Assert.Equal("https://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options(tls: true)));
}
[Fact]
public void ResolveAddress_Tls_PrefersDnsIdentityForCertMatch()
{
string address = HistorianGrpcChannelFactory.ResolveAddress(Options(host: "10.0.0.5", tls: true, dnsIdentity: "localhost"));
Assert.Equal("https://localhost:32565", address);
}
[Fact]
public void Create_CompressionDisabled_EmitsNoEncodingHeader()
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: false));
Assert.DoesNotContain(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
}
[Fact]
public void Create_CompressionEnabled_AdvertisesGzipRequestEncoding()
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: true));
global::Grpc.Core.Metadata.Entry entry = Assert.Single(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
Assert.Equal("gzip", entry.Value);
}
[Fact]
public void StartQueryRequest_CarriesNativeDataQueryBufferUnchanged()
{
// The gRPC envelope must wrap the exact bytes the WCF StartQuery2 path sends, so the
// already-reverse-engineered DataQueryRequest serializer is reused verbatim.
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(
"Tag.Counter", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), 100);
byte[] nativeBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
var message = new StartQueryRequest
{
UiHandle = 7,
UiQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData,
BtRequestBuffer = ByteString.CopyFrom(nativeBuffer)
};
// Round-trip through protobuf and confirm the native buffer survives byte-for-byte.
byte[] wire = message.ToByteArray();
var decoded = StartQueryRequest.Parser.ParseFrom(wire);
Assert.Equal(nativeBuffer, decoded.BtRequestBuffer.ToByteArray());
Assert.Equal(7u, decoded.UiHandle);
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 BuildTagNamesBuffer_EncodesCountThenLengthPrefixedUtf16Names()
{
// R0.2 request framing: uint count + per-name(uint charCount + UTF-16LE). Golden bytes.
byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildTagNamesBuffer(["AB", "C"]);
byte[] expected =
[
0x02, 0x00, 0x00, 0x00, // count = 2
0x02, 0x00, 0x00, 0x00, // "AB" char count = 2
0x41, 0x00, 0x42, 0x00, // 'A','B' UTF-16LE
0x01, 0x00, 0x00, 0x00, // "C" char count = 1
0x43, 0x00 // 'C' UTF-16LE
];
Assert.Equal(expected, buffer);
}
[Fact]
public void BuildQueryTagRequest_EncodesMarkerVersionTypeStartCount()
{
// R0.1 QueryTag paging request: u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count.
byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildQueryTagRequest(1, 0, 50);
byte[] expected =
[
0x52, 0x67, // marker 0x6752
0x01, 0x00, // version 1
0x01, 0x00, // queryType 1 (names)
0x00, 0x00, 0x00, 0x00, // startIndex 0
0x32, 0x00, 0x00, 0x00 // count 50
];
Assert.Equal(expected, buffer);
}
[Theory]
[InlineData("*", "")]
[InlineData("", "")]
[InlineData("Sys*", "startswith(TagName,'Sys')")]
[InlineData("*Total", "endswith(TagName,'Total')")]
[InlineData("*Alarm*", "contains(TagName,'Alarm')")]
[InlineData("Exact.Tag", "TagName eq 'Exact.Tag'")]
[InlineData("Pre*Suf", "startswith(TagName,'Pre') and endswith(TagName,'Suf')")]
[InlineData("O'Brien*", "startswith(TagName,'O''Brien')")]
public void GlobToODataFilter_TranslatesWildcards(string glob, string expected)
{
Assert.Equal(expected, AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.GlobToODataFilter(glob));
}
[Fact]
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
{
byte[] open2 = HistorianNativeHandshake.BuildOpenConnection3Request(
"histserver", Guid.NewGuid(), HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
var message = new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2) };
var decoded = GrpcHistory.OpenConnectionRequest.Parser.ParseFrom(message.ToByteArray());
Assert.Equal(open2, decoded.BtConnectionRequest.ToByteArray());
}
}