d8a85c3d89
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
186 lines
6.7 KiB
C#
186 lines
6.7 KiB
C#
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");
|
|
}
|
|
}
|