using System.Security.Claims; using ZB.MOM.WW.ScadaBridge.Security; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using SmtpConfigurationPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.SmtpConfiguration; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// /// bUnit rendering tests for the SMTP Configuration page — specifically the TlsMode /// field added so the UI exposes all five user-relevant SmtpConfiguration fields. /// public class SmtpConfigurationPageTests : BunitContext { private void WireAuth() { var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); } private static SmtpConfiguration Sample() => new("smtp.example.com", "Basic", "noreply@example.com") { Id = 1, Port = 587, TlsMode = "StartTLS", Credentials = "user:pass", }; private static SmtpConfiguration OAuth2Sample() => new("smtp.example.com", "OAuth2", "noreply@example.com") { Id = 1, Port = 587, TlsMode = "StartTLS", Credentials = "client-secret", }; private static SmtpConfiguration EwsSample() => new("https://mail.example.com/ews/exchange.asmx", "Basic", "noreply@example.com") { Id = 2, Port = 587, TlsMode = "StartTLS", Transport = "Ews", Credentials = @"domain\user:pass", }; private IRenderedComponent RenderWith( INotificationRepository repo, params SmtpConfiguration[] configs) { repo.GetAllSmtpConfigurationsAsync() .Returns(Task.FromResult>(configs.ToList())); Services.AddSingleton(repo); WireAuth(); var cut = Render(); cut.WaitForState(() => cut.Markup.Contains(configs[0].Host)); return cut; } private static void ClickEdit(IRenderedComponent cut) => cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click(); private static void ClickSave(IRenderedComponent cut) => cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click(); /// Locates a select by an option it offers — the page has no element ids. private static AngleSharp.Dom.IElement SelectWithOption( IRenderedComponent cut, string optionText) => cut.FindAll("select") .Single(s => s.QuerySelectorAll("option").Any(o => o.TextContent == optionText)); private static bool HasSelectWithOption( IRenderedComponent cut, string optionText) => cut.FindAll("select") .Any(s => s.QuerySelectorAll("option").Any(o => o.TextContent == optionText)); [Fact] public void EditForm_RendersTlsModeSelectWithAllThreeModes() { var repo = Substitute.For(); repo.GetAllSmtpConfigurationsAsync() .Returns(Task.FromResult>( new List { Sample() })); Services.AddSingleton(repo); WireAuth(); var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("smtp.example.com")); cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click(); cut.WaitForAssertion(() => { var selects = cut.FindAll("select"); var tlsSelect = selects.Single(s => s.QuerySelectorAll("option") .Any(o => o.TextContent == "StartTLS")); var modes = tlsSelect.QuerySelectorAll("option").Select(o => o.TextContent).ToList(); Assert.Equal(new[] { "None", "StartTLS", "SSL" }, modes); }); } [Fact] public void ReadOnlyView_ShowsTlsMode() { var repo = Substitute.For(); repo.GetAllSmtpConfigurationsAsync() .Returns(Task.FromResult>( new List { Sample() })); Services.AddSingleton(repo); WireAuth(); var cut = Render(); cut.WaitForAssertion(() => { Assert.Contains("TLS Mode", cut.Markup); Assert.Contains("StartTLS", cut.Markup); }); } [Fact] public void SavingEdit_PersistsChosenTlsMode() { var config = Sample(); var repo = Substitute.For(); repo.GetAllSmtpConfigurationsAsync() .Returns(Task.FromResult>( new List { config })); Services.AddSingleton(repo); WireAuth(); var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("smtp.example.com")); cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click(); var tlsSelect = cut.FindAll("select") .Single(s => s.QuerySelectorAll("option").Any(o => o.TextContent == "StartTLS")); tlsSelect.Change("SSL"); cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click(); cut.WaitForAssertion(() => { repo.Received().UpdateSmtpConfigurationAsync( Arg.Is(c => c.TlsMode == "SSL")); repo.Received().SaveChangesAsync(); }); } // ======================================================================== // Transport selector (EWS) — form state machine // ======================================================================== [Fact] public void EditForm_WithEwsTransport_HidesPortTlsAndAuthTypeAndForcesBasicAuth() { var repo = Substitute.For(); var cut = RenderWith(repo, OAuth2Sample()); ClickEdit(cut); SelectWithOption(cut, "Ews").Change("Ews"); // Port input, TLS select and Auth Type select are all gone under EWS. Assert.Empty(cut.FindAll("input[type=number]")); Assert.False(HasSelectWithOption(cut, "StartTLS")); Assert.False(HasSelectWithOption(cut, "OAuth2")); Assert.Contains("EWS URL", cut.Markup); // The forced auth type is only observable through what gets persisted: the row // loaded as OAuth2 must save as Basic because EWS supports Basic only. cut.Find("input[type=text]").Change("https://mail.example.com/ews/exchange.asmx"); cut.Find("input[type=password]").Change(@"dom\user:pw"); ClickSave(cut); cut.WaitForAssertion(() => repo.Received().UpdateSmtpConfigurationAsync(Arg.Is( c => c.Transport == "Ews" && c.AuthType == "Basic" && c.Host == "https://mail.example.com/ews/exchange.asmx"))); } [Fact] public void EditForm_SwitchingEwsBackToSmtp_RestoresAuthTypeAndReshowsPortAndTls() { var repo = Substitute.For(); var cut = RenderWith(repo, OAuth2Sample()); ClickEdit(cut); SelectWithOption(cut, "Ews").Change("Ews"); SelectWithOption(cut, "Ews").Change("Smtp"); // Port and TLS controls are back... Assert.Single(cut.FindAll("input[type=number]")); Assert.True(HasSelectWithOption(cut, "StartTLS")); Assert.True(HasSelectWithOption(cut, "OAuth2")); // ...and the round trip left the pre-switch values (OAuth2 / 587 / StartTLS) intact. ClickSave(cut); cut.WaitForAssertion(() => repo.Received().UpdateSmtpConfigurationAsync(Arg.Is( c => c.Transport == "Smtp" && c.AuthType == "OAuth2" && c.Port == 587 && c.TlsMode == "StartTLS"))); } [Fact] public void Save_WithEwsTransportAndNonHttpsHost_ShowsFormErrorAndDoesNotPersist() { var repo = Substitute.For(); var cut = RenderWith(repo, OAuth2Sample()); ClickEdit(cut); SelectWithOption(cut, "Ews").Change("Ews"); cut.Find("input[type=text]").Change("http://mail.example.com/ews/exchange.asmx"); ClickSave(cut); cut.WaitForAssertion(() => Assert.Contains("absolute https://", cut.Markup)); repo.DidNotReceive().UpdateSmtpConfigurationAsync(Arg.Any()); repo.DidNotReceive().SaveChangesAsync(); } [Fact] public void Save_WithEwsTransportAndCredentialsWithoutColon_ShowsFormErrorAndDoesNotPersist() { var repo = Substitute.For(); // The realistic mistake: an existing SMTP row whose credential is a bare password // is flipped to Ews. Host is valid, so only the credentials rule can reject it. var cut = RenderWith(repo, OAuth2Sample()); ClickEdit(cut); SelectWithOption(cut, "Ews").Change("Ews"); cut.Find("input[type=text]").Change("https://mail.example.com/ews/exchange.asmx"); cut.Find("input[type=password]").Change("bare-password"); ClickSave(cut); cut.WaitForAssertion(() => Assert.Contains("username:password", cut.Markup)); repo.DidNotReceive().UpdateSmtpConfigurationAsync(Arg.Any()); repo.DidNotReceive().SaveChangesAsync(); } [Fact] public void Save_WithEwsTransportAndDomainQualifiedCredentials_Persists() { var repo = Substitute.For(); var cut = RenderWith(repo, OAuth2Sample()); ClickEdit(cut); SelectWithOption(cut, "Ews").Change("Ews"); cut.Find("input[type=text]").Change("https://mail.example.com/ews/exchange.asmx"); // A domain-qualified user name carries no colon of its own; the split takes the // FIRST colon only, so a password containing colons stays intact. cut.Find("input[type=password]").Change(@"dom\user:pw:with:colons"); ClickSave(cut); cut.WaitForAssertion(() => { repo.Received().UpdateSmtpConfigurationAsync(Arg.Is( c => c.Transport == "Ews" && c.Credentials == @"dom\user:pw:with:colons")); repo.Received().SaveChangesAsync(); }); } [Fact] public void ReadOnlyView_ShowsTransportBadgeAndHidesPortAndTlsForEwsRows() { var ewsCut = RenderWith(Substitute.For(), EwsSample()); ewsCut.WaitForAssertion(() => { Assert.Contains("Ews", ewsCut.Markup); Assert.Contains("EWS URL", ewsCut.Markup); Assert.DoesNotContain("TLS Mode", ewsCut.Markup); // The EWS URL is rendered bare — no ":port" suffix as the SMTP host row has. Assert.DoesNotContain("exchange.asmx:587", ewsCut.Markup); }); } [Fact] public void ReadOnlyView_LegacyRowWithNullTransport_ShowsSmtpBadgeAndKeepsPortAndTls() { var cut = RenderWith(Substitute.For(), Sample()); cut.WaitForAssertion(() => { Assert.Contains("Smtp", cut.Markup); Assert.Contains("smtp.example.com:587", cut.Markup); Assert.Contains("TLS Mode", cut.Markup); }); } }