feat(adminui): manual failover control on the cluster redundancy page
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+185
@@ -0,0 +1,185 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the Trigger-failover control's behaviour. The repo has no bUnit (see
|
||||
/// <c>PageAuthorizationGuardTests</c>), so the consequential parts — the peer guard, the confirm
|
||||
/// flow, and the refused-vs-succeeded outcome — live in a pure model that the razor is a shell over,
|
||||
/// and are tested here rather than left to live verification alone.
|
||||
/// </summary>
|
||||
public sealed class ManualFailoverPageModelTests
|
||||
{
|
||||
private sealed class FakeFailoverService : IManualFailoverService
|
||||
{
|
||||
public ManualFailoverSnapshot Next { get; set; } = new("akka.tcp://otopcua@a:4053", new[]
|
||||
{
|
||||
"akka.tcp://otopcua@a:4053", "akka.tcp://otopcua@b:4053",
|
||||
});
|
||||
|
||||
public int FailOverCalls { get; private set; }
|
||||
public List<string> Actors { get; } = new();
|
||||
public string? Result { get; set; } = "akka.tcp://otopcua@a:4053";
|
||||
public Exception? Throw { get; set; }
|
||||
public Exception? ThrowOnSnapshot { get; set; }
|
||||
|
||||
public ManualFailoverSnapshot GetSnapshot()
|
||||
=> ThrowOnSnapshot is not null ? throw ThrowOnSnapshot : Next;
|
||||
|
||||
public Task<string?> FailOverDriverPrimaryAsync(string actor)
|
||||
{
|
||||
FailOverCalls++;
|
||||
Actors.Add(actor);
|
||||
if (Throw is not null) throw Throw;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private static (ManualFailoverPageModel Model, FakeFailoverService Service) Build()
|
||||
{
|
||||
var svc = new FakeFailoverService();
|
||||
var model = new ManualFailoverPageModel(svc);
|
||||
model.Refresh();
|
||||
return (model, svc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control is Administrator-only. Not <see cref="AdminUiPolicies.ConfigEditor"/> — which the
|
||||
/// neighbouring cluster-authoring pages use, and which also admits Designer: this restarts a
|
||||
/// production node rather than editing configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Control_is_gated_on_the_fleet_admin_policy()
|
||||
{
|
||||
ManualFailoverPageModel.RequiredPolicy.ShouldBe(AdminUiPolicies.FleetAdmin);
|
||||
ManualFailoverPageModel.RequiredPolicy.ShouldNotBe(AdminUiPolicies.ConfigEditor);
|
||||
}
|
||||
|
||||
/// <summary>With a peer present the button is live and explains nothing away.</summary>
|
||||
[Fact]
|
||||
public void Button_is_enabled_when_a_driver_peer_exists()
|
||||
{
|
||||
var (model, _) = Build();
|
||||
|
||||
model.CanFailOver.ShouldBeTrue();
|
||||
model.DisabledReason.ShouldBeNull();
|
||||
model.Snapshot!.PrimaryAddress.ShouldBe("akka.tcp://otopcua@a:4053");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE peer guard. On a lone driver node a "failover" is a shutdown: the button must be disabled
|
||||
/// and say why, and requesting it must not even open the dialog.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Button_is_disabled_with_a_reason_when_there_is_no_peer()
|
||||
{
|
||||
var (model, svc) = Build();
|
||||
svc.Next = new ManualFailoverSnapshot("akka.tcp://otopcua@a:4053", new[] { "akka.tcp://otopcua@a:4053" });
|
||||
model.Refresh();
|
||||
|
||||
model.CanFailOver.ShouldBeFalse();
|
||||
model.DisabledReason.ShouldNotBeNull().ShouldContain("1 Up driver member");
|
||||
|
||||
model.RequestFailover();
|
||||
model.ConfirmOpen.ShouldBeFalse("a guarded-off control must not open its confirmation dialog");
|
||||
}
|
||||
|
||||
/// <summary>The confirm flow calls the service exactly once, with the authenticated user.</summary>
|
||||
[Fact]
|
||||
public async Task Confirm_flow_calls_the_service_exactly_once()
|
||||
{
|
||||
var (model, svc) = Build();
|
||||
|
||||
model.RequestFailover();
|
||||
model.ConfirmOpen.ShouldBeTrue();
|
||||
await model.ConfirmFailoverAsync("alice");
|
||||
|
||||
svc.FailOverCalls.ShouldBe(1);
|
||||
svc.Actors.ShouldBe(new[] { "alice" });
|
||||
model.ConfirmOpen.ShouldBeFalse();
|
||||
model.StatusIsError.ShouldBeFalse();
|
||||
model.StatusMessage.ShouldNotBeNull().ShouldContain("akka.tcp://otopcua@a:4053");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirming without an open dialog does nothing — a stray double-submit on the circuit must not
|
||||
/// bounce a second node.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Confirming_twice_only_fails_over_once()
|
||||
{
|
||||
var (model, svc) = Build();
|
||||
|
||||
model.RequestFailover();
|
||||
await model.ConfirmFailoverAsync("alice");
|
||||
await model.ConfirmFailoverAsync("alice");
|
||||
|
||||
svc.FailOverCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Cancelling closes the dialog and calls nothing.</summary>
|
||||
[Fact]
|
||||
public async Task Cancel_does_not_call_the_service()
|
||||
{
|
||||
var (model, svc) = Build();
|
||||
|
||||
model.RequestFailover();
|
||||
model.CancelFailover();
|
||||
await model.ConfirmFailoverAsync("alice");
|
||||
|
||||
model.ConfirmOpen.ShouldBeFalse();
|
||||
svc.FailOverCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The service re-evaluates the peer guard against live state, which can have changed since the
|
||||
/// page rendered. A refusal must surface as a failure — reporting it as a success would tell an
|
||||
/// operator the Primary moved when it did not.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Refusal_is_reported_as_an_error_not_a_success()
|
||||
{
|
||||
var (model, svc) = Build();
|
||||
svc.Result = null;
|
||||
|
||||
model.RequestFailover();
|
||||
await model.ConfirmFailoverAsync("alice");
|
||||
|
||||
model.StatusIsError.ShouldBeTrue();
|
||||
model.StatusMessage.ShouldNotBeNull().ShouldContain("refused");
|
||||
}
|
||||
|
||||
/// <summary>A throwing service surfaces as an error, not an unhandled circuit exception.</summary>
|
||||
[Fact]
|
||||
public async Task Service_failure_is_surfaced_not_thrown()
|
||||
{
|
||||
var (model, svc) = Build();
|
||||
svc.Throw = new InvalidOperationException("cluster gone");
|
||||
|
||||
model.RequestFailover();
|
||||
await model.ConfirmFailoverAsync("alice");
|
||||
|
||||
model.StatusIsError.ShouldBeTrue();
|
||||
model.StatusMessage.ShouldNotBeNull().ShouldContain("cluster gone");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unreadable cluster renders a disabled control, not a 500 — the page is also the place an
|
||||
/// operator looks when the node is unhealthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Unreadable_cluster_state_disables_the_control_instead_of_throwing()
|
||||
{
|
||||
var svc = new FakeFailoverService { ThrowOnSnapshot = new InvalidOperationException("no cluster") };
|
||||
var model = new ManualFailoverPageModel(svc);
|
||||
|
||||
Should.NotThrow(model.Refresh);
|
||||
model.Snapshot.ShouldBeNull();
|
||||
model.CanFailOver.ShouldBeFalse();
|
||||
model.DisabledReason.ShouldNotBeNull().ShouldContain("unavailable");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user