diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index 9b0f343a..df590a17 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -4,6 +4,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @using System.ComponentModel.DataAnnotations @using ZB.MOM.WW.OtOpcUa.AdminUI.Acls @using ZB.MOM.WW.OtOpcUa.Configuration @@ -148,12 +149,16 @@ else Cancel - @if (!IsNew) { } + @if (!IsNew) { } + + } @@ -175,6 +180,7 @@ else private NodeAcl? _existing; private bool _loaded; private bool _busy; + private bool _confirmDeleteVisible; private string? _error; private bool _pickerVisible; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor index c1349d6b..cd934acf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor @@ -5,6 +5,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @using System.ComponentModel.DataAnnotations @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -99,11 +100,15 @@ else Cancel - + + } @@ -114,6 +119,7 @@ else private ServerCluster? _existing; private bool _loaded; private bool _busy; + private bool _confirmDeleteVisible; private string? _error; protected override async Task OnInitializedAsync() diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor index f4f5392a..707fd0cd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor @@ -6,6 +6,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @using System.ComponentModel.DataAnnotations @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -116,12 +117,16 @@ else @if (!IsNew) { - } + + } @@ -135,6 +140,7 @@ else private ClusterNode? _existing; private bool _loaded; private bool _busy; + private bool _confirmDeleteVisible; private string? _error; protected override async Task OnInitializedAsync() diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor index 2031c4b5..6cfd8410 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor @@ -2,6 +2,7 @@ @attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)] @rendermode RenderMode.InteractiveServer @using Microsoft.Extensions.Options +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Configuration.Services @@ -65,7 +66,7 @@ @DisplayText.Abbreviate(r.LdapGroup, 48) @r.Role - + } } @@ -74,6 +75,11 @@ + + @if (_options is not null) {
@@ -113,6 +119,7 @@ private AdminRole _newRole = AdminRole.Viewer; private string? _error; private bool _busy; + private LdapGroupRoleMapping? _pendingDelete; protected override async Task OnInitializedAsync() { @@ -143,6 +150,13 @@ finally { _busy = false; } } + private async Task ConfirmDeleteAsync() + { + var id = _pendingDelete?.Id; + _pendingDelete = null; + if (id is Guid g) await DeleteAsync(g); + } + private async Task DeleteAsync(Guid id) { _error = null; _busy = true; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pages/DeleteConfirmRenderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pages/DeleteConfirmRenderTests.cs new file mode 100644 index 00000000..bec9295b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pages/DeleteConfirmRenderTests.cs @@ -0,0 +1,191 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Acls; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters; +using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls; +using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Services; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pages; + +/// +/// lmxopcua#785: the Delete buttons on RoleGrants, ClusterEdit, NodeEdit and AclEdit deleted on the +/// first click. Each must open the shared RawConfirmDialog; only Confirm deletes, Cancel keeps the item. +/// +[Trait("Category", "Unit")] +public sealed class DeleteConfirmRenderTests : BunitContext +{ + private const string ModalSelector = ".modal-dialog"; + + private IDbContextFactory NewDb(Action seed) + { + var name = $"del-{Guid.NewGuid():N}"; + using (var db = RawTreeTestDb.CreateNamed(name)) { seed(db); db.SaveChanges(); } + var factory = RawTreeTestDb.Factory(name); + Services.AddSingleton(factory); + Services.AddSingleton(new FakeAuth()); + return factory; + } + + private static void SeedCluster(OtOpcUaConfigDbContext db) => + db.ServerClusters.Add(new ServerCluster + { + ClusterId = "C1", Name = "Cluster One", Enterprise = "zb", Site = "s1", + RedundancyMode = RedundancyMode.None, CreatedBy = "test", + }); + + private static void ClickDelete(IRenderedComponent cut) => + cut.Find("button.btn-outline-danger").Click(); + + // ---------------- ClusterEdit ---------------- + + [Fact] + public void ClusterEdit_Delete_confirms_first() + { + var f = NewDb(SeedCluster); + var cut = Render(p => p.Add(x => x.ClusterId, "C1")); + cut.FindAll(ModalSelector).ShouldBeEmpty(); + + ClickDelete(cut); + cut.Find(ModalSelector).TextContent.ShouldContain("C1"); + ClusterCount(f).ShouldBe(1); // nothing deleted by merely opening the modal + + cut.Find(".modal-footer .btn-outline-secondary").Click(); // Cancel + cut.FindAll(ModalSelector).ShouldBeEmpty(); + ClusterCount(f).ShouldBe(1); + + ClickDelete(cut); + cut.Find(".modal-footer .btn-danger").Click(); // Confirm + cut.WaitForAssertion(() => ClusterCount(f).ShouldBe(0)); + } + + // ---------------- NodeEdit ---------------- + + [Fact] + public void NodeEdit_Delete_confirms_first() + { + var f = NewDb(db => + { + SeedCluster(db); + db.ClusterNodes.Add(new ClusterNode + { + NodeId = "n1", ClusterId = "C1", Host = "h1", ApplicationUri = "urn:n1", CreatedBy = "test", + }); + }); + var cut = Render(p => p.Add(x => x.ClusterId, "C1").Add(x => x.NodeId, "n1")); + cut.FindAll(ModalSelector).ShouldBeEmpty(); + + ClickDelete(cut); + cut.Find(ModalSelector).TextContent.ShouldContain("n1"); + NodeCount(f).ShouldBe(1); + + cut.Find(".modal-footer .btn-outline-secondary").Click(); + cut.FindAll(ModalSelector).ShouldBeEmpty(); + NodeCount(f).ShouldBe(1); + + ClickDelete(cut); + cut.Find(".modal-footer .btn-danger").Click(); + cut.WaitForAssertion(() => NodeCount(f).ShouldBe(0)); + } + + // ---------------- AclEdit ---------------- + + [Fact] + public void AclEdit_Delete_confirms_first() + { + var f = NewDb(db => + { + SeedCluster(db); + db.NodeAcls.Add(new NodeAcl + { + NodeAclId = "acl-1", ClusterId = "C1", Principal = "ops-team", + ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Browse, + }); + }); + Services.AddSingleton( + new AclScopeService(f, new RawTreeService(f), new UnsTreeService(f))); + var cut = Render(p => p.Add(x => x.ClusterId, "C1").Add(x => x.NodeAclId, "acl-1")); + cut.FindAll(ModalSelector).ShouldBeEmpty(); + + ClickDelete(cut); + cut.Find(ModalSelector).TextContent.ShouldContain("acl-1"); + AclCount(f).ShouldBe(1); + + cut.Find(".modal-footer .btn-outline-secondary").Click(); + cut.FindAll(ModalSelector).ShouldBeEmpty(); + AclCount(f).ShouldBe(1); + + ClickDelete(cut); + cut.Find(".modal-footer .btn-danger").Click(); + cut.WaitForAssertion(() => AclCount(f).ShouldBe(0)); + } + + // ---------------- RoleGrants ---------------- + + [Fact] + public void RoleGrants_Delete_confirms_first() + { + var svc = new FakeMappings(new LdapGroupRoleMapping + { + Id = Guid.NewGuid(), LdapGroup = "UG_Admins", Role = AdminRole.Viewer, IsSystemWide = true, + }); + Services.AddSingleton(svc); + Services.AddSingleton>(new FakeOptions()); + var cut = Render(); + cut.FindAll(ModalSelector).ShouldBeEmpty(); + + cut.Find("table button.btn-outline-danger").Click(); + cut.Find(ModalSelector).TextContent.ShouldContain("UG_Admins"); + svc.DeleteCalls.ShouldBe(0); + + cut.Find(".modal-footer .btn-outline-secondary").Click(); + cut.FindAll(ModalSelector).ShouldBeEmpty(); + svc.DeleteCalls.ShouldBe(0); + cut.Markup.ShouldContain("UG_Admins"); + + cut.Find("table button.btn-outline-danger").Click(); + cut.Find(".modal-footer .btn-danger").Click(); + cut.WaitForAssertion(() => svc.DeleteCalls.ShouldBe(1)); + } + + private static int ClusterCount(IDbContextFactory f) { using var d = f.CreateDbContext(); return d.ServerClusters.Count(); } + private static int NodeCount(IDbContextFactory f) { using var d = f.CreateDbContext(); return d.ClusterNodes.Count(); } + private static int AclCount(IDbContextFactory f) { using var d = f.CreateDbContext(); return d.NodeAcls.Count(); } + + private sealed class FakeAuth : AuthenticationStateProvider + { + public override Task GetAuthenticationStateAsync() => + Task.FromResult(new AuthenticationState(new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity("test")))); + } + + private sealed class FakeOptions : IOptionsSnapshot + { + public LdapOptions Value { get; } = new(); + public LdapOptions Get(string? name) => Value; + } + + private sealed class FakeMappings(LdapGroupRoleMapping row) : ILdapGroupRoleMappingService + { + private readonly List _rows = [row]; + public int DeleteCalls { get; private set; } + public Task> GetByGroupsAsync(IEnumerable g, CancellationToken ct) => + Task.FromResult>(_rows.ToList()); + public Task> ListAllAsync(CancellationToken ct) => + Task.FromResult>(_rows.ToList()); + public Task CreateAsync(LdapGroupRoleMapping r, CancellationToken ct) { _rows.Add(r); return Task.FromResult(r); } + public Task DeleteAsync(Guid id, CancellationToken ct) { DeleteCalls++; _rows.RemoveAll(r => r.Id == id); return Task.CompletedTask; } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index 438e49fa..103fd88d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -9,6 +9,11 @@ + + +