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
@@ -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
// ========================================================================