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
@@ -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()
{