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