feat(sms): complete SmsConfig bundle export/import wiring + GetSmsConfigurationByIdAsync (S10b)

This commit is contained in:
Joseph Doherty
2026-06-19 11:10:39 -04:00
parent 78fadb82d2
commit c3501ecd72
19 changed files with 586 additions and 6 deletions
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
@@ -61,6 +62,16 @@ public sealed class BundleImporterApplyTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
// S10b: secret-bearing columns (SmsConfiguration.AuthToken, SmtpConfiguration
// .Credentials, …) require the context's encrypting two-arg ctor with a Data
// Protection key ring. Register an ephemeral provider and override the
// AddDbContext registration to construct the encrypting context — mirrors the
// production AddConfigurationDatabase wiring (and BundleImporterRollbackFailureTests).
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddScoped(sp => new ScadaBridgeDbContext(
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -768,6 +779,142 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Equal(1, result.Overwritten);
}
// ─────────────────────────────────────────────────────────────────────
// S10b: SMS provider config bundle apply. Mirrors the SMTP apply path —
// create a new config (Add) and overwrite an existing one (matched by the
// natural key AccountSid), with the auth token decrypted from the
// SecretsBlock. Hand-packs a BundleContentDto with SmsConfigs set so the
// test is decoupled from the export selection (covered separately).
// ─────────────────────────────────────────────────────────────────────
private async Task<Guid> PackAndLoadSmsBundleAsync(params SmsConfigDto[] smsConfigs)
{
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
SmsConfigs = smsConfigs,
};
await using var scope = _provider.CreateAsyncScope();
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var contentBytes = serializer.SerializeContentBytes(content);
var manifest = manifestBuilder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, SmsConfigs: smsConfigs.Length),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
using var ms = new MemoryStream();
await packed.CopyToAsync(ms);
ms.Position = 0;
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
[Fact]
public async Task ApplyAsync_creates_new_sms_config_with_decrypted_secret()
{
var dto = new SmsConfigDto(
AccountSid: "AC_new_sid",
FromNumber: "+15551112222",
MessagingServiceSid: "MG_svc",
ApiBaseUrl: "https://api.twilio.example",
ConnectionTimeoutSeconds: 30,
MaxRetries: 10,
RetryDelay: TimeSpan.FromMinutes(1),
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
{
["AuthToken"] = "fresh-token",
}));
var sessionId = await PackAndLoadSmsBundleAsync(dto);
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("SmsConfiguration", "AC_new_sid", ResolutionAction.Add, null) },
user: "bob");
}
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var saved = await ctx.Set<Commons.Entities.Notifications.SmsConfiguration>()
.SingleAsync(s => s.AccountSid == "AC_new_sid");
Assert.Equal("+15551112222", saved.FromNumber);
Assert.Equal("MG_svc", saved.MessagingServiceSid);
Assert.Equal("fresh-token", saved.AuthToken);
}
Assert.Equal(1, result.Added);
}
[Fact]
public async Task ApplyAsync_overwrites_existing_sms_config_matched_by_account_sid()
{
// Seed an existing config with the SAME AccountSid but stale field + secret.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Set<Commons.Entities.Notifications.SmsConfiguration>().Add(
new Commons.Entities.Notifications.SmsConfiguration("AC_existing_sid", "+15550000000")
{
AuthToken = "old-token",
ApiBaseUrl = "https://old.example",
});
await ctx.SaveChangesAsync();
}
var dto = new SmsConfigDto(
AccountSid: "AC_existing_sid",
FromNumber: "+15559998888",
MessagingServiceSid: null,
ApiBaseUrl: "https://new.example",
ConnectionTimeoutSeconds: 45,
MaxRetries: 5,
RetryDelay: TimeSpan.FromMinutes(2),
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
{
["AuthToken"] = "new-token",
}));
var sessionId = await PackAndLoadSmsBundleAsync(dto);
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("SmsConfiguration", "AC_existing_sid", ResolutionAction.Overwrite, null) },
user: "bob");
}
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Exactly one row for this AccountSid — Overwrite updated in place, no dup.
var saved = await ctx.Set<Commons.Entities.Notifications.SmsConfiguration>()
.SingleAsync(s => s.AccountSid == "AC_existing_sid");
Assert.Equal("+15559998888", saved.FromNumber);
Assert.Equal("https://new.example", saved.ApiBaseUrl);
Assert.Equal("new-token", saved.AuthToken);
}
Assert.Equal(1, result.Overwritten);
Assert.Equal(0, result.Added);
}
// ─────────────────────────────────────────────────────────────────────
// Re-arch C4 backward-compat: a LEGACY (pre-C4) bundle still carries an
// ApiKeys section. The importer must ignore those keys gracefully — it must