From 82f52e81ce75191d7628776ca8cdbf770428380e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 06:53:15 -0400 Subject: [PATCH] fix(ui): mirror the EWS username:password credentials rule on the SMTP page --- .../Notifications/SmtpConfiguration.razor | 18 ++++++++ .../Pages/SmtpConfigurationPageTests.cs | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor index 5d9092da..79d56a56 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor @@ -308,6 +308,24 @@ return; } + // Mirror of the same gate's credentials rule, with the identical FIRST-colon-only + // split: a domain-qualified user name contains no colon but a password may. This + // page has no preserve-if-blank behaviour — the field's value IS what gets + // persisted, and a blank field clears the stored credential — so the rule applies + // on edit as well as add, exactly as the management gate validates the effective row. + if (IsEwsForm) + { + var credentialParts = _credentials?.Trim().Split(':', 2) ?? []; + if (credentialParts.Length != 2 + || credentialParts[0].Length == 0 + || credentialParts[1].Length == 0) + { + _formError = "EWS credentials must be in 'username:password' form " + + @"(username may be 'domain\username')."; + return; + } + } + try { if (_editingSmtp != null) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs index 8593dc73..1116f94c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs @@ -182,6 +182,7 @@ public class SmtpConfigurationPageTests : BunitContext // 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(() => @@ -233,6 +234,47 @@ public class SmtpConfigurationPageTests : BunitContext 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() {