Resolve all 46 low-severity architecture review findings
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
using NSubstitute;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -67,16 +73,51 @@ public class AuthEndpointsCsrfTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostAuthLogin_StillDisablesAntiforgery_PreAuthIsAcceptable()
|
||||
public async Task PostAuthLogin_ValidTokenAuthenticatesAndSetsCookie()
|
||||
{
|
||||
// Login is a pre-auth endpoint; disabling antiforgery there is acceptable
|
||||
// and intentional. This pins that the fix did not over-correct.
|
||||
var endpoints = BuildEndpoints();
|
||||
var login = Find(endpoints, "/auth/login", "POST");
|
||||
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddAntiforgery();
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
|
||||
services.AddSingleton(new LoginThrottle(TimeProvider.System, Options.Create(new SecurityOptions())));
|
||||
var ldap = Substitute.For<ILdapAuthService>();
|
||||
ldap.AuthenticateAsync("alice", "password", Arg.Any<CancellationToken>())
|
||||
.Returns(LdapAuthResult.Success("alice", "Alice", []));
|
||||
services.AddSingleton(ldap);
|
||||
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
||||
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new GroupRoleMapping<string>([Roles.Viewer], Scope: null));
|
||||
services.AddSingleton(mapper);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var tokenContext = new DefaultHttpContext { RequestServices = provider };
|
||||
var tokens = provider.GetRequiredService<IAntiforgery>().GetAndStoreTokens(tokenContext);
|
||||
var cookie = tokenContext.Response.Headers.SetCookie.Single()!.Split(';')[0];
|
||||
var context = new DefaultHttpContext { RequestServices = provider };
|
||||
context.Request.Headers.Cookie = cookie;
|
||||
context.Request.Method = "POST";
|
||||
context.Request.ContentType = "application/x-www-form-urlencoded";
|
||||
var form = $"username=alice&password=password&{tokens.FormFieldName}={Uri.EscapeDataString(tokens.RequestToken!)}";
|
||||
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(form));
|
||||
await endpoint.RequestDelegate!(context);
|
||||
Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode);
|
||||
Assert.Equal("/", context.Response.Headers.Location.ToString());
|
||||
Assert.Contains(context.Response.Headers.SetCookie, value => value!.StartsWith(".AspNetCore.Cookies="));
|
||||
}
|
||||
|
||||
Assert.NotNull(login);
|
||||
var antiforgery = login!.Metadata.GetMetadata<IAntiforgeryMetadata>();
|
||||
Assert.NotNull(antiforgery);
|
||||
Assert.False(antiforgery!.RequiresValidation);
|
||||
[Fact]
|
||||
public async Task PostAuthLogin_MissingTokenRejectsBeforeCredentialLookup()
|
||||
{
|
||||
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddAntiforgery();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var context = new DefaultHttpContext { RequestServices = provider };
|
||||
context.Request.Method = "POST";
|
||||
context.Request.ContentType = "application/x-www-form-urlencoded";
|
||||
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("username=test&password=password"));
|
||||
await endpoint.RequestDelegate!(context);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
public class RoutePolicyCoverageTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryRoutableComponent_HasItsReviewedPolicy_IncludingExternalSecrets()
|
||||
{
|
||||
var expected = new Dictionary<string, string>
|
||||
{
|
||||
["/"] = "authenticated",
|
||||
["/admin/api-keys"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/api-keys/create"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/api-keys/{KeyId}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/ldap-mappings"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/ldap-mappings/create"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/ldap-mappings/{Id:int}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/sites"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/sites/create"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/sites/{Id:int}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/audit/configuration"] = AuthorizationPolicies.OperationalAudit,
|
||||
["/audit/execution-tree"] = AuthorizationPolicies.OperationalAudit,
|
||||
["/audit/log"] = AuthorizationPolicies.OperationalAudit,
|
||||
["/deployment/debug-view"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/deployments"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/flow-debugger"] = AuthorizationPolicies.RequireFlowDebugger,
|
||||
["/deployment/instances"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/instances/create"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/instances/{Id:int}/configure"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/topology"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/design/api-methods/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/api-methods/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/connections"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/connections/{Id:int}/certificates"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/design/connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/data-connections"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/data-connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/data-connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/db-connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/db-connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/external-systems"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/external-systems/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/external-systems/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/schema-library"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/shared-scripts"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/shared-scripts/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/shared-scripts/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/templates"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/templates/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/templates/{Id:int}"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/transport/export"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/transport/import"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/login"] = "anonymous",
|
||||
["/monitoring/alarms"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/monitoring/event-logs"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/monitoring/health"] = "authenticated",
|
||||
["/monitoring/parked-messages"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/notifications/kpis"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/notifications/lists"] = AuthorizationPolicies.RequireDesign,
|
||||
["/notifications/lists/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/notifications/lists/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/notifications/report"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/notifications/sms"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/notifications/smtp"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/operations/secured-writes"] = AuthorizationPolicies.RequireSecuredWriteAccess,
|
||||
["/site-calls/report"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/admin/secrets"] = "secrets:manage",
|
||||
};
|
||||
var actual = new Dictionary<string, string>();
|
||||
foreach (var type in new[] { typeof(MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }
|
||||
.SelectMany(a => a.GetTypes()))
|
||||
{
|
||||
foreach (var route in type.GetCustomAttributes<RouteAttribute>())
|
||||
{
|
||||
var anonymous = type.IsDefined(typeof(AllowAnonymousAttribute));
|
||||
var authorization = type.GetCustomAttributes<AuthorizeAttribute>().ToArray();
|
||||
Assert.True(anonymous || authorization.Length > 0, $"Missing authorization: {route.Template}");
|
||||
actual.Add(route.Template, anonymous ? "anonymous" : Assert.Single(authorization).Policy ?? "authenticated");
|
||||
}
|
||||
}
|
||||
Assert.Equal(expected.OrderBy(x => x.Key), actual.OrderBy(x => x.Key));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ internal static class ConfigurationMutationTestExtensions
|
||||
public static IServiceCollection AddTestConfigurationMutations(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddScoped<ConfigurationCommandService>();
|
||||
services.TryAddScoped<AuthorizedManualFailoverService>();
|
||||
services.TryAddSingleton<IAuditService>(_ => Substitute.For<IAuditService>());
|
||||
services.TryAddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>();
|
||||
return services;
|
||||
|
||||
@@ -52,6 +52,17 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
],
|
||||
[new("n1", "n2", false), new("n2", "n3", false)]);
|
||||
|
||||
[Fact]
|
||||
public void RefreshMerge_UsesReplayOrderThenLiveOnlyRows()
|
||||
{
|
||||
var first = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var late = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var last = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var liveOnly = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var result = FlowDebuggerPage.MergeEvents([first, last, liveOnly], [first, late, last]);
|
||||
Assert.Equal(new[] { first.EventId, late.EventId, last.EventId, liveOnly.EventId }, result.Select(e => e.EventId));
|
||||
}
|
||||
|
||||
private static FlowDebugOutcome GraphOutcome(bool withSite, bool withChildId)
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
|
||||
+3
-1
@@ -96,7 +96,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
|
||||
new List<Site> { new("Plant-A", "plant-a") { Id = 1 } }));
|
||||
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
||||
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
_repo.GetTopologyInstancesAsync(1, null, 0, 50, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>
|
||||
{
|
||||
new("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.Enabled }
|
||||
@@ -133,6 +133,8 @@ public class TopologyDisableConfirmLabelTests : BunitContext
|
||||
{
|
||||
SeedOneEnabledInstance();
|
||||
var cut = RenderPageWithHost();
|
||||
cut.InvokeAsync(() => cut.FindComponent<TopologyPage>().Instance.LoadBranchAsync(1, null)).GetAwaiter().GetResult();
|
||||
cut.FindComponent<TopologyPage>().Render();
|
||||
|
||||
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
|
||||
.QuerySelector(".tv-toggle")!.Click();
|
||||
|
||||
@@ -74,6 +74,17 @@ public class NavMenuTests : BunitContext
|
||||
return host.FindComponent<NavMenu>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Viewer_HasFlowDebuggerAndNoEmptyNotificationsSection()
|
||||
{
|
||||
var cut = RenderWithRoles("Viewer");
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("/deployment/flow-debugger", cut.Markup);
|
||||
Assert.DoesNotContain(">Notifications<", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_RenderAsKitDetailsDisclosures_ExpandedByDefault()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
||||
|
||||
public class PollGateTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RejectedRendererDispatch_ReleasesGateAndObservesFault()
|
||||
{
|
||||
var gate = new PollGate();
|
||||
Exception? observed = null;
|
||||
await gate.RunAsync(() => Task.FromException(new ObjectDisposedException("renderer")), ex => observed = ex);
|
||||
Assert.IsType<ObjectDisposedException>(observed);
|
||||
Assert.True(gate.TryEnter());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEnter_SecondCallWhileHeld_ReturnsFalse()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
public class AuthorizedManualFailoverTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task RevokedAdmin_CannotDispatchCentralOrSite(bool site)
|
||||
{
|
||||
var auth = Substitute.For<AuthenticationStateProvider>();
|
||||
var identity = new ClaimsIdentity([new Claim(JwtTokenService.RoleClaimType, Roles.Administrator)], "test");
|
||||
auth.GetAuthenticationStateAsync().Returns(new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||
var backend = Substitute.For<IManualFailoverService>();
|
||||
var service = new AuthorizedManualFailoverService(auth, backend);
|
||||
identity.RemoveClaim(identity.FindFirst(JwtTokenService.RoleClaimType)!);
|
||||
if (site) await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.FailOverSiteAsync("plant"));
|
||||
else await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.FailOverCentralAsync());
|
||||
await backend.DidNotReceiveWithAnyArgs().FailOverCentralAsync(default!);
|
||||
await backend.DidNotReceiveWithAnyArgs().FailOverSiteAsync(default!, default!);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
};
|
||||
|
||||
private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests");
|
||||
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
|
||||
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
@@ -66,7 +67,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
|
||||
public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
|
||||
private FlowDebugService CreateService(object? reply, string role = "Deployer")
|
||||
private FlowDebugService CreateService(object? reply, string role = "Deployer", string? permittedSite = null)
|
||||
{
|
||||
var holder = new ManagementActorHolder();
|
||||
if (reply != null)
|
||||
@@ -77,7 +78,9 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, role),
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||
if (permittedSite is not null) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, permittedSite));
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
@@ -85,7 +88,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
|
||||
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))));
|
||||
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance);
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance, _sites);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -110,6 +113,33 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null,
|
||||
Lifecycle: null, CorrelationId: correlationId);
|
||||
|
||||
[Theory]
|
||||
[InlineData(42, true)]
|
||||
[InlineData(99, false)]
|
||||
public async Task ResolvedSite_ScopedCallerCanOnlyRelayItsGrant(int siteId, bool expected)
|
||||
{
|
||||
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new[]
|
||||
{
|
||||
new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant", "plant") { Id = siteId }
|
||||
});
|
||||
var svc = CreateService(null, permittedSite: "42");
|
||||
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "plant"));
|
||||
Assert.Equal(expected, result.Success);
|
||||
Assert.Equal(expected ? 1 : 0, _siteCallRequests.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task UnresolvedSite_ScopedCallerCannotRelay(bool retry)
|
||||
{
|
||||
var svc = CreateService(null, permittedSite: "42");
|
||||
var step = OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "unknown-site");
|
||||
var result = retry ? await svc.RetryOutboundAsync(step) : await svc.DiscardOutboundAsync(step);
|
||||
Assert.False(result.Success);
|
||||
Assert.Empty(_siteCallRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite()
|
||||
{
|
||||
|
||||
+35
@@ -25,6 +25,41 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext
|
||||
return (new() { a, b, other }, new() { t7, t8 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtraNodeMetadataChange_RebuildsAndSurvivesFiltering()
|
||||
{
|
||||
var template = new Template("Template") { Id = 1 };
|
||||
var inherited = false;
|
||||
IReadOnlyList<TemplateTreeNode> Children(Template _) => [new()
|
||||
{
|
||||
Kind = TemplateTreeNodeKind.Composition, Id = 2, Name = "Slot",
|
||||
IsInherited = inherited, KeyOverride = inherited ? "inherited:2" : "local:2"
|
||||
}];
|
||||
var cut = Render<TemplateFolderTree>(p => p.Add(x => x.Templates, new[] { template })
|
||||
.Add(x => x.ExtraTemplateChildren, Children).Add(x => x.Filter, "Slot"));
|
||||
inherited = true;
|
||||
cut.Render(p => p.Add(x => x.Filter, "Slot"));
|
||||
var roots = cut.FindComponent<TreeView<TemplateTreeNode>>().Instance.Items;
|
||||
var node = Assert.Single(Assert.Single(roots).Children);
|
||||
Assert.True(node.IsInherited);
|
||||
Assert.Equal("inherited:2", node.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedParameters_PreserveForest_ButInPlaceRenameRebuilds()
|
||||
{
|
||||
var (folders, templates) = Catalogue();
|
||||
var cut = Render<TemplateFolderTree>(p => p.Add(x => x.Folders, folders).Add(x => x.Templates, templates));
|
||||
var field = typeof(TemplateFolderTree).GetField("_allRoots", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
var first = field.GetValue(cut.Instance);
|
||||
cut.Render(p => p.Add(x => x.Folders, folders).Add(x => x.Templates, templates));
|
||||
Assert.Same(first, field.GetValue(cut.Instance));
|
||||
folders[0].Name = "Renamed";
|
||||
cut.Render(p => p.Add(x => x.Folders, folders).Add(x => x.Templates, templates));
|
||||
Assert.NotSame(first, field.GetValue(cut.Instance));
|
||||
Assert.Contains("Renamed", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutRevealKeys_AllFoldersStartCollapsed()
|
||||
{
|
||||
|
||||
@@ -125,6 +125,9 @@ public class TopologyPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates?.ToList() ?? new List<Template>()));
|
||||
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances?.ToList() ?? new List<Instance>()));
|
||||
_repo.GetTopologyInstancesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call => (IReadOnlyList<Instance>)(instances ?? []).Where(i => i.SiteId == call.ArgAt<int>(0) && i.AreaId == call.ArgAt<int?>(1))
|
||||
.Skip(call.ArgAt<int>(2)).Take(call.ArgAt<int>(3)).ToList());
|
||||
|
||||
areasBySite ??= new();
|
||||
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
@@ -137,6 +140,94 @@ public class TopologyPageTests : BunitContext
|
||||
});
|
||||
}
|
||||
|
||||
private IRenderedComponent<TopologyPage> RenderLoadedTopology()
|
||||
{
|
||||
var cut = Render<TopologyPage>();
|
||||
cut.InvokeAsync(async () =>
|
||||
{
|
||||
foreach (var site in await _siteRepo.GetAllSitesAsync())
|
||||
{
|
||||
await cut.Instance.LoadBranchAsync(site.Id, null);
|
||||
foreach (var area in await _repo.GetAreasBySiteIdAsync(site.Id))
|
||||
await cut.Instance.LoadBranchAsync(site.Id, area.Id);
|
||||
}
|
||||
cut.Render();
|
||||
}).GetAwaiter().GetResult();
|
||||
return cut;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task BranchAndRefresh_SerializeRepositoryReadsWithoutDroppingUserPage(bool branchFirst)
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
|
||||
instances: [new Instance("First") { Id = 1, SiteId = 1 }]);
|
||||
var cut = Render<TopologyPage>();
|
||||
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null));
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource<IReadOnlyList<Instance>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var calls = 0;
|
||||
_repo.GetTopologyInstancesAsync(1, null, Arg.Any<int>(), 50, Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
entered.TrySetResult();
|
||||
return release.Task;
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<Instance>>([new Instance("Next") { Id = 51, SiteId = 1 }]);
|
||||
});
|
||||
var first = cut.InvokeAsync(() => branchFirst
|
||||
? cut.Instance.LoadBranchAsync(1, null, 50) : cut.Instance.LoadDeployedStateAsync());
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Task second = Task.CompletedTask;
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
second = branchFirst ? cut.Instance.LoadDeployedStateAsync() : cut.Instance.LoadBranchAsync(1, null, 50);
|
||||
});
|
||||
try
|
||||
{
|
||||
Assert.False(second.IsCompleted);
|
||||
Assert.Equal(1, Volatile.Read(ref calls));
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.TrySetResult([new Instance("Next") { Id = 51, SiteId = 1 }]);
|
||||
await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
Assert.Equal(2, calls);
|
||||
var field = typeof(TopologyPage).GetField("_allInstances", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
Assert.Equal(51, Assert.Single((List<Instance>)field.GetValue(cut.Instance)!).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BranchPages_AreLazyAndReplacePreviousPage()
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
|
||||
instances: Enumerable.Range(1, 101).Select(id => new Instance($"Pump-{id}") { Id = id, SiteId = 1 }));
|
||||
var cut = Render<TopologyPage>();
|
||||
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null));
|
||||
var field = typeof(TopologyPage).GetField("_allInstances", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
Assert.Equal(50, ((List<Instance>)field.GetValue(cut.Instance)!).Count);
|
||||
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null, 50));
|
||||
var second = (List<Instance>)field.GetValue(cut.Instance)!;
|
||||
Assert.Equal(50, second.Count);
|
||||
Assert.Equal(51, second[0].Id);
|
||||
await _repo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialLoad_DoesNotReadInstancesOrTemplates()
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }]);
|
||||
_repo.ClearReceivedCalls();
|
||||
var cut = Render<TopologyPage>();
|
||||
cut.WaitForAssertion(() => Assert.Contains("Plant", cut.Markup));
|
||||
_repo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>());
|
||||
_repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -145,7 +236,7 @@ public class TopologyPageTests : BunitContext
|
||||
var instance = new Instance("Pump") { Id = 100, SiteId = 1, AreaId = 10 };
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }], instances: [instance]);
|
||||
_repo.GetInstanceByIdAsync(100, Arg.Any<CancellationToken>()).Returns(instance);
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
|
||||
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
|
||||
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
|
||||
@@ -164,7 +255,7 @@ public class TopologyPageTests : BunitContext
|
||||
public async Task UI13_ExistingTopologyCreateUsesCurrentSiteScope(bool revoked)
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }]);
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
|
||||
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
|
||||
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
|
||||
@@ -181,7 +272,7 @@ public class TopologyPageTests : BunitContext
|
||||
{
|
||||
SeedRepos();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
Assert.Contains("No sites configured", cut.Markup);
|
||||
}
|
||||
@@ -193,7 +284,7 @@ public class TopologyPageTests : BunitContext
|
||||
// under it must still appear so users can move/create into it.
|
||||
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.Contains("bi-building", cut.Markup);
|
||||
@@ -222,7 +313,7 @@ public class TopologyPageTests : BunitContext
|
||||
},
|
||||
areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
// Expand the site, then the area, to render the instance leaf. The
|
||||
// helper scopes by the row's own label so we don't match outer rows
|
||||
@@ -251,7 +342,7 @@ public class TopologyPageTests : BunitContext
|
||||
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||
areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var search = cut.Find("input[type='text']");
|
||||
@@ -287,7 +378,7 @@ public class TopologyPageTests : BunitContext
|
||||
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
// The permitted site is rendered; the non-permitted site is not.
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
@@ -304,7 +395,7 @@ public class TopologyPageTests : BunitContext
|
||||
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.Contains("Plant-B", cut.Markup);
|
||||
@@ -319,7 +410,7 @@ public class TopologyPageTests : BunitContext
|
||||
};
|
||||
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1");
|
||||
@@ -342,7 +433,7 @@ public class TopologyPageTests : BunitContext
|
||||
new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.NotDeployed }
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
|
||||
@@ -410,7 +501,7 @@ public class TopologyPageTests : BunitContext
|
||||
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
FindToggleForLabel(cut, "Line-1")!.Click();
|
||||
|
||||
@@ -472,7 +563,7 @@ public class TopologyPageTests : BunitContext
|
||||
// call count is the proxy for "did staleness recompute".
|
||||
SeedOneDeployedInstanceWithComparison();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
// Initial load computed staleness exactly once.
|
||||
await _deployRepo.Received(1)
|
||||
@@ -493,7 +584,7 @@ public class TopologyPageTests : BunitContext
|
||||
// cheap deployed state (hierarchy + State badge) must still render.
|
||||
SeedOneDeployedInstanceWithComparison();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
Assert.Contains("Pump-001", cut.Markup);
|
||||
@@ -510,7 +601,7 @@ public class TopologyPageTests : BunitContext
|
||||
// again — a second snapshot-repository call.
|
||||
SeedOneDeployedInstanceWithComparison();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
_deployRepo.Received(1)
|
||||
.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user