Merge re/grpc-2023r2-handshake: M0 gRPC parity (probe/system-param/metadata/browse) + handshake fix
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Structural guardrail pinning the 2023 R2 gRPC auth-handshake op routing. The SSPI/Negotiate
|
||||
/// token loop MUST be carried by <c>StorageService.ValidateClientCredential</c> (the op that kept
|
||||
/// the 2020 inBuff/outBuff token framing), NOT by <c>HistoryService.ExchangeKey</c> — ExchangeKey
|
||||
/// is a separate key-exchange/cert-path op that rejects an NTLM token at round 0 regardless of
|
||||
/// 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 <c>HistorianGrpcHandshake</c> (and its
|
||||
/// compiler-generated nested closure types — the token-loop call lives inside a lambda) and
|
||||
/// collecting every method invoked.
|
||||
/// </summary>
|
||||
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<string> calledMethods = CollectCalledMethodNames(
|
||||
"AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake");
|
||||
|
||||
Assert.Contains("ValidateClientCredential", calledMethods);
|
||||
Assert.DoesNotContain("ExchangeKey", calledMethods);
|
||||
}
|
||||
|
||||
private static HashSet<string> CollectCalledMethodNames(string typeFullName)
|
||||
{
|
||||
Assembly sdk = typeof(HistorianClientOptions).Assembly;
|
||||
Type orchestrator = sdk.GetType(typeFullName, throwOnError: true)!;
|
||||
Module module = orchestrator.Module;
|
||||
|
||||
// The orchestrator type plus its compiler-generated nested types (lambda closures).
|
||||
IEnumerable<Type> types = new[] { orchestrator }
|
||||
.Concat(orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic));
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (Type t in types)
|
||||
{
|
||||
Type[] typeArgs = t.IsGenericType ? t.GetGenericArguments() : Type.EmptyTypes;
|
||||
foreach (MethodInfo m in t.GetMethods(
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
byte[]? il = m.GetMethodBody()?.GetILAsByteArray();
|
||||
if (il is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Type[] methodArgs = m.IsGenericMethodDefinition ? m.GetGenericArguments() : Type.EmptyTypes;
|
||||
foreach (int token in EnumerateMethodTokens(il))
|
||||
{
|
||||
try
|
||||
{
|
||||
MethodBase? resolved = module.ResolveMethod(token, typeArgs, methodArgs);
|
||||
if (resolved is not null)
|
||||
{
|
||||
names.Add(resolved.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or BadImageFormatException)
|
||||
{
|
||||
// vararg / MethodSpec tokens that don't resolve cleanly — irrelevant here.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks an IL byte stream, yielding the 4-byte metadata token of every call/callvirt/newobj
|
||||
/// (any <see cref="OperandType.InlineMethod"/> opcode). Uses the reflection-emit opcode table so
|
||||
/// operands of other instructions are skipped correctly rather than misread as opcodes.
|
||||
/// </summary>
|
||||
private static IEnumerable<int> EnumerateMethodTokens(byte[] il)
|
||||
{
|
||||
int pos = 0;
|
||||
while (pos < il.Length)
|
||||
{
|
||||
OpCode op;
|
||||
if (il[pos] == 0xFE && pos + 1 < il.Length)
|
||||
{
|
||||
op = TwoByteOpCodes[il[pos + 1]];
|
||||
pos += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
op = OneByteOpCodes[il[pos]];
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
switch (op.OperandType)
|
||||
{
|
||||
case OperandType.InlineMethod:
|
||||
yield return BitConverter.ToInt32(il, pos);
|
||||
pos += 4;
|
||||
break;
|
||||
case OperandType.InlineNone:
|
||||
break;
|
||||
case OperandType.ShortInlineBrTarget:
|
||||
case OperandType.ShortInlineI:
|
||||
case OperandType.ShortInlineVar:
|
||||
pos += 1;
|
||||
break;
|
||||
case OperandType.InlineVar:
|
||||
pos += 2;
|
||||
break;
|
||||
case OperandType.InlineI8:
|
||||
case OperandType.InlineR:
|
||||
pos += 8;
|
||||
break;
|
||||
case OperandType.InlineSwitch:
|
||||
int count = BitConverter.ToInt32(il, pos);
|
||||
pos += 4 + (4 * count);
|
||||
break;
|
||||
default:
|
||||
// InlineBrTarget, InlineField, InlineI, InlineSig, InlineString, InlineTok,
|
||||
// InlineType, ShortInlineR — all 4-byte operands.
|
||||
pos += 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly OpCode[] OneByteOpCodes = BuildOpCodeTable(twoByte: false);
|
||||
private static readonly OpCode[] TwoByteOpCodes = BuildOpCodeTable(twoByte: true);
|
||||
|
||||
private static OpCode[] BuildOpCodeTable(bool twoByte)
|
||||
{
|
||||
var table = new OpCode[256];
|
||||
foreach (FieldInfo f in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (f.GetValue(null) is not OpCode op)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ushort value = unchecked((ushort)op.Value);
|
||||
bool isTwoByte = (value & 0xFF00) == 0xFE00;
|
||||
if (isTwoByte == twoByte)
|
||||
{
|
||||
table[value & 0xFF] = op;
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,21 @@ namespace AVEVA.Historian.Client.Tests;
|
||||
/// </summary>
|
||||
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,61 @@ 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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_OverGrpc_ReturnsRequestedTag()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag)
|
||||
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(BuildOptions(host));
|
||||
HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(tag, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(tag, metadata!.Name);
|
||||
// A real metadata record decodes to a known data type (descriptor passed MapDataType).
|
||||
Assert.True(Enum.IsDefined(metadata.DataType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Full R0.1 browse over gRPC: StartTagQuery(OData) -> paged QueryTag(0x6752) -> EndTagQuery.
|
||||
HistorianClient client = new(BuildOptions(host));
|
||||
List<string> names = [];
|
||||
await foreach (string name in client.BrowseTagNamesAsync("Sys*", CancellationToken.None))
|
||||
{
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(names);
|
||||
Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static HistorianClientOptions BuildOptions(string host)
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
|
||||
@@ -100,6 +100,88 @@ 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 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()
|
||||
{
|
||||
|
||||
@@ -29,6 +29,20 @@ public sealed class HistorianServerVersionGateTests
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Transaction, HistorianServerVersionGate.TransactionInterfaceVersion, Options());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_History_AcceptsBoth2020And2023R2Versions()
|
||||
{
|
||||
// History 11 (2020 WCF) and 12 (2023 R2 gRPC) are both buffer-compatible — a live gRPC
|
||||
// read against a real 2023 R2 server (interface version 12) returns rows. Both must pass.
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 11u, Options());
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, HistorianServerVersionGate.HistoryInterfaceVersionGrpc2023R2, Options());
|
||||
Assert.Equal(12u, HistorianServerVersionGate.HistoryInterfaceVersionGrpc2023R2);
|
||||
Assert.Contains(11u, HistorianServerVersionGate.AcceptedVersions(HistorianServiceInterface.History));
|
||||
Assert.Contains(12u, HistorianServerVersionGate.AcceptedVersions(HistorianServiceInterface.History));
|
||||
// Retrieval reported 4 on the live 2023 R2 server — matches 2020, so it is NOT widened.
|
||||
Assert.DoesNotContain(5u, HistorianServerVersionGate.AcceptedVersions(HistorianServiceInterface.Retrieval));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MismatchedVersion_ThrowsProtocolEvidenceMissing()
|
||||
{
|
||||
@@ -36,7 +50,7 @@ public sealed class HistorianServerVersionGateTests
|
||||
(HistorianServiceInterface Service, uint Version)[] cases =
|
||||
[
|
||||
(HistorianServiceInterface.History, 10u),
|
||||
(HistorianServiceInterface.History, 12u),
|
||||
(HistorianServiceInterface.History, 13u),
|
||||
(HistorianServiceInterface.Retrieval, 3u),
|
||||
(HistorianServiceInterface.Retrieval, 5u),
|
||||
(HistorianServiceInterface.Transaction, 1u),
|
||||
|
||||
Reference in New Issue
Block a user