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; namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListNotificationListsCommand; public record ListNotificationListsCommand;
public record GetNotificationListCommand(int NotificationListId); public record GetNotificationListCommand(int NotificationListId);
public record CreateNotificationListCommand(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); public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email);
public record DeleteNotificationListCommand(int NotificationListId); public record DeleteNotificationListCommand(int NotificationListId);
public record ListSmtpConfigsCommand; public record ListSmtpConfigsCommand;
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null); 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 CreateNotificationListCommand or UpdateNotificationListCommand
or DeleteNotificationListCommand or DeleteNotificationListCommand
or UpdateSmtpConfigCommand or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand or MoveDataConnectionCommand or DeleteDataConnectionCommand or MoveDataConnectionCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
@@ -331,6 +332,8 @@ public class ManagementActor : ReceiveActor
DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd, user.Username), DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd, user.Username),
ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp), ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp),
UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd, user.Username), UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd, user.Username),
ListSmsConfigsCommand => await HandleListSmsConfigs(sp),
UpdateSmsConfigCommand cmd => await HandleUpdateSmsConfig(sp, cmd, user.Username),
// Shared Scripts // Shared Scripts
ListSharedScriptsCommand => await HandleListSharedScripts(sp), ListSharedScriptsCommand => await HandleListSharedScripts(sp),
@@ -1682,7 +1685,7 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user) private static async Task<object?> HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user)
{ {
var repo = sp.GetRequiredService<INotificationRepository>(); 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) foreach (var email in cmd.RecipientEmails)
{ {
list.Recipients.Add(new NotificationRecipient(email, email)); list.Recipients.Add(new NotificationRecipient(email, email));
@@ -1699,6 +1702,7 @@ public class ManagementActor : ReceiveActor
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId) var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException($"NotificationList with ID {cmd.NotificationListId} not found."); ?? throw new ManagementCommandException($"NotificationList with ID {cmd.NotificationListId} not found.");
list.Name = cmd.Name; list.Name = cmd.Name;
list.Type = cmd.Type;
var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId); var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId);
foreach (var r in existingRecipients) foreach (var r in existingRecipients)
@@ -1784,6 +1788,58 @@ public class ManagementActor : ReceiveActor
return publicShape; 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 // Security handlers
// ======================================================================== // ========================================================================
@@ -1337,6 +1337,226 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal("Basic", existing.AuthType); 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] [Fact]
public void CuratedHandlerFailure_SurfacesTheCuratedMessage() public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
{ {