feat(sms): list Type + SMS-config management commands/handlers (S5)

This commit is contained in:
Joseph Doherty
2026-06-19 10:12:55 -04:00
parent bffbb0c2da
commit 609bdb37ef
3 changed files with 283 additions and 3 deletions
@@ -1,9 +1,13 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListNotificationListsCommand;
public record GetNotificationListCommand(int NotificationListId);
public record CreateNotificationListCommand(string Name, IReadOnlyList<string> RecipientEmails);
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails);
public record CreateNotificationListCommand(string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email);
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email);
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);
public record ListSmsConfigsCommand;
public record UpdateSmsConfigCommand(int SmsConfigId, string AccountSid, string FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null);
@@ -187,6 +187,7 @@ public class ManagementActor : ReceiveActor
or CreateNotificationListCommand or UpdateNotificationListCommand
or DeleteNotificationListCommand
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand or MoveDataConnectionCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
@@ -331,6 +332,8 @@ public class ManagementActor : ReceiveActor
DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd, user.Username),
ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp),
UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd, user.Username),
ListSmsConfigsCommand => await HandleListSmsConfigs(sp),
UpdateSmsConfigCommand cmd => await HandleUpdateSmsConfig(sp, cmd, user.Username),
// Shared Scripts
ListSharedScriptsCommand => await HandleListSharedScripts(sp),
@@ -1682,7 +1685,7 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = new NotificationList(cmd.Name);
var list = new NotificationList(cmd.Name) { Type = cmd.Type };
foreach (var email in cmd.RecipientEmails)
{
list.Recipients.Add(new NotificationRecipient(email, email));
@@ -1699,6 +1702,7 @@ public class ManagementActor : ReceiveActor
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException($"NotificationList with ID {cmd.NotificationListId} not found.");
list.Name = cmd.Name;
list.Type = cmd.Type;
var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId);
foreach (var r in existingRecipients)
@@ -1784,6 +1788,58 @@ public class ManagementActor : ReceiveActor
return publicShape;
}
/// <summary>
/// MgmtSvc-020 (SMS): project an SmsConfiguration to a credential-free shape so the
/// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
/// response payloads or audit afterState. Mirrors <see cref="SmtpConfigPublicShape"/>.
/// </summary>
private static object SmsConfigPublicShape(Commons.Entities.Notifications.SmsConfiguration c) =>
new
{
c.Id,
c.AccountSid,
c.FromNumber,
c.MessagingServiceSid,
c.ApiBaseUrl,
c.ConnectionTimeoutSeconds,
c.MaxRetries,
c.RetryDelay,
HasAuthToken = !string.IsNullOrEmpty(c.AuthToken),
};
private static async Task<object?> HandleListSmsConfigs(IServiceProvider sp)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var configs = await repo.GetAllSmsConfigurationsAsync();
// MgmtSvc-020: project away the AuthToken field — read access to this
// list is broader than the Designer-gated UpdateSmsConfig path that owns
// the secret.
return configs.Select(SmsConfigPublicShape).ToList();
}
private static async Task<object?> HandleUpdateSmsConfig(IServiceProvider sp, UpdateSmsConfigCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var configs = await repo.GetAllSmsConfigurationsAsync();
var config = configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
?? throw new ManagementCommandException($"SmsConfiguration with ID {cmd.SmsConfigId} not found.");
config.AccountSid = cmd.AccountSid;
config.FromNumber = cmd.FromNumber;
config.MessagingServiceSid = cmd.MessagingServiceSid;
// Preserve-if-null: an update that omits ApiBaseUrl/AuthToken leaves the
// existing values intact (non-breaking for callers that do not send them,
// and so the secret AuthToken survives a config edit that does not rotate it).
if (cmd.ApiBaseUrl is not null) config.ApiBaseUrl = cmd.ApiBaseUrl;
if (cmd.AuthToken is not null) config.AuthToken = cmd.AuthToken;
await repo.UpdateSmsConfigurationAsync(config);
await repo.SaveChangesAsync();
// MgmtSvc-020: audit the credential-free shape — the AuthToken secret is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmsConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
return publicShape;
}
// ========================================================================
// Security handlers
// ========================================================================
@@ -1337,6 +1337,226 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal("Basic", existing.AuthType);
}
// ========================================================================
// SMS Notifications (S5) — list Type discriminator + SMS-config management
// ========================================================================
[Fact]
public void CreateNotificationList_WithSmsType_PersistsSms()
{
var notifRepo = Substitute.For<INotificationRepository>();
Commons.Entities.Notifications.NotificationList? added = null;
notifRepo.When(r => r.AddNotificationListAsync(
Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateNotificationListCommand("Ops SMS", new[] { "+15551230000" },
Commons.Types.Enums.NotificationType.Sms),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(added);
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
}
[Fact]
public void CreateNotificationList_DefaultType_IsEmail()
{
// The Type parameter is a trailing default — existing callers that omit it
// continue to produce Email lists (additive, non-breaking).
var notifRepo = Substitute.For<INotificationRepository>();
Commons.Entities.Notifications.NotificationList? added = null;
notifRepo.When(r => r.AddNotificationListAsync(
Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateNotificationListCommand("Ops Email", new[] { "ops@example.com" }),
"Designer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.Equal(Commons.Types.Enums.NotificationType.Email, added!.Type);
}
[Fact]
public void UpdateNotificationList_WithSmsType_PersistsSms()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.NotificationList("Ops")
{
Id = 7,
Type = Commons.Types.Enums.NotificationType.Email,
};
notifRepo.GetNotificationListByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
notifRepo.GetRecipientsByListIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.NotificationRecipient>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateNotificationListCommand(7, "Ops", new[] { "+15551230000" },
Commons.Types.Enums.NotificationType.Sms),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, existing.Type);
}
[Fact]
public void UpdateSmsConfig_WithApiBaseUrlAndAuthToken_PersistsThem()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
MessagingServiceSid = "MGold",
ApiBaseUrl = "https://old.example.com",
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000", "MGnew",
"https://new.example.com", "new-secret"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("ACnew", existing.AccountSid);
Assert.Equal("+15551110000", existing.FromNumber);
Assert.Equal("MGnew", existing.MessagingServiceSid);
Assert.Equal("https://new.example.com", existing.ApiBaseUrl);
Assert.Equal("new-secret", existing.AuthToken);
}
[Fact]
public void UpdateSmsConfig_WithNullAuthTokenAndApiBaseUrl_PreservesExistingValues()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
ApiBaseUrl = "https://old.example.com",
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// AuthToken + ApiBaseUrl omitted -> preserve-if-null.
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// Omitted secret/url are preserved, not nulled.
Assert.Equal("old-secret", existing.AuthToken);
Assert.Equal("https://old.example.com", existing.ApiBaseUrl);
// Provided fields are still updated.
Assert.Equal("ACnew", existing.AccountSid);
Assert.Equal("+15551110000", existing.FromNumber);
}
[Fact]
public void UpdateSmsConfig_ResultAndAudit_NeverContainAuthToken()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
object? auditedAfterState = null;
_auditService.When(a => a.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>()))
.Do(ci => auditedAfterState = ci.ArgAt<object?>(5));
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000", AuthToken: "super-secret-token"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The secret is applied to the entity ...
Assert.Equal("super-secret-token", existing.AuthToken);
// ... but never leaves the boundary in the response payload ...
Assert.DoesNotContain("super-secret-token", response.JsonData);
// ... and the public shape signals presence only (camelCase serializer).
Assert.Contains("hasAuthToken", response.JsonData);
// ... nor in the audit afterState.
Assert.NotNull(auditedAfterState);
Assert.DoesNotContain("super-secret-token",
ManagementActor.SerializeResult(auditedAfterState));
}
[Fact]
public void ListSmsConfigs_ProjectsAwayAuthToken()
{
var notifRepo = Substitute.For<INotificationRepository>();
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration>
{
new("ACone", "+15550000000") { Id = 1, AuthToken = "leak-me" },
});
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// Read-only -> any authenticated user (no special role required).
var envelope = Envelope(new ListSmsConfigsCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("leak-me", response.JsonData);
Assert.Contains("hasAuthToken", response.JsonData);
}
[Fact]
public void UpdateSmsConfig_WithViewerRole_ReturnsUnauthorized()
{
// Mirrors UpdateSmtpConfig gating: mutating the SMS config is a Designer
// operation; a read-only role cannot rotate the secret.
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000"),
"Viewer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
{