149 lines
8.7 KiB
C#
149 lines
8.7 KiB
C#
using Bunit;
|
|
using Microsoft.AspNetCore.Components;
|
|
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
using DashboardPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Dashboard;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
|
|
|
/// <summary>Dashboard facts distinguish independent failures, loading and known zero.</summary>
|
|
public class DashboardStateTests : BunitContext
|
|
{
|
|
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
|
|
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
|
private readonly IInboundApiKeyAdmin _keys = Substitute.For<IInboundApiKeyAdmin>();
|
|
/// <summary>Registers deterministic read-only dependencies and a signed-in fixture.</summary>
|
|
public DashboardStateTests()
|
|
{
|
|
SetRoles("Viewer");
|
|
_sites.GetAllSitesAsync().Returns(Task.FromResult<IReadOnlyList<Site>>([new("A", "a"), new("B", "b")]));
|
|
_sites.GetAllDataConnectionsAsync().Returns(Task.FromResult<IReadOnlyList<DataConnection>>([new("Link", "OpcUa", 1)]));
|
|
_templates.GetTemplateStubsAsync().Returns(Task.FromResult<IReadOnlyList<TemplateStub>>([
|
|
new(1,"A",null,null,false,null), new(2,"B",null,null,false,null), new(3,"C",null,null,false,null)]));
|
|
_keys.ListAsync().Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>([]));
|
|
Services.AddSingleton(_sites); Services.AddSingleton(_templates); Services.AddSingleton(_keys);
|
|
}
|
|
private void SetRoles(params string[] roles)
|
|
{
|
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "fixture") };
|
|
claims.AddRange(roles.Select(role => new Claim(JwtTokenService.RoleClaimType, role)));
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(
|
|
new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
|
|
Services.AddAuthorizationCore();
|
|
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
|
}
|
|
|
|
private IRenderedComponent<DashboardPage> RenderDashboard() => Render<CascadingAuthenticationState>(parameters =>
|
|
parameters.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
|
{
|
|
builder.OpenComponent<DashboardPage>(0);
|
|
builder.CloseComponent();
|
|
}))).FindComponent<DashboardPage>();
|
|
|
|
/// <summary>Shortcut visibility evaluates the same real policies as navigation and destination routes.</summary>
|
|
[Theory]
|
|
[InlineData("Viewer", false, true)]
|
|
[InlineData("Designer", true, false)]
|
|
[InlineData("Deployer", false, false)]
|
|
[InlineData("Administrator", false, true)]
|
|
[InlineData("Administrator,Designer,Deployer", true, true)]
|
|
[InlineData("Unmapped", false, false)]
|
|
public void Shortcuts_RespectDestinationPolicies(string role, bool templates, bool audit)
|
|
{
|
|
SetRoles(role.Split(','));
|
|
var cut = RenderDashboard();
|
|
Assert.Single(cut.FindAll("a[href='/monitoring/health']"));
|
|
Assert.Equal(templates ? 1 : 0, cut.FindAll("a[href='/design/templates']").Count);
|
|
Assert.Equal(audit ? 1 : 0, cut.FindAll("a[href='/audit/configuration']").Count);
|
|
}
|
|
|
|
/// <summary>Each read failure leaves the other facts intact and is never a false zero.</summary>
|
|
[Theory]
|
|
[InlineData("Sites configured")]
|
|
[InlineData("Data connections configured")]
|
|
[InlineData("Templates")]
|
|
[InlineData("API keys")]
|
|
public void CountFailure_DoesNotEraseOtherFactsOrRenderZero(string label)
|
|
{
|
|
if(label=="Sites configured")_sites.GetAllSitesAsync().Returns(Task.FromException<IReadOnlyList<Site>>(new Exception("local failure")));
|
|
if(label=="Data connections configured")_sites.GetAllDataConnectionsAsync().Returns(Task.FromException<IReadOnlyList<DataConnection>>(new Exception("local failure")));
|
|
if(label=="Templates")_templates.GetTemplateStubsAsync().Returns(Task.FromException<IReadOnlyList<TemplateStub>>(new Exception("local failure")));
|
|
if(label=="API keys")_keys.ListAsync().Returns(Task.FromException<IReadOnlyList<InboundApiKeyInfo>>(new Exception("local failure")));
|
|
var cut=RenderDashboard();
|
|
var facts=new Dictionary<string,string>{{"Sites configured","2"},{"Data connections configured","1"},{"Templates","3"},{"API keys","0"}};
|
|
foreach(var (name,count) in facts)
|
|
{
|
|
var fact=cut.Find("[role=region][aria-label='"+name+"']");
|
|
Assert.Contains(name==label?"Unavailable":count,fact.TextContent);
|
|
if(name==label)
|
|
{
|
|
Assert.Contains("Retry "+name,fact.TextContent);
|
|
Assert.DoesNotContain("0",fact.TextContent);
|
|
}
|
|
}
|
|
}
|
|
/// <summary>Only a failed API count is retried, and a real zero is displayed after success.</summary>
|
|
[Fact]
|
|
public void RetryFailure_PreservesSuccessfulFactsAndCanRecoverToZero()
|
|
{
|
|
_keys.ListAsync().Returns(Task.FromException<IReadOnlyList<InboundApiKeyInfo>>(new Exception("local failure")));
|
|
var cut=RenderDashboard();
|
|
Assert.Contains("Unavailable",cut.Find("[aria-label='API keys']").TextContent);
|
|
_keys.ListAsync().Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>([]));
|
|
cut.Find("[aria-label='API keys'] button").Click();
|
|
Assert.Contains("0",cut.Find("[aria-label='API keys']").TextContent);
|
|
Assert.DoesNotContain("Unavailable",cut.Markup);
|
|
_sites.Received(1).GetAllSitesAsync(); _sites.Received(1).GetAllDataConnectionsAsync(); _templates.Received(1).GetTemplateStubsAsync();
|
|
}
|
|
/// <summary>Early retries share the initial-read gate and duplicate retries remain single-flight.</summary>
|
|
[Fact]
|
|
public async Task RetryDuringNextRead_WaitsWithoutOverlapAndCoalescesDuplicateTarget()
|
|
{
|
|
var connectionRead = new TaskCompletionSource<IReadOnlyList<DataConnection>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_sites.GetAllSitesAsync().Returns(Task.FromException<IReadOnlyList<Site>>(new Exception("local failure")),
|
|
Task.FromResult<IReadOnlyList<Site>>([new("Recovered", "recovered")]));
|
|
_sites.GetAllDataConnectionsAsync().Returns(connectionRead.Task);
|
|
var cut = RenderDashboard();
|
|
Assert.Contains("Unavailable", cut.Find("[aria-label='Sites configured']").TextContent);
|
|
Assert.Contains("Loading", cut.Find("[aria-label='Data connections configured']").TextContent);
|
|
// Model two already-dispatched retries without relying on a stale DOM node.
|
|
var retry = typeof(DashboardPage).GetMethod("LoadSites", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
Task first = null!, second = null!;
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
first = (Task)retry.Invoke(cut.Instance, null)!;
|
|
second = (Task)retry.Invoke(cut.Instance, null)!;
|
|
});
|
|
Assert.Equal(1, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllSitesAsync)));
|
|
Assert.False(first.IsCompleted);
|
|
await cut.InvokeAsync(() => connectionRead.SetResult([new("Link", "OpcUa", 1)]));
|
|
await Task.WhenAll(first, second);
|
|
cut.WaitForAssertion(() => Assert.Contains("1", cut.Find("[aria-label='Sites configured']").TextContent));
|
|
Assert.Equal(2, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllSitesAsync)));
|
|
Assert.Equal(1, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllDataConnectionsAsync)));
|
|
}
|
|
|
|
/// <summary>An outstanding read remains loading while already loaded facts are available.</summary>
|
|
[Fact]
|
|
public async Task DelayedRead_RemainsLoadingUntilKnownZeroArrives()
|
|
{
|
|
var pending=new TaskCompletionSource<IReadOnlyList<InboundApiKeyInfo>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_keys.ListAsync().Returns(pending.Task);
|
|
var cut=RenderDashboard();
|
|
Assert.Contains("Loading",cut.Find("[aria-label='API keys']").TextContent);
|
|
Assert.Contains("2",cut.Find("[aria-label='Sites configured']").TextContent);
|
|
await cut.InvokeAsync(()=>pending.SetResult([]));
|
|
cut.WaitForAssertion(()=>Assert.Contains("0",cut.Find("[aria-label='API keys']").TextContent));
|
|
Assert.DoesNotContain("Loading",cut.Find("[aria-label='API keys']").TextContent);
|
|
}
|
|
}
|