feat(management): Transport on UpdateSmtpConfigCommand with EWS shape validation

This commit is contained in:
Joseph Doherty
2026-08-10 06:33:29 -04:00
parent 8657fae14f
commit 4e633b1e64
3 changed files with 210 additions and 1 deletions
@@ -8,7 +8,9 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList<string> R
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
public record DeleteNotificationListCommand(int NotificationListId);
public record ListSmtpConfigsCommand;
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null);
// Transport is a trailing additive parameter (Smtp or Ews; null = preserve the
// stored value, which is itself null for every pre-EWS row and means Smtp).
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null, string? Transport = null);
public record ListSmsConfigsCommand;
// FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid
// instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary.
@@ -2120,6 +2120,7 @@ public class ManagementActor : ReceiveActor
c.AuthType,
c.FromAddress,
c.TlsMode,
c.Transport,
c.ConnectionTimeoutSeconds,
c.MaxConcurrentConnections,
c.MaxRetries,
@@ -2156,6 +2157,10 @@ public class ManagementActor : ReceiveActor
// omitted authority/scope leaves the stored value (or its M365 default) intact.
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
// Preserve-if-null for the transport too: an omitted value keeps the stored
// one (null on every pre-EWS row, which means SMTP).
if (cmd.Transport is not null) config.Transport = cmd.Transport;
ValidateSmtpConfigTransportShape(config);
await repo.UpdateSmtpConfigurationAsync(config);
await repo.SaveChangesAsync();
// Audit the credential-free shape — the *fact of* the change
@@ -2166,6 +2171,60 @@ public class ManagementActor : ReceiveActor
return publicShape;
}
/// <summary>
/// Write-path gate for the email transport: validates the EFFECTIVE
/// post-assignment state of an SmtpConfiguration so an operator cannot persist a
/// row the delivery adapter would immediately park every notification on. The
/// adapter re-validates at delivery time and remains the authoritative check —
/// this is a fail-fast at the point of the mistake, not a second source of truth.
/// </summary>
/// <param name="config">The configuration with all command assignments already applied.</param>
/// <exception cref="ManagementCommandException">The transport is unknown, or the EWS shape rules are violated.</exception>
private static void ValidateSmtpConfigTransportShape(Commons.Entities.Notifications.SmtpConfiguration config)
{
// Mirrors NotificationService's EmailTransportParser, inlined because
// ManagementService does not (and should not) reference NotificationService
// just for a two-value parse. Keep the accepted values in step with it.
var isEws = false;
var transport = config.Transport;
if (!string.IsNullOrWhiteSpace(transport))
{
switch (transport.Trim().ToLowerInvariant())
{
case "smtp":
break;
case "ews":
isEws = true;
break;
default:
throw new ManagementCommandException(
$"Unknown email transport '{transport}'. Expected one of: Smtp, Ews.");
}
}
if (!isEws)
{
return;
}
// EWS reuses Host as the SOAP endpoint URL, not a mail-server hostname.
if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
|| !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal))
{
throw new ManagementCommandException(
"EWS transport requires Host to be an absolute https:// EWS endpoint URL "
+ "(e.g. https://mail.example.com/ews/exchange.asmx).");
}
// The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists
// on that path, so an OAuth2 row would park on every send.
if (!string.Equals(config.AuthType, "basic", StringComparison.OrdinalIgnoreCase))
{
throw new ManagementCommandException(
"EWS transport supports only Basic authentication; set AuthMode to 'basic'.");
}
}
/// <summary>
/// Project an SmsConfiguration to a credential-free shape so the
/// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
@@ -1667,6 +1667,154 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal("old.scope/.default", existing.OAuth2Scope);
}
// ========================================================================
// UpdateSmtpConfig — Transport (EWS) write-path gate
// ========================================================================
[Fact]
public void UpdateSmtpConfig_WithEwsTransport_PersistsTransportAndSurfacesItInPublicShape()
{
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();
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<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("Ews", existing.Transport);
Assert.Equal("https://ews.example.test/ews/exchange.asmx", existing.Host);
// The transport is a non-secret field, so it belongs on the public shape
// the response and audit afterState carry.
Assert.Contains("\"transport\":\"Ews\"", response.JsonData, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void UpdateSmtpConfig_WithUnknownTransport_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();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://graph.example.test/", 443, "basic", "new@example.com",
Transport: "Graph"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unknown email transport", response.Error);
Assert.Contains("Graph", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithEwsTransportAndNonHttpsHost_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();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "http://ews.example.test/ews/exchange.asmx", 80, "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("https://", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithEwsTransportAndOAuth2Auth_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();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews.example.test/ews/exchange.asmx", 443, "oauth2", "new@example.com",
Transport: "Ews"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Basic authentication", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
{
Id = 1,
Port = 443,
Transport = "Ews",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// Transport omitted (a pre-EWS caller, or Newtonsoft deserializing a
// payload without the field): the stored transport survives, and the
// EWS shape rules are still enforced against that preserved value.
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews2.example.test/ews/exchange.asmx", 443, "basic", "new@example.com"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("Ews", existing.Transport);
Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host);
}
// ========================================================================
// SMS Notifications (S5) — list Type discriminator + SMS-config management
// ========================================================================