diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs index 7b144ef9..653e3f9d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs @@ -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 RecipientEmails); -public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList RecipientEmails); +public record CreateNotificationListCommand(string Name, IReadOnlyList RecipientEmails, NotificationType Type = NotificationType.Email); +public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList 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); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 3366f84b..8b7b5b12 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -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 HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user) { var repo = sp.GetRequiredService(); - 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; } + /// + /// 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 . + /// + 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 HandleListSmsConfigs(IServiceProvider sp) + { + var repo = sp.GetRequiredService(); + 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 HandleUpdateSmsConfig(IServiceProvider sp, UpdateSmsConfigCommand cmd, string user) + { + var repo = sp.GetRequiredService(); + 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 // ======================================================================== diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 16c79746..ab0d1c76 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -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(); + Commons.Entities.Notifications.NotificationList? added = null; + notifRepo.When(r => r.AddNotificationListAsync( + Arg.Any(), Arg.Any())) + .Do(ci => added = ci.Arg()); + _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(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(); + Commons.Entities.Notifications.NotificationList? added = null; + notifRepo.When(r => r.AddNotificationListAsync( + Arg.Any(), Arg.Any())) + .Do(ci => added = ci.Arg()); + _services.AddScoped(_ => notifRepo); + + var actor = CreateActor(); + var envelope = Envelope( + new CreateNotificationListCommand("Ops Email", new[] { "ops@example.com" }), + "Designer"); + + actor.Tell(envelope); + + ExpectMsg(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(); + var existing = new Commons.Entities.Notifications.NotificationList("Ops") + { + Id = 7, + Type = Commons.Types.Enums.NotificationType.Email, + }; + notifRepo.GetNotificationListByIdAsync(7, Arg.Any()).Returns(existing); + notifRepo.GetRecipientsByListIdAsync(7, Arg.Any()) + .Returns(new List()); + _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(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(); + 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()) + .Returns(new List { 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(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(); + var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000") + { + Id = 1, + ApiBaseUrl = "https://old.example.com", + AuthToken = "old-secret", + }; + notifRepo.GetAllSmsConfigurationsAsync(Arg.Any()) + .Returns(new List { 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(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(); + var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000") + { + Id = 1, + AuthToken = "old-secret", + }; + notifRepo.GetAllSmsConfigurationsAsync(Arg.Any()) + .Returns(new List { existing }); + _services.AddScoped(_ => notifRepo); + + object? auditedAfterState = null; + _auditService.When(a => a.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => auditedAfterState = ci.ArgAt(5)); + + var actor = CreateActor(); + var envelope = Envelope( + new UpdateSmsConfigCommand(1, "ACnew", "+15551110000", AuthToken: "super-secret-token"), + "Designer"); + + actor.Tell(envelope); + + var response = ExpectMsg(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(); + notifRepo.GetAllSmsConfigurationsAsync(Arg.Any()) + .Returns(new List + { + 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(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(TimeSpan.FromSeconds(5)); + Assert.Contains("Designer", response.Message); + } + [Fact] public void CuratedHandlerFailure_SurfacesTheCuratedMessage() {