using Akka.Actor; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; public class UiAuditRegressionTests : BunitContext { /// Verifies that creating an LDAP mapping from the UI persists its audit row with the generated mapping id, or rolls back when the audit write fails. /// Whether the audit write is made to fail. /// A task that represents the asynchronous operation. [Theory] [InlineData(false)] [InlineData(true)] public async Task LdapCreateFromUi_PersistsAuditWithGeneratedMappingId_OrRollsBack(bool failAudit) { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; Services.AddScoped(_ => new ScadaBridgeDbContext(options)); Services.AddScoped(); Services.AddScoped(); Services.AddSingleton(Substitute.For()); Services.AddScoped(); if (failAudit) { var failing = Substitute.For(); failing.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.FromException(new InvalidOperationException("audit unavailable"))); Services.AddSingleton(failing); } else Services.AddScoped(); Services.AddSingleton(Substitute.For()); var user = SessionClaimBuilder.Build("administrator", "Administrator", ["admins"], new RoleMappingResult([Roles.Administrator], [], true), DateTimeOffset.UtcNow); Services.AddSingleton(new StaticAuth(user)); Services.AddAuthorizationCore(); Services.AddScoped(); Services.AddSingleton(); Services.AddScoped(); Services.AddScoped(sp => sp.GetRequiredService()); Services.AddLogging(); using var schema = new ScadaBridgeDbContext(options); await schema.Database.EnsureCreatedAsync(); var page = Render(); page.Find("input").Change("NewDirectoryGroup"); page.Find("select").Change(Roles.Viewer); page.FindAll("button").Single(b => b.TextContent.Contains("Save")).Click(); if (failAudit) { page.WaitForAssertion(() => Assert.Contains("audit unavailable", page.Markup)); Assert.Empty(await schema.LdapGroupMappings.Where(x => x.LdapGroupName == "NewDirectoryGroup").ToListAsync()); Assert.Empty(await schema.AuditLogEntries.ToListAsync()); return; } page.WaitForAssertion(() => Assert.Single(schema.LdapGroupMappings.AsNoTracking().Where(x => x.LdapGroupName == "NewDirectoryGroup").ToList())); var mapping = await schema.LdapGroupMappings.AsNoTracking().SingleAsync(x => x.LdapGroupName == "NewDirectoryGroup"); var audit = Assert.Single(await schema.AuditLogEntries.AsNoTracking().ToListAsync()); Assert.Equal(mapping.Id.ToString(), audit.EntityId); Assert.Equal("Create", audit.Action); // Existing edit form uses the same durable boundary; generated IDs are retained. page.Dispose(); var edit = Render(parameters => parameters.Add(p => p.Id, mapping.Id)); edit.Find("input").Change("RenamedDirectoryGroup"); edit.FindAll("button").Single(b => b.TextContent.Contains("Save")).Click(); edit.WaitForAssertion(() => Assert.Equal("RenamedDirectoryGroup", schema.LdapGroupMappings.AsNoTracking().Single(x => x.Id == mapping.Id).LdapGroupName)); edit.Dispose(); var list = Render(); list.WaitForAssertion(() => Assert.Contains("RenamedDirectoryGroup", list.Markup)); var row = list.FindAll("tr").Single(r => r.TextContent.Contains("RenamedDirectoryGroup")); await row.QuerySelector("button.dropdown-item")!.ClickAsync(new()); list.WaitForAssertion(() => Assert.DoesNotContain("RenamedDirectoryGroup", list.Markup)); Assert.False(await schema.LdapGroupMappings.AnyAsync(x => x.Id == mapping.Id)); var auditRows = await schema.AuditLogEntries.AsNoTracking().OrderBy(x => x.Id).ToListAsync(); Assert.Equal(new[] { "Create", "Update", "Delete" }, auditRows.Select(x => x.Action)); Assert.All(auditRows, row => Assert.Equal(mapping.Id.ToString(), row.EntityId)); } private sealed class StaticAuth(System.Security.Claims.ClaimsPrincipal principal) : AuthenticationStateProvider { /// public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(principal)); } }