diff --git a/CLAUDE.md b/CLAUDE.md index b91d57e..4e3939b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,8 +94,7 @@ End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist Smaller, isolated items — none block the production read surface: - Remote TCP transports (`RemoteTcpIntegrated`, `RemoteTcpCertificate`) untested against an actual remote Historian (tests skip without `HISTORIAN_REMOTE_TCP_HOST`). -- Explicit username/password tag-metadata path (`HistorianWcfTagClient` line ~357) throws — only integrated security wired for that op. -- `Historian2020ProtocolDialect.GetTagInfoByName/GetTagInfos` throws — currently dead code; `GetTagMetadataAsync` works through the WCF tag client instead. +- Explicit username/password tag-metadata path is wired (validator only blocks no-auth-at-all), but live-verification requires `HISTORIAN_USER`+`HISTORIAN_PASSWORD` set; gated test `GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian` skips otherwise. - Per-row trailing ~24 bytes of `GetNextQueryResultBuffer` are not decoded (likely per-sample value/source/state metadata). - `EnsureTagAsync` distinct `MinRaw`/`MaxRaw` persistence requires `ApplyScaling=true` + a follow-up `UpdateTags` call — not yet wired (no API user has asked). diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index 38d64f4..8c5bb04 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -87,17 +87,6 @@ internal sealed class Historian2020ProtocolDialect #pragma warning restore CA1416 } - public IAsyncEnumerable BrowseTagNamesAsync(string filter, CancellationToken cancellationToken) - { - return Missing("StartLikeTagNameSearch/GetLikeTagnames", cancellationToken); - } - - public Task GetTagMetadataAsync(string tag, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - throw new ProtocolEvidenceMissingException("GetTagInfoByName/GetTagInfos"); - } - public Task GetConnectionStatusAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs index 74ad591..e126298 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs @@ -351,10 +351,18 @@ internal static class HistorianWcfTagClient private static void ValidateSupportedAuth(HistorianClientOptions options) { + // Three valid auth shapes: + // 1. IntegratedSecurity=true (current Windows identity, no UserName/Password) + // 2. IntegratedSecurity=false + UserName + Password (NTLM/Kerberos with explicit creds) + // 3. IntegratedSecurity=true + UserName + Password (impersonation/explicit override) + // The fourth combination — IntegratedSecurity=false with no UserName/Password — has + // no way to authenticate against the /Hist-Integrated endpoint and is rejected. if (!options.IntegratedSecurity - && (!string.IsNullOrEmpty(options.UserName) || !string.IsNullOrEmpty(options.Password))) + && string.IsNullOrEmpty(options.UserName) + && string.IsNullOrEmpty(options.Password)) { - throw new ProtocolEvidenceMissingException("Open2 explicit username/password tag browse"); + throw new ProtocolEvidenceMissingException( + "Tag browse / metadata requires either IntegratedSecurity=true OR an explicit UserName + Password."); } } diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs index 2fe5426..0e82e0a 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs @@ -313,6 +313,55 @@ public sealed class HistorianClientIntegrationTests Assert.Equal(host, status.ServerName); } + // The validator inside HistorianWcfTagClient now allows IntegratedSecurity=false WHEN + // explicit UserName + Password are provided (NTLM/Kerberos with non-current-user creds). + // It still rejects the no-credentials-at-all case since there's no way to authenticate + // against /Hist-Integrated. + [Fact] + public async Task GetTagMetadataAsync_NoAuthAndNoCredentials_Throws() + { + HistorianClient client = new(new HistorianClientOptions + { + Host = "localhost", + IntegratedSecurity = false, + UserName = string.Empty, + Password = string.Empty, + }); + await Assert.ThrowsAsync( + () => client.GetTagMetadataAsync("anytag", CancellationToken.None)); + } + + [Fact] + public async Task GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian() + { + // Live verification of the explicit-creds tag-metadata path. Gated on + // HISTORIAN_USER + HISTORIAN_PASSWORD being set; skips cleanly otherwise. The path + // routes through WCF Windows transport security with Credentials.Windows.ClientCredential. + string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); + string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) + || string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(password) + || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(new HistorianClientOptions + { + Host = host, + IntegratedSecurity = false, + UserName = user, + Password = password, + }); + + AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata = + await client.GetTagMetadataAsync(testTag, CancellationToken.None); + Assert.NotNull(metadata); + Assert.Equal(testTag, metadata.Name); + } + [Fact] public async Task GetTagMetadataAsync_ReturnsConfiguredTestTagMetadata() {