Resolve all 46 low-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 22:56:36 -04:00
parent fef3e7675f
commit 9ee402bbcd
139 changed files with 2621 additions and 1082 deletions
@@ -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();
@@ -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()
{
@@ -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>());