From acbb9eafa5f80857f26de4d79335a42491971664 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 06:48:15 -0400 Subject: [PATCH] fix(management): EWS write gate requires username:password credentials --- .../ManagementActor.cs | 15 +- .../ManagementActorTests.cs | 149 ++++++++++++++++++ ...ScadaBridge.ManagementService.Tests.csproj | 5 + 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 6f31113f..ae012ea8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -2213,7 +2213,7 @@ public class ManagementActor : ReceiveActor { throw new ManagementCommandException( "EWS transport requires Host to be an absolute https:// EWS endpoint URL " - + "(e.g. https://mail.example.com/ews/exchange.asmx)."); + + $"(e.g. https://mail.example.com/ews/exchange.asmx); got '{config.Host}'."); } // The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists @@ -2223,6 +2223,19 @@ public class ManagementActor : ReceiveActor throw new ManagementCommandException( "EWS transport supports only Basic authentication; set AuthMode to 'basic'."); } + + // Same split semantics as the delivery adapter: FIRST colon only, because a + // domain-qualified user name contains none but a password legitimately may. + // Without this the common mistake — flipping an existing SMTP row (whose + // Credentials is a bare password) to Ews — would persist happily and then park + // every single notification at delivery time. + var credentialParts = config.Credentials?.Split(':', 2) ?? []; + if (credentialParts.Length != 2 || credentialParts[0].Length == 0 || credentialParts[1].Length == 0) + { + throw new ManagementCommandException( + "EWS transport requires Credentials in 'username:password' form " + + "(username may be 'domain\\user')."); + } } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index e125b036..3ee88572 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -1680,6 +1680,7 @@ public class ManagementActorTests : TestKit, IDisposable { Id = 1, Port = 25, + Credentials = "example\\notify:fake-password", }; notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); _services.AddScoped(_ => notifRepo); @@ -1755,6 +1756,9 @@ public class ManagementActorTests : TestKit, IDisposable var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("COMMAND_FAILED", response.ErrorCode); Assert.Contains("https://", response.Error); + // The offending value is echoed so the operator sees what was rejected + // (matches the delivery adapter's message style). + Assert.Contains("http://ews.example.test/ews/exchange.asmx", response.Error); } [Fact] @@ -1784,6 +1788,97 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Basic authentication", response.Error); } + [Fact] + public void UpdateSmtpConfig_WithEwsTransportAndMalformedCredentials_ReturnsManagementError() + { + var notifRepo = Substitute.For(); + var existing = new Commons.Entities.Notifications.SmtpConfiguration( + "old.example.com", "Basic", "old@example.com") + { + Id = 1, + Port = 25, + Credentials = "example\\notify:fake-password", + }; + notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); + _services.AddScoped(_ => notifRepo); + + var actor = CreateActor(); + // A supplied credential with no colon at all — the EWS sender cannot build a + // Basic header from it, so every send would park. + var envelope = Envelope( + new UpdateSmtpConfigCommand( + 1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com", + Credentials: "passwordwithoutcolon", + Transport: "Ews"), + "Administrator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("username:password", response.Error); + } + + [Fact] + public void UpdateSmtpConfig_WithEwsTransportAndEmptyCredentialHalf_ReturnsManagementError() + { + var notifRepo = Substitute.For(); + var existing = new Commons.Entities.Notifications.SmtpConfiguration( + "old.example.com", "Basic", "old@example.com") + { + Id = 1, + Port = 25, + }; + notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); + _services.AddScoped(_ => notifRepo); + + var actor = CreateActor(); + // A trailing colon splits into two parts, but the password half is empty — + // the adapter rejects it, so the write gate must too. + var envelope = Envelope( + new UpdateSmtpConfigCommand( + 1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com", + Credentials: "example\\notify:", + Transport: "Ews"), + "Administrator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("username:password", response.Error); + } + + [Fact] + public void UpdateSmtpConfig_FlippingToEwsWithStoredBadCredentials_ReturnsManagementError() + { + var notifRepo = Substitute.For(); + var existing = new Commons.Entities.Notifications.SmtpConfiguration( + "old.example.com", "Basic", "old@example.com") + { + Id = 1, + Port = 25, + Credentials = "bare-smtp-password", + }; + notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); + _services.AddScoped(_ => notifRepo); + + var actor = CreateActor(); + // Credentials omitted entirely: the gate validates the EFFECTIVE state, so the + // preserved (unusable) stored value is what gets rejected. + var envelope = Envelope( + new UpdateSmtpConfigCommand( + 1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com", + Transport: "Ews"), + "Administrator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("username:password", response.Error); + } + [Fact] public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue() { @@ -1794,6 +1889,7 @@ public class ManagementActorTests : TestKit, IDisposable Id = 1, Port = 443, Transport = "Ews", + Credentials = "example\\notify:fake-password", }; notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); _services.AddScoped(_ => notifRepo); @@ -1815,6 +1911,59 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host); } + [Theory] + [InlineData("Smtp")] + [InlineData("Ews")] + [InlineData("Graph")] + [InlineData("")] + [InlineData(null)] + public void UpdateSmtpConfig_TransportAcceptance_MatchesEmailTransportParser(string? transport) + { + // Parity pin: the write gate re-implements the transport parse inline (ManagementService + // does not reference NotificationService), so this asserts the mirror accepts exactly the + // values EmailTransportParser accepts. If the parser gains a transport, this goes red. + var parserAccepts = true; + try + { + NotificationService.EmailTransportParser.Parse(transport); + } + catch (ArgumentException) + { + parserAccepts = false; + } + + var notifRepo = Substitute.For(); + // A fixture valid under EITHER transport, so the transport parse is the only variable. + var existing = new Commons.Entities.Notifications.SmtpConfiguration( + "https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com") + { + Id = 1, + Port = 443, + Credentials = "example\\notify:fake-password", + }; + notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); + _services.AddScoped(_ => notifRepo); + + var actor = CreateActor(); + var envelope = Envelope( + new UpdateSmtpConfigCommand( + 1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com", + Transport: transport), + "Administrator"); + + actor.Tell(envelope); + + if (parserAccepts) + { + ExpectMsg(TimeSpan.FromSeconds(5)); + } + else + { + var error = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("Unknown email transport", error.Error); + } + } + // ======================================================================== // SMS Notifications (S5) — list Type discriminator + SMS-config management // ======================================================================== diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj index 9b864eb9..e0f8673e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj @@ -28,5 +28,10 @@ + +