04ea0b9a1f
Live-probed both R1.3 and R1.4 against a real 2023 R2 server over the gRPC StatusService; implemented the one that carries an evidence-backed value. R1.3 GetServerTimeZoneAsync — SHIPPED: - StatusService.GetSystemTimeZoneName(uiHandle) returns the real server zone over RemoteGrpc (the 2020 WCF op is a client-side stub returning empty). - HistorianGrpcStatusClient.GetSystemTimeZoneNameAsync -> dialect routing -> public HistorianClient.GetServerTimeZoneAsync. Non-gRPC transports fail closed with ProtocolEvidenceMissingException (no empty-string lie). - Golden message-shape unit test + non-gRPC guardrail unit test + gated live test. 271 unit tests pass. R1.4 GetHistorianInfoAsync (EventStorageMode) — bounded out on gRPC too: - gRPC GetHistorianInfo is the same named-value query as 2020 WCF (only HistorianVersion resolves); EventStorageMode + 7 variants fail on both GetHistorianInfo and GetSystemParameter. The 518-byte struct is filled by a native vtable+648 HCAL call, not the gRPC op (per the 2023 R2 decompile), so the field is never on the wire. Not shipped on any transport. Closes the roadmap's open "build against a live 2023 R2 server" caveat. Also correct the stale M3 roadmap section: D2 already proved Transaction.AddNonStreamValues* rides the storage-engine pipe (STransactPipeClient2 -> aaStorageEngine), not WCF — same wall as R4.2 — so M3-over-WCF is blocked, not "the path that is NOT the gated cache push". Docs: hcal-roadmap.md, wcf-historian-info.md, wcf-status-localhost.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
230 lines
10 KiB
C#
230 lines
10 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 GetSystemTimeZoneNameMessages_CarryHandleAndValue_AsStatusClientExpects()
|
|
{
|
|
// R1.3 sends {uiHandle} and reads strSystemTimeZoneName when status succeeds — no buffer.
|
|
var request = ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameRequest.Parser.ParseFrom(
|
|
new ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameRequest { UiHandle = 11 }.ToByteArray());
|
|
Assert.Equal(11u, request.UiHandle);
|
|
|
|
var response = ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameResponse.Parser.ParseFrom(
|
|
new ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameResponse
|
|
{
|
|
Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true },
|
|
StrSystemTimeZoneName = "Eastern Daylight Time"
|
|
}.ToByteArray());
|
|
Assert.True(response.Status.BSuccess);
|
|
Assert.Equal("Eastern Daylight Time", response.StrSystemTimeZoneName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetServerTimeZoneAsync_OnNonGrpcTransport_ThrowsEvidenceMissing()
|
|
{
|
|
// The 2020 WCF GetSystemTimeZoneName is a client-side stub (empty value); R1.3 only has an
|
|
// evidence-backed value on the gRPC front door, so the non-gRPC path must fail closed.
|
|
await using var client = new HistorianClient(new HistorianClientOptions
|
|
{
|
|
Host = "histserver",
|
|
Transport = HistorianTransport.LocalPipe,
|
|
IntegratedSecurity = true
|
|
});
|
|
|
|
await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(() => client.GetServerTimeZoneAsync());
|
|
}
|
|
|
|
[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());
|
|
}
|
|
}
|