feat(sms): list Type + SMS-config management commands/handlers (S5)
This commit is contained in:
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user