d0110cf406
Phase 3 tasks 3.1-3.3 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md. Scaffold (3.1): a plain directory in scadaproj (NOT a nested git repo), slnx over src/ + tests/, HistorianGateway-pattern Directory.Build.props (warnings-as-errors) and nuget.config (nuget.org * + the Gitea feed scoped to ZB.MOM.WW.*). Inline package pins, no CPM — the app convention here. NO Auth/Audit/Secrets packages: the dashboard is anonymous and read-only by requirement. Registry (3.2): OverviewOptions/ApplicationEntry/InstanceEntry bound from the "Overview" section, plus EffectiveTimings resolving instance > application > global overrides in ONE place so no caller re-implements the precedence. OverviewOptionsValidator (OptionsValidatorBase + AddValidatedOptions) rejects an empty registry, applications with no instances, duplicate names, non-absolute or non-http(s) URLs, and non-positive intervals — and checks stale > poll on the EFFECTIVE values, since an override at either level can invert that on one instance while the globals look fine. A ConfigPreflight presence check runs before the host is built so a missing registry fails with the key name, not with "Applications must have at least 1 entry". Health client (3.3): ZbHealthReport DTOs for the canonical ZbHealthWriter body, with per-entry `data` kept as JsonElement — it is an open contract, and binding it to concrete types would break the moment a check adds a field. 200 AND 503 are both parseable answers (503 carries the body naming the failing check); only a transport failure or a non-canonical body is a failed probe, which is why Unreachable stays distinct from Down. Host shutdown propagates as cancellation rather than being recorded as every instance timing out. Status model: Up/Degraded/Down/Unreachable + Active/Standby/Unknown/NotApplicable, with two-strike flap damping — a failing observation only replaces a good state after 2 consecutive failures, while recovery is immediate (damping suppresses false alarms; symmetric damping would just make them linger). 53 tests green, 0 warnings in Debug and Release. Tests for the validator, the client golden payloads (with AND without `data`, so a partly-bumped fleet renders) and the derivation table are front-loaded here from task 3.8 so this batch is verified rather than pending. Also pins AngleSharp 1.5.2 test-side: bunit 1.40.0 pulls 1.2.0, which trips NU1902 and therefore the warnings-as-errors gate (same fix HistorianGateway made in 6bc005d).
227 lines
8.0 KiB
C#
227 lines
8.0 KiB
C#
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Overview.Registry;
|
|
|
|
namespace ZB.MOM.WW.Overview.Tests;
|
|
|
|
/// <summary>
|
|
/// Registry validation. A malformed registry must fail the host at startup with a message naming
|
|
/// the offending key — the alternative is a dashboard that boots and quietly shows nothing, or one
|
|
/// that throws on its first poll where nobody is looking.
|
|
/// </summary>
|
|
public class OverviewOptionsValidatorTests
|
|
{
|
|
private static readonly OverviewOptionsValidator Validator = new();
|
|
|
|
private static OverviewOptions Valid(Action<OverviewOptions>? mutate = null)
|
|
{
|
|
var options = new OverviewOptions
|
|
{
|
|
PollIntervalSeconds = 10,
|
|
TimeoutSeconds = 3,
|
|
StaleAfterSeconds = 45,
|
|
Applications =
|
|
{
|
|
new ApplicationEntry
|
|
{
|
|
Name = "OtOpcUa",
|
|
ManagementLabel = "AdminUI",
|
|
Instances =
|
|
{
|
|
new InstanceEntry
|
|
{
|
|
Name = "central-1",
|
|
Group = "central",
|
|
BaseUrl = "http://otopcua-central-1:9000",
|
|
ManagementUrl = "http://otopcua-central-1:9000/",
|
|
HasActiveRole = true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
mutate?.Invoke(options);
|
|
return options;
|
|
}
|
|
|
|
private static ValidateOptionsResult Run(OverviewOptions options) => Validator.Validate(null, options);
|
|
|
|
private static void AssertFailsWith(OverviewOptions options, string expectedFragment)
|
|
{
|
|
var result = Run(options);
|
|
|
|
Assert.True(result.Failed, "expected validation to fail");
|
|
Assert.Contains(
|
|
result.Failures,
|
|
f => f.Contains(expectedFragment, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidRegistry_Succeeds()
|
|
{
|
|
// Positive control: without this, every "fails with X" assertion below could be passing
|
|
// because the fixture itself is malformed for some unrelated reason.
|
|
Assert.True(Run(Valid()).Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyRegistry_IsRejected()
|
|
{
|
|
AssertFailsWith(Valid(o => o.Applications.Clear()), "Overview:Applications");
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplicationWithNoInstances_IsRejected()
|
|
{
|
|
AssertFailsWith(Valid(o => o.Applications[0].Instances.Clear()), "Instances");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void ApplicationWithoutName_IsRejected(string name)
|
|
{
|
|
AssertFailsWith(Valid(o => o.Applications[0].Name = name), "Applications:0:Name");
|
|
}
|
|
|
|
[Fact]
|
|
public void DuplicateApplicationNames_AreRejected()
|
|
{
|
|
AssertFailsWith(
|
|
Valid(o => o.Applications.Add(new ApplicationEntry
|
|
{
|
|
Name = "OtOpcUa",
|
|
Instances = { new InstanceEntry { Name = "other", BaseUrl = "http://other:9000" } },
|
|
})),
|
|
"duplicated");
|
|
}
|
|
|
|
[Fact]
|
|
public void DuplicateInstanceNamesWithinAnApplication_AreRejected()
|
|
{
|
|
AssertFailsWith(
|
|
Valid(o => o.Applications[0].Instances.Add(new InstanceEntry
|
|
{
|
|
Name = "central-1",
|
|
BaseUrl = "http://otopcua-central-2:9000",
|
|
})),
|
|
"duplicated");
|
|
}
|
|
|
|
[Fact]
|
|
public void SameInstanceNameInDifferentApplications_IsAllowed()
|
|
{
|
|
// "central-1" under OtOpcUa and "central-1" under ScadaBridge are different machines with
|
|
// the same conventional name — uniqueness is per application, not global.
|
|
var options = Valid(o => o.Applications.Add(new ApplicationEntry
|
|
{
|
|
Name = "ScadaBridge",
|
|
Instances = { new InstanceEntry { Name = "central-1", BaseUrl = "http://sb-central-a:5000" } },
|
|
}));
|
|
|
|
Assert.True(Run(options).Succeeded);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("otopcua-central-1:9000")] // no scheme
|
|
[InlineData("/health/ready")] // relative
|
|
[InlineData("ftp://otopcua-central-1")] // wrong scheme
|
|
public void NonAbsoluteHttpBaseUrl_IsRejected(string baseUrl)
|
|
{
|
|
AssertFailsWith(Valid(o => o.Applications[0].Instances[0].BaseUrl = baseUrl), "BaseUrl");
|
|
}
|
|
|
|
[Fact]
|
|
public void RelativeManagementUrl_IsRejected()
|
|
{
|
|
AssertFailsWith(Valid(o => o.Applications[0].Instances[0].ManagementUrl = "/admin"), "ManagementUrl");
|
|
}
|
|
|
|
[Fact]
|
|
public void OmittedManagementUrl_IsAllowed()
|
|
{
|
|
Assert.True(Run(Valid(o => o.Applications[0].Instances[0].ManagementUrl = null)).Succeeded);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-1)]
|
|
public void NonPositiveGlobalIntervals_AreRejected(int value)
|
|
{
|
|
AssertFailsWith(Valid(o => o.PollIntervalSeconds = value), "PollIntervalSeconds");
|
|
AssertFailsWith(Valid(o => o.TimeoutSeconds = value), "TimeoutSeconds");
|
|
AssertFailsWith(Valid(o => o.StaleAfterSeconds = value), "StaleAfterSeconds");
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleWindowNotGreaterThanPollInterval_IsRejected()
|
|
{
|
|
AssertFailsWith(Valid(o => o.StaleAfterSeconds = o.PollIntervalSeconds), "StaleAfterSeconds");
|
|
AssertFailsWith(Valid(o => o.StaleAfterSeconds = o.PollIntervalSeconds - 1), "StaleAfterSeconds");
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleWindowIsCheckedOnEffectiveValues_NotGlobals()
|
|
{
|
|
// The globals here are perfectly sane (10 / 45). Only the INSTANCE override inverts the
|
|
// relationship, which a globals-only check would wave straight through.
|
|
AssertFailsWith(
|
|
Valid(o => o.Applications[0].Instances[0].PollIntervalSeconds = 120),
|
|
"effective StaleAfterSeconds");
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplicationLevelOverrideCanSatisfyTheStaleRule()
|
|
{
|
|
// The mirror of the previous test: an override that RESTORES the relationship must pass, so
|
|
// the rule is not just "any override fails".
|
|
var options = Valid(o =>
|
|
{
|
|
o.Applications[0].Instances[0].PollIntervalSeconds = 120;
|
|
o.Applications[0].Instances[0].StaleAfterSeconds = 300;
|
|
});
|
|
|
|
Assert.True(Run(options).Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllFailuresAreAccumulated_NotJustTheFirst()
|
|
{
|
|
// OptionsValidatorBase accumulates; an operator fixing a registry one boot at a time is the
|
|
// failure mode this avoids.
|
|
var result = Run(Valid(o =>
|
|
{
|
|
o.Applications[0].Name = string.Empty;
|
|
o.Applications[0].Instances[0].BaseUrl = "not-a-url";
|
|
o.PollIntervalSeconds = 0;
|
|
}));
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.True(result.Failures.Count() >= 3, $"expected >= 3 failures, got {result.Failures.Count()}");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null, null, null, 10, 3, 45)] // all defaults
|
|
[InlineData(20, null, null, 20, 3, 45)] // application override
|
|
[InlineData(20, 30, null, 30, 3, 45)] // instance beats application
|
|
[InlineData(null, 30, 90, 30, 90, 45)] // independent fields resolve independently
|
|
public void EffectiveTimings_ResolveInstanceOverApplicationOverGlobal(
|
|
int? applicationPoll, int? instancePoll, int? instanceTimeout,
|
|
int expectedPoll, int expectedTimeout, int expectedStale)
|
|
{
|
|
var options = Valid(o =>
|
|
{
|
|
o.Applications[0].PollIntervalSeconds = applicationPoll;
|
|
o.Applications[0].Instances[0].PollIntervalSeconds = instancePoll;
|
|
o.Applications[0].Instances[0].TimeoutSeconds = instanceTimeout;
|
|
});
|
|
|
|
var timings = EffectiveTimings.Resolve(options, options.Applications[0], options.Applications[0].Instances[0]);
|
|
|
|
Assert.Equal(TimeSpan.FromSeconds(expectedPoll), timings.PollInterval);
|
|
Assert.Equal(TimeSpan.FromSeconds(expectedTimeout), timings.Timeout);
|
|
Assert.Equal(TimeSpan.FromSeconds(expectedStale), timings.StaleAfter);
|
|
}
|
|
}
|