fix(management): EWS write gate requires username:password credentials

This commit is contained in:
Joseph Doherty
2026-08-10 06:48:15 -04:00
parent 8df15b34b8
commit acbb9eafa5
3 changed files with 168 additions and 1 deletions
@@ -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').");
}
}
/// <summary>
@@ -1680,6 +1680,7 @@ public class ManagementActorTests : TestKit, IDisposable
{
Id = 1,
Port = 25,
Credentials = "example\\notify:fake-password",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
@@ -1755,6 +1756,9 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementError>(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<INotificationRepository>();
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<CancellationToken>()).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<ManagementError>(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<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).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<ManagementError>(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<INotificationRepository>();
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<CancellationToken>()).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<ManagementError>(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<CancellationToken>()).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<INotificationRepository>();
// 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<CancellationToken>()).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<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
else
{
var error = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Unknown email transport", error.Error);
}
}
// ========================================================================
// SMS Notifications (S5) — list Type discriminator + SMS-config management
// ========================================================================
@@ -28,5 +28,10 @@
<!-- #206: SecuredWrite audit now routes through the real CentralAuditWriter so the
handler tests verify SourceNode stamping end-to-end (actor → writer → repo). -->
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj" />
<!-- Test-only: ManagementActor deliberately does NOT reference NotificationService, so its
EWS write gate re-implements the transport parse inline. This reference exists purely so
a parity test can pin that mirror to the real EmailTransportParser. Do not take a
production dependency on it from ManagementService. -->
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj" />
</ItemGroup>
</Project>