26ef5e5645
Probes the 2023 R2 gRPC browse path and records the finding. The front door does NOT hit the 2020 WCF metadata-server-pipe wall. - RetrievalService.StartTagQuery is cracked: the server (CMdServer::StartActiveTagnamesQuery over \.\pipe\aahMetadataServer\console) parses the filter as OData. startswith()/ contains()/eq/empty succeed and return the 8-byte (queryHandle, tagCount); SQL-LIKE "%" and glob "*" fail with "ODataFilter: bad token". Live: 220 Sys* tags counted. - QueryTag (paging) remains: every guessed btRequest returns a constant native error type 4 / code 72 (content-independent) -> framing needs a native capture, not guessing. Adds RE probe helpers Grpc/HistorianGrpcTagClient.ProbeStartTagQuery + ProbeTagQuerySequence, a gated StartTagQuery_OverGrpc_AcceptsODataFilter test, and the finding doc docs/reverse-engineering/grpc-tag-query-odata.md. Browse is not yet wired (QueryTag open). 217 unit tests pass; 5/5 live gRPC tests pass. No tag names/identities committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
137 lines
5.8 KiB
C#
137 lines
5.8 KiB
C#
using AVEVA.Historian.Client.Models;
|
|
|
|
namespace AVEVA.Historian.Client.Tests;
|
|
|
|
/// <summary>
|
|
/// Live integration tests for the 2023 R2 RemoteGrpc transport. Gated on a dedicated
|
|
/// <c>HISTORIAN_GRPC_HOST</c> env var (plus <c>HISTORIAN_TEST_TAG</c>) so they skip cleanly until
|
|
/// a 2023 R2 Historian is available. Optional:
|
|
/// HISTORIAN_GRPC_PORT (default 32565), HISTORIAN_GRPC_TLS (true/false),
|
|
/// HISTORIAN_USER / HISTORIAN_PASSWORD (explicit creds; otherwise IntegratedSecurity),
|
|
/// HISTORIAN_GRPC_DNSID (server certificate name when connecting by IP over TLS).
|
|
/// </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()
|
|
{
|
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
|
{
|
|
return;
|
|
}
|
|
|
|
HistorianClient client = new(BuildOptions(host));
|
|
|
|
DateTime endUtc = DateTime.UtcNow;
|
|
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
|
|
|
|
List<HistorianSample> samples = [];
|
|
await foreach (HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
|
|
{
|
|
samples.Add(sample);
|
|
}
|
|
|
|
Assert.NotEmpty(samples);
|
|
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 StartTagQuery_OverGrpc_AcceptsODataFilter()
|
|
{
|
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// R0.1 finding (2026-06-21): on the 2023 R2 gRPC front door the metadata-server pipe IS
|
|
// reachable (unlike 2020 WCF) and StartActiveTagnamesQuery parses the filter as OData —
|
|
// startswith/contains/eq succeed; SQL-LIKE "%"/glob "*" fail with "ODataFilter: bad token".
|
|
// StartTagQuery returns the 8-byte (queryHandle, tagCount) response. The follow-on QueryTag
|
|
// paging request format is not yet captured (see roadmap R0.1), so browse is not yet wired.
|
|
HistorianClientOptions options = BuildOptions(host);
|
|
var result = await Task.Run(() =>
|
|
AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.ProbeStartTagQuery(options, "startswith(TagName,'Sys')", CancellationToken.None));
|
|
|
|
Assert.True(result.Success,
|
|
$"StartTagQuery(OData) should succeed; errLen={result.ErrorLength} " +
|
|
$"err=\"{System.Text.Encoding.ASCII.GetString(result.Error).Replace('\0', '.')}\"");
|
|
Assert.Equal(8, result.ResponseLength); // (queryHandle, tagCount)
|
|
}
|
|
|
|
private static HistorianClientOptions BuildOptions(string host)
|
|
{
|
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
|
bool explicitCreds = !string.IsNullOrEmpty(user);
|
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
|
? parsed
|
|
: HistorianClientOptions.DefaultGrpcPort;
|
|
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
|
|
|
return new HistorianClientOptions
|
|
{
|
|
Host = host,
|
|
Port = port,
|
|
Transport = HistorianTransport.RemoteGrpc,
|
|
GrpcUseTls = tls,
|
|
AllowUntrustedServerCertificate = tls,
|
|
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
|
IntegratedSecurity = !explicitCreds,
|
|
UserName = user ?? string.Empty,
|
|
Password = password ?? string.Empty
|
|
};
|
|
}
|
|
}
|