diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json index ea61746a..0b287f11 100644 --- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json +++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json @@ -4,13 +4,13 @@ "tasks": [ { "id": "T1", - "subject": "RED: misconfig repro test \u2014 HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)", + "subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)", "status": "completed", "blockedBy": [] }, { "id": "T2", - "subject": "GREEN: adapter guard \u2014 Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)", + "subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)", "status": "completed", "blockedBy": [ "T1" @@ -19,7 +19,7 @@ { "id": "T3", "subject": "RED: ServerHistorianOptionsValidator unit tests (7 cases incl. the AlarmHistorian-enabled/ServerHistorian-disabled corner naming both sections)", - "status": "pending", + "status": "completed", "blockedBy": [ "T1" ] @@ -50,7 +50,7 @@ }, { "id": "T7", - "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType \u2014 0.2.0 proto3-optional presence witness; pin, no RED phase)", + "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)", "status": "pending", "blockedBy": [] }, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs new file mode 100644 index 00000000..d374c6b9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs @@ -0,0 +1,137 @@ +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). + [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 succeeds. + [Fact] + public void Enabled_with_valid_https_endpoint_succeeds() + { + var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222" }); + + 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 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" }); + + result.Succeeded.ShouldBeTrue(); + } + + /// + /// 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")); + } +}