using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// archreview 06/S-11 — verifies the net-new (built on /// the shared ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder). /// The fail tier is deliberately narrow: only provably-crashing configs fail — an enabled /// historian (or an enabled AlarmHistorian, which sources its gateway connection from the /// ServerHistorian section) with an empty / non-absolute / non-http(s) Endpoint, i.e. /// exactly the configs that would otherwise throw deep in the /// gateway client factory at startup. Empty ApiKey / non-positive MaxTieClusterOverfetch /// stay operator warnings in (they degrade, not crash). /// public sealed class ServerHistorianOptionsValidatorTests { private static ServerHistorianOptionsValidator Sut(bool alarmHistorianEnabled = false) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false", }) .Build(); return new ServerHistorianOptionsValidator(configuration); } /// Enabled historian with an empty endpoint fails (would crash-loop in the factory). /// An unknown backend always fails, even when nothing consumes the section. [Fact] public void Unknown_backend_fails_even_when_not_consumed() { var result = Sut().Validate(null, new ServerHistorianOptions { Backend = "Nope" }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Backend") && f.Contains("Nope") && f.Contains("Gateway")); } /// Backend names are case-insensitive; the gateway checks still apply (control: no ApiKey fails). [Fact] public void Lowercase_gateway_backend_still_enforces_gateway_checks() { var result = Sut().Validate(null, new ServerHistorianOptions { Backend = "gateway", Enabled = true, Endpoint = "https://host:5222", ApiKey = "", }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey")); } /// /// The gateway-only checks (Endpoint, ApiKey, UseTls) do not apply to a non-gateway backend. Needs a /// registered non-gateway backend, which does not exist yet, so this asserts the gate through /// and the registry's unknown-name failure only. /// [Fact] public void Gateway_checks_are_gated_on_backend_being_gateway() { new ServerHistorianOptions { Backend = "SqlServer" }.IsGatewayBackend.ShouldBeFalse(); new ServerHistorianOptions { Backend = "gateway" }.IsGatewayBackend.ShouldBeTrue(); new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }.Validate().ShouldBeEmpty(); } [Fact] public void Enabled_with_empty_endpoint_fails() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "" }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); } /// Enabled with a relative (non-absolute) URI fails. [Fact] public void Enabled_with_relative_uri_fails() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "host:5222" }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); } /// Enabled with a non-http(s) scheme (ftp) fails. [Fact] public void Enabled_with_bad_scheme_fails() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "ftp://host:5222" }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); } /// /// Enabled with a valid absolute https endpoint and a key succeeds. The key is not /// incidental: this case previously omitted it and still asserted success, which pinned a config /// that actually crash-loops the host — see . /// [Fact] public void Enabled_with_valid_https_endpoint_succeeds() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", }); result.Succeeded.ShouldBeTrue(); } /// A disabled section may legitimately be empty — no failure. [Fact] public void Disabled_with_empty_endpoint_succeeds() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" }); result.Succeeded.ShouldBeTrue(); } /// /// The residual corner: ServerHistorian:Enabled=false but AlarmHistorian:Enabled=true /// (which sources its connection from the ServerHistorian section) with an empty endpoint fails — /// and the message names BOTH sections so the operator understands why a disabled section is required. /// [Fact] public void Disabled_but_alarm_historian_enabled_with_empty_endpoint_fails_naming_both_sections() { var result = Sut(alarmHistorianEnabled: true) .Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint") && f.Contains("AlarmHistorian:Enabled")); } /// Alarm-only mode with a valid endpoint and a key succeeds. [Fact] public void Disabled_but_alarm_historian_enabled_with_valid_endpoint_succeeds() { var result = Sut(alarmHistorianEnabled: true) .Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", }); result.Succeeded.ShouldBeTrue(); } /// /// An empty ApiKey on a consumed section fails, because it crashes — it does not /// degrade. /// /// /// /// Found by the LocalDb Phase 2 live gate. The rig booted with /// AlarmHistorian:Enabled=true and a valid endpoint but no key, and every driver /// node crash-looped on /// ArgumentException: The gateway API key must not be empty (Parameter 'ApiKey') /// thrown by HistorianGatewayClientOptions.Validate() — reached through /// GatewayHistorian.CreateAlarmWriter during Akka startup. /// /// /// That is the *same* factory path, and the same crash-loop-under-a-restart-policy /// failure mode, this validator was built to convert into a named /// OptionsValidationException. The previous "empty ApiKey degrades, the gateway /// just rejects calls" premise was simply wrong: the client validates its own options /// at construction, so the process never reaches the point of making a call. /// /// [Fact] public void Consumed_with_empty_api_key_fails() { var result = Sut(alarmHistorianEnabled: true) .Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "https://host:5222", ApiKey = "", }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey") && f.Contains("AlarmHistorian:Enabled")); } /// The read path has the identical crash, so it is gated identically. [Fact] public void Enabled_with_empty_api_key_fails() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222", ApiKey = "", }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey")); } /// /// The key must never be echoed. An endpoint is not a secret and is quoted to make the error /// actionable; a key is, so the failure names only the setting. /// [Fact] public void Api_key_failure_never_echoes_the_key() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222", ApiKey = " ", }); result.Failed.ShouldBeTrue(); result.Failures.ShouldNotContain(f => f.Contains(" ", StringComparison.Ordinal)); } /// A section nobody consumes may be keyless — no failure. [Fact] public void Disabled_with_empty_api_key_succeeds() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, ApiKey = "" }); result.Succeeded.ShouldBeTrue(); } /// /// UseTls=true against an http:// endpoint fails — the third member of the /// crash-at-construction family, and the easiest of the three for an operator to trip. /// /// /// Also found by the Phase 2 live gate, immediately after the ApiKey fix: the rig points /// at a deliberately unresolvable http:// endpoint, and UseTls defaults to /// , so every node crash-looped a second time on /// ArgumentException: UseTls requires an https gateway endpoint. Switching an endpoint /// from https to http without clearing UseTls is an ordinary migration slip, and it takes /// the host down rather than degrading it. /// [Fact] public void Consumed_with_use_tls_against_http_endpoint_fails() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = true, }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:UseTls") && f.Contains("http://host:5222")); } /// An http endpoint with TLS explicitly off is the valid h2c shape — no failure. [Fact] public void Consumed_with_http_endpoint_and_tls_off_succeeds() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = false, }); result.Succeeded.ShouldBeTrue(); } /// /// The mirror slip — UseTls=false against an https:// endpoint — also fails, so the /// scheme and the flag are pinned to agree in both directions rather than in one. /// [Fact] public void Consumed_with_tls_off_against_https_endpoint_fails() { var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", UseTls = false, }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:UseTls")); } /// /// Wiring guard (the "register-AND-consume" trap): resolving IOptions<ServerHistorianOptions>.Value /// after throws /// for an enabled+empty section — proving the validator is /// attached to the options pipeline (not merely defined). ValidateOnStart reaching host start /// is already proven by the LDAP precedent. /// [Fact] public void AddValidatedOptions_wiring_throws_on_enabled_empty_endpoint() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ServerHistorian:Enabled"] = "true", ["ServerHistorian:Endpoint"] = "", }) .Build(); var services = new ServiceCollection(); services.AddSingleton(configuration); services.AddValidatedOptions( configuration, ServerHistorianOptions.SectionName); using var provider = services.BuildServiceProvider(); var ex = Should.Throw( () => _ = provider.GetRequiredService>().Value); ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); } private static ServerHistorianOptionsValidator SqlSut(string? connectionString) { var values = new Dictionary { ["AlarmHistorian:Enabled"] = "false" }; if (connectionString is not null) values["ServerHistorian:SqlServer:ConnectionString"] = connectionString; return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build()); } /// SqlServer with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire. [Fact] public void SqlServer_backend_with_connection_string_passes_without_gateway_settings() { var result = SqlSut("Server=localhost;Database=h").Validate(null, new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }); result.Failed.ShouldBeFalse(string.Join("; ", result.Failures ?? [])); } /// SqlServer with no connection string fails, naming the key. [Fact] public void SqlServer_backend_without_connection_string_fails_naming_the_key() { var result = SqlSut(null).Validate(null, new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:SqlServer:ConnectionString")); result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:Endpoint")); } /// Unconsumed SqlServer section (nothing enabled) is not validated. [Fact] public void SqlServer_backend_not_consumed_passes_without_connection_string() => SqlSut(null).Validate(null, new ServerHistorianOptions { Backend = "SqlServer" }).Failed.ShouldBeFalse(); private static IConfiguration BadSchemaConfig(bool alarmHistorianEnabled) => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false", ["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;Database=h", ["ServerHistorian:SqlServer:Schema"] = "bad;schema", }).Build(); /// /// An invalid ServerHistorian:SqlServer:Schema must not fail the host when nothing consumes the section: /// (which Program.cs calls unconditionally) does not throw and /// the validator does not check it — the same consumer gate the connection string has. /// [Fact] public void SqlServer_invalid_schema_not_consumed_neither_resolve_nor_validation_fails() { var configuration = BadSchemaConfig(alarmHistorianEnabled: false); var options = new ServerHistorianOptions { Backend = "SqlServer" }; Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration)); new ServerHistorianOptionsValidator(configuration).Validate(null, options).Failed.ShouldBeFalse(); } /// Consumed (here by AlarmHistorian alone), the same invalid schema is a named validator error. [Fact] public void SqlServer_invalid_schema_consumed_is_a_validator_error_naming_the_key() { var configuration = BadSchemaConfig(alarmHistorianEnabled: true); var options = new ServerHistorianOptions { Backend = "SqlServer" }; Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration)); var result = new ServerHistorianOptionsValidator(configuration).Validate(null, options); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:SqlServer:Schema")); } private static ServerHistorianOptionsValidator QuestDbSut(string? connectionString, string? prefix = null, bool alarmHistorianEnabled = false) { var values = new Dictionary { ["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false" }; if (connectionString is not null) values["ServerHistorian:QuestDb:ConnectionString"] = connectionString; if (prefix is not null) values["ServerHistorian:QuestDb:TablePrefix"] = prefix; return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build()); } /// QuestDb with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire. [Fact] public void QuestDb_backend_with_connection_string_passes_without_gateway_settings() { var result = QuestDbSut("Host=questdb;Port=8812").Validate(null, new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }); result.Failed.ShouldBeFalse(string.Join("; ", result.Failures ?? [])); } /// QuestDb with no connection string fails, naming the key, and never asks for gateway settings. [Fact] public void QuestDb_backend_without_connection_string_fails_naming_the_key() { var result = QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "questdb", Enabled = true }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:ConnectionString")); result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:Endpoint")); result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:ApiKey")); } /// Every QuestDbHistorianOptions.Validate() error is reported, each naming its key. [Fact] public void QuestDb_backend_reports_every_options_error_naming_the_key() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["AlarmHistorian:Enabled"] = "false", ["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix", ["ServerHistorian:QuestDb:RetentionDays"] = "0", ["ServerHistorian:QuestDb:CommandTimeout"] = "00:00:00", ["ServerHistorian:QuestDb:VisibilityTimeout"] = "-00:00:01", }).Build(); var result = new ServerHistorianOptionsValidator(configuration) .Validate(null, new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }); result.Failed.ShouldBeTrue(); foreach (var key in new[] { "ConnectionString", "TablePrefix", "RetentionDays", "CommandTimeout", "VisibilityTimeout" }) result.Failures.ShouldContain(f => f.Contains($"ServerHistorian:QuestDb:{key}"), key); } /// Unconsumed QuestDb section (nothing enabled) is not validated. [Fact] public void QuestDb_backend_not_consumed_passes_without_connection_string() => QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "QuestDb" }).Failed.ShouldBeFalse(); /// /// An invalid ServerHistorian:QuestDb:TablePrefix must not fail the host when nothing consumes the /// section: does not throw and the validator does not check it. /// [Fact] public void QuestDb_invalid_prefix_not_consumed_neither_resolve_nor_validation_fails() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["AlarmHistorian:Enabled"] = "false", ["ServerHistorian:QuestDb:ConnectionString"] = "Host=questdb;Port=8812", ["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix", }).Build(); var options = new ServerHistorianOptions { Backend = "QuestDb" }; Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration)); new ServerHistorianOptionsValidator(configuration).Validate(null, options).Failed.ShouldBeFalse(); } /// Consumed (here by AlarmHistorian alone), the same invalid prefix is a named validator error. [Fact] public void QuestDb_invalid_prefix_consumed_is_a_validator_error_naming_the_key() { var options = new ServerHistorianOptions { Backend = "QuestDb" }; var result = QuestDbSut("Host=questdb;Port=8812", "bad;prefix", alarmHistorianEnabled: true).Validate(null, options); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:TablePrefix")); } }