From c3501ecd726e55978821760e2c5e4a5017034637 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 11:10:39 -0400 Subject: [PATCH] feat(sms): complete SmsConfig bundle export/import wiring + GetSmsConfigurationByIdAsync (S10b) --- .../Commands/BundleCommands.cs | 7 +- src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 1 + .../Repositories/INotificationRepository.cs | 6 + .../Messages/Management/TransportCommands.cs | 7 +- .../Types/Transport/BundleSummary.cs | 6 +- .../Types/Transport/ExportSelection.cs | 9 +- .../Repositories/NotificationRepository.cs | 4 + .../ManagementActor.cs | 7 +- .../SiteNotificationRepository.cs | 4 + .../Export/BundleExporter.cs | 11 +- .../Export/DependencyResolver.cs | 20 +++ .../Export/ResolvedExport.cs | 8 + .../Import/ArtifactDiff.cs | 34 ++++ .../Import/BundleImporter.cs | 84 ++++++++++ .../RepositoryCoverageTests.cs | 12 ++ .../Export/BundleExporterTests.cs | 83 ++++++++++ .../Import/BundleImporterApplyTests.cs | 147 ++++++++++++++++++ .../RoundTripTests.cs | 110 +++++++++++++ .../Export/DependencyResolverTests.cs | 32 ++++ 19 files changed, 586 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs index 9d8eee20..44beeac6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs @@ -61,6 +61,9 @@ public static class BundleCommands var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names"); var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names"); var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names"); + // SMS (S10b): SmsConfiguration is keyed by AccountSid (no Name column), so + // tokens are AccountSid values — mirrors --smtp-configs using Host. + var smsConfigsOption = NameListOption("--sms-configs", "Comma-separated SMS provider account SIDs"); // Inbound API keys are not transported between environments (re-arch C4) — no // --api-keys option. Re-create keys and re-grant their method scopes on the // destination via the admin UI/CLI. @@ -91,6 +94,7 @@ public static class BundleCommands cmd.Add(dbConnectionsOption); cmd.Add(notificationListsOption); cmd.Add(smtpConfigsOption); + cmd.Add(smsConfigsOption); cmd.Add(apiMethodsOption); cmd.Add(sitesOption); cmd.Add(instancesOption); @@ -118,7 +122,8 @@ public static class BundleCommands Passphrase: passphrase, SourceEnvironment: sourceEnv, SiteNames: result.GetValue(sitesOption), - InstanceNames: result.GetValue(instancesOption)); + InstanceNames: result.GetValue(instancesOption), + SmsConfigurationNames: result.GetValue(smsConfigsOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 3f1428af..4547724e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -1744,6 +1744,7 @@ scadabridge --url bundle export --output [--passphrase ] [- | `--db-connections` | no | Comma-separated database-connection names to include | | `--notification-lists` | no | Comma-separated notification-list names to include | | `--smtp-configs` | no | Comma-separated SMTP host names to include | +| `--sms-configs` | no | Comma-separated SMS provider account SIDs to include | | `--api-methods` | no | Comma-separated API-method names to include | | `--sites` | no | Comma-separated site identifiers (preferred) or friendly names to include | | `--instances` | no | Comma-separated instance unique-names to include (also pulls their site + bound data connections) | diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationRepository.cs index ae822a21..8ae33d6b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationRepository.cs @@ -107,6 +107,12 @@ public interface INotificationRepository /// The SMS configuration, or null if not found. Task GetSmsConfigurationAsync(CancellationToken cancellationToken = default); + /// Gets an SMS configuration by ID. + /// The SMS configuration ID. + /// Cancellation token. + /// The SMS configuration, or null if not found. + Task GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default); + /// Gets all SMS configurations. /// Cancellation token. /// A read-only list of SMS configurations. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs index 0c335674..5bd6d1a4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs @@ -30,7 +30,12 @@ public sealed record ExportBundleCommand( // Defaulted null so every existing positional caller keeps compiling; the // handler normalizes null to "select nothing" (or everything under All=true). IReadOnlyList? SiteNames = null, - IReadOnlyList? InstanceNames = null); + IReadOnlyList? InstanceNames = null, + // Additive (S10b): SMS provider config selection, mirroring + // SmtpConfigurationNames. SmsConfiguration is keyed by AccountSid (no Name + // column), so tokens are AccountSid values. Defaulted null so every existing + // positional caller keeps compiling. + IReadOnlyList? SmsConfigurationNames = null); /// /// Bundle body returned as base64-encoded ZIP. is the diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs index 5397980e..4a6787ce 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs @@ -15,4 +15,8 @@ public sealed record BundleSummary( // written by older exporters (which omit these JSON properties) deserialize fine. int Sites = 0, int DataConnections = 0, - int Instances = 0); + int Instances = 0, + // Additive (S10b): SMS provider config count, mirroring SmtpConfigs. Defaulted to + // 0 so manifests written by older exporters (which omit this JSON property) + // deserialize fine. + int SmsConfigs = 0); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs index 051e6413..a886aeee 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs @@ -17,11 +17,18 @@ public sealed record ExportSelection( // Additive (M8 A1): site/instance-scoped export. Defaulted to empty so every // existing positional caller keeps compiling; older callers select no sites/instances. IReadOnlyList? SiteIds = null, - IReadOnlyList? InstanceIds = null) + IReadOnlyList? InstanceIds = null, + // Additive (S10b): SMS provider config export, mirroring SmtpConfigurationIds. + // Defaulted null (trailing) so every existing positional caller keeps compiling; + // older callers select no SMS configs. + IReadOnlyList? SmsConfigurationIds = null) { /// Sites selected for site/instance-scoped export (M8). Never null. public IReadOnlyList SiteIds { get; init; } = SiteIds ?? Array.Empty(); /// Instances selected for site/instance-scoped export (M8). Never null. public IReadOnlyList InstanceIds { get; init; } = InstanceIds ?? Array.Empty(); + + /// SMS provider configurations selected for export (S10b). Never null. + public IReadOnlyList SmsConfigurationIds { get; init; } = SmsConfigurationIds ?? Array.Empty(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs index 96ab8843..fde0bc97 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationRepository.cs @@ -92,6 +92,10 @@ public class NotificationRepository : INotificationRepository public async Task GetSmsConfigurationAsync(CancellationToken cancellationToken = default) => await _context.Set().FirstOrDefaultAsync(cancellationToken); + /// + public async Task GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default) + => await _context.Set().FindAsync(new object[] { id }, cancellationToken); + /// public async Task> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default) => await _context.Set().ToListAsync(cancellationToken); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 734f6e5f..e2090302 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -2721,6 +2721,8 @@ public class ManagementActor : ReceiveActor var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync(); var notificationLists = await notifRepo.GetAllNotificationListsAsync(); var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync(); + // SMS (S10b): central-only SMS provider configs, mirroring smtpConfigs. + var smsConfigs = await notifRepo.GetAllSmsConfigurationsAsync(); // Inbound API keys are not transported between environments (re-arch C4); only methods. var apiMethods = await inboundRepo.GetAllApiMethodsAsync(); // M8 (B4): site/instance-scoped selection. Sites match on SiteIdentifier @@ -2787,7 +2789,10 @@ public class ManagementActor : ReceiveActor // M8 (B4): site/instance-scoped selection. Under All=true include every // site + instance, mirroring how All includes every template/etc. SiteIds: ResolveSiteIds(), - InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance")); + InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance"), + // SMS (S10b): SmsConfiguration is keyed by AccountSid (no Name column); + // the bundle preview row shows AccountSid, so the CLI uses AccountSid too. + SmsConfigurationIds: ResolveIds(smsConfigs, cmd.SmsConfigurationNames, s => s.AccountSid, s => s.Id, "SMS configuration")); var exporter = sp.GetRequiredService(); await using var stream = await exporter.ExportAsync( diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs index f40ae81e..87485b73 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs @@ -199,6 +199,10 @@ public class SiteNotificationRepository : INotificationRepository public Task GetSmsConfigurationAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); + /// + public Task GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + /// public Task> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException("Managed via artifact deployment from Central"); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs index 692821f0..745ed1a4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs @@ -92,6 +92,11 @@ public sealed class BundleExporter : IBundleExporter Sites = resolved.Sites, DataConnections = resolved.DataConnections, Instances = resolved.Instances, + // SMS (S10b): wire the resolver's SMS config closure through the + // aggregate. Without this the aggregate carries the empty default and the + // bundle ships an empty smsConfigs array even when SMS configs were + // selected — mirrors the M8 site/instance fix (review item I3). + SmsConfigurations = resolved.SmsConfigs, }; var contentDto = _entitySerializer.ToBundleContent(aggregate); @@ -112,7 +117,11 @@ public sealed class BundleExporter : IBundleExporter // are 0 today and become correct once those waves land. Sites: contentDto.Sites.Count, DataConnections: contentDto.DataConnections.Count, - Instances: contentDto.Instances.Count); + Instances: contentDto.Instances.Count, + // SMS (S10b): additive count sourced from the serialized content DTO — the + // same array the importer reads — so the manifest summary always matches + // the packed payload. + SmsConfigs: contentDto.SmsConfigs.Count); // 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both // ContentHash and EncryptionMetadata against the bytes it actually diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs index 19dd292b..288615c9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs @@ -111,6 +111,15 @@ public sealed class DependencyResolver if (sm is not null) smtpConfigs[sm.Id] = sm; } + // SMS provider configs (S10b): mirror the SMTP seed exactly — resolve each + // selected id via the by-id repository accessor; missing ids are skipped. + var smsConfigs = new Dictionary(); + foreach (var id in selection.SmsConfigurationIds.Distinct()) + { + var sms = await _notifications.GetSmsConfigurationByIdAsync(id, ct).ConfigureAwait(false); + if (sms is not null) smsConfigs[sms.Id] = sms; + } + // Inbound API keys are intentionally NOT resolved into the bundle: per the // inbound-API-key re-architecture (C4) keys are not transported between // environments. Only API methods travel. @@ -234,6 +243,7 @@ public sealed class DependencyResolver dbConnections.Values, notificationLists.Values, smtpConfigs.Values, + smsConfigs.Values, apiMethods.Values, orderedSites, orderedDataConnections, @@ -256,6 +266,9 @@ public sealed class DependencyResolver Sites = orderedSites, DataConnections = orderedDataConnections, Instances = orderedInstances, + // SMS (S10b): ordered by AccountSid — the natural key the importer matches + // on, mirroring how SMTP is ordered by Host. + SmsConfigs = smsConfigs.Values.OrderBy(s => s.AccountSid, StringComparer.Ordinal).ToList(), }; } @@ -549,6 +562,7 @@ public sealed class DependencyResolver IEnumerable dbConnections, IEnumerable notificationLists, IEnumerable smtpConfigs, + IEnumerable smsConfigs, IEnumerable apiMethods, IReadOnlyList sites, IReadOnlyList dataConnections, @@ -604,6 +618,12 @@ public sealed class DependencyResolver { entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty())); } + // SMS (S10b): mirror SMTP — one manifest row per config, keyed by AccountSid + // (the natural key the importer matches on). + foreach (var s in smsConfigs.OrderBy(x => x.AccountSid, StringComparer.Ordinal)) + { + entries.Add(new ManifestContentEntry("SmsConfiguration", s.AccountSid, 1, Array.Empty())); + } // Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries. foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal)) { diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs index 1e57f8c5..92ee921e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs @@ -56,4 +56,12 @@ public sealed record ResolvedExport( /// properties. /// public IReadOnlyList Instances { get; init; } = Array.Empty(); + + /// + /// SMS provider configurations in the closure, ordered by + /// (S10b). Mirrors + /// ; init-only with an empty default so callers that + /// only resolve other selections keep compiling. + /// + public IReadOnlyList SmsConfigs { get; init; } = Array.Empty(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs index 336042bf..88993daf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs @@ -252,6 +252,40 @@ public sealed class ArtifactDiff return BuildItem("SmtpConfiguration", incoming.Host, changes); } + /// + /// Compares an incoming SMS provider configuration against the existing one in + /// the database (S10b). Mirrors : keyed by + /// the natural key AccountSid, with the auth token diffed presence-only + /// (it lives in , never compared by value). + /// + /// The incoming SMS configuration from the bundle. + /// The existing SMS configuration in the database, or null if new. + /// An import preview item describing the conflict type and differences. + public ImportPreviewItem CompareSmsConfiguration(SmsConfigDto incoming, SmsConfiguration? existing) + { + ArgumentNullException.ThrowIfNull(incoming); + if (existing is null) return New("SmsConfiguration", incoming.AccountSid); + + var changes = new List(); + AddIfDifferent(changes, "FromNumber", existing.FromNumber, incoming.FromNumber); + AddIfDifferent(changes, "MessagingServiceSid", existing.MessagingServiceSid, incoming.MessagingServiceSid); + AddIfDifferent(changes, "ApiBaseUrl", existing.ApiBaseUrl, incoming.ApiBaseUrl); + AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds); + AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries); + AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString()); + + var existingHasSecret = !string.IsNullOrEmpty(existing.AuthToken); + var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthToken"); + if (existingHasSecret != incomingHasSecret) + { + changes.Add(new FieldChange("Secrets.AuthToken", + existingHasSecret ? "" : null, + incomingHasSecret ? "" : null)); + } + + return BuildItem("SmsConfiguration", incoming.AccountSid, changes); + } + // CompareApiKey was removed in re-arch C4: inbound API keys are not transported // between environments, so the import preview never diffs keys. diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index faa0b6a8..324dabd4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -429,6 +429,15 @@ public sealed class BundleImporter : IBundleImporter items.Add(_diff.CompareSmtpConfiguration(sm, existing)); } + // ---- SmsConfigurations (S10b; no by-AccountSid lookup — scan GetAll) ---- + var allSms = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false); + var smsBySid = allSms.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal); + foreach (var sms in content.SmsConfigs) + { + smsBySid.TryGetValue(sms.AccountSid, out var existing); + items.Add(_diff.CompareSmsConfiguration(sms, existing)); + } + // ---- ApiKeys ---- // Inbound API keys are not transported between environments (re-arch C4). // New bundles never carry a keys section. A pre-C4 bundle may still contain @@ -1010,6 +1019,7 @@ public sealed class BundleImporter : IBundleImporter await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false); await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false); await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false); + await ApplySmsConfigsAsync(content.SmsConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false); // Inbound API keys are NOT applied from a bundle (re-arch C4) — any keys // in a legacy bundle were counted above (apiKeysIgnored) and are skipped. await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false); @@ -2534,6 +2544,80 @@ public sealed class BundleImporter : IBundleImporter target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null; } + // SMS (S10b): mirrors ApplySmtpConfigsAsync exactly. SmsConfiguration is keyed by + // AccountSid (the diff engine's natural key — analogous to SMTP's Host), so a + // Rename targets AccountSid and Overwrite matches an existing config by AccountSid. + // The provider auth token is decrypted out of the SecretsBlock via the same path + // SMTP uses for its Credentials secret. + private async Task ApplySmsConfigsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + var all = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false); + var bySid = all.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal); + + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "SmsConfiguration", dto.AccountSid); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var sid = resolution.RenameTo ?? dto.AccountSid; + var sms = BuildSms(dto, overrideAccountSid: sid); + await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sid, + new { sms.AccountSid, RenamedFrom = dto.AccountSid }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when bySid.TryGetValue(dto.AccountSid, out var ex): + ApplySmsFields(ex, dto); + await _notificationRepo.UpdateSmsConfigurationAsync(ex, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "SmsConfiguration", ex.Id.ToString(), ex.AccountSid, + new { ex.AccountSid }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var sms = BuildSms(dto, overrideAccountSid: null); + await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sms.AccountSid, + new { sms.AccountSid }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private static SmsConfiguration BuildSms(SmsConfigDto dto, string? overrideAccountSid) + { + var sms = new SmsConfiguration(overrideAccountSid ?? dto.AccountSid, dto.FromNumber); + ApplySmsFields(sms, dto); + return sms; + } + + private static void ApplySmsFields(SmsConfiguration target, SmsConfigDto dto) + { + target.FromNumber = dto.FromNumber; + target.MessagingServiceSid = dto.MessagingServiceSid; + target.ApiBaseUrl = dto.ApiBaseUrl; + target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds; + target.MaxRetries = dto.MaxRetries; + target.RetryDelay = dto.RetryDelay; + target.AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null; + } + // ApplyApiKeysAsync was removed in re-arch C4: inbound API keys are not // transported between environments, so a bundle never re-creates keys. Any keys // present in a legacy (pre-C4) bundle are counted and ignored in ApplyAsync. diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index 0ce7297d..bef4dd48 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -213,6 +213,18 @@ public class NotificationRepositoryTests : IDisposable Assert.Equal("smtp.example.test", loaded!.Host); } + [Fact] + public async Task AddSmsConfiguration_AndGetById_RoundTrips() + { + var sms = new SmsConfiguration("ACbyid123", "+14155550111"); + await _repository.AddSmsConfigurationAsync(sms); + await _repository.SaveChangesAsync(); + + var loaded = await _repository.GetSmsConfigurationByIdAsync(sms.Id); + Assert.NotNull(loaded); + Assert.Equal("ACbyid123", loaded!.AccountSid); + } + [Fact] public async Task DeleteNotificationList_RemovesEntity() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs index 61ea6b09..06f21ff6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -52,6 +53,16 @@ public sealed class BundleExporterTests : IDisposable services.AddDbContext(opts => opts.UseInMemoryDatabase(dbName)); + // 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(new EphemeralDataProtectionProvider()); + services.AddScoped(sp => new ScadaBridgeDbContext( + sp.GetRequiredService>(), + sp.GetRequiredService())); + // Repositories the resolver pulls from. M8 (B4): the resolver now injects // ISiteRepository to walk the site/data-connection/instance closure, so it // must be registered or the BuildServiceProvider-time graph resolution for @@ -201,6 +212,78 @@ public sealed class BundleExporterTests : IDisposable } } + [Fact] + public async Task ExportAsync_emits_selected_sms_config_with_secret_and_summary_count() + { + // Arrange: seed one SMS provider config with a secret auth token. + int smsId; + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var sms = new SmsConfiguration("AC_export_sid", "+15557654321") + { + AuthToken = "super-secret-token", + MessagingServiceSid = "MG_svc", + ApiBaseUrl = "https://api.twilio.example", + }; + ctx.Set().Add(sms); + await ctx.SaveChangesAsync(); + smsId = sms.Id; + } + + // Act: export selecting only the SMS config. + Stream bundleStream; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var selection = new ExportSelection( + TemplateIds: Array.Empty(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: false, + SmsConfigurationIds: new[] { smsId }); + + bundleStream = await exporter.ExportAsync( + selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + } + + byte[] bundleBytes; + using (var ms = new MemoryStream()) + { + await bundleStream.CopyToAsync(ms); + bundleBytes = ms.ToArray(); + } + + // Assert: manifest summary counts the SMS config; content carries it with + // the auth token preserved inside the SecretsBlock. + var serializer = _provider.GetRequiredService(); + BundleManifest manifest; + using (var ms = new MemoryStream(bundleBytes, writable: false)) + { + manifest = serializer.ReadManifest(ms); + } + Assert.Equal(1, manifest.Summary.SmsConfigs); + + byte[] rawContent; + using (var ms = new MemoryStream(bundleBytes, writable: false)) + { + rawContent = serializer.ReadContentBytes(ms, manifest); + } + var content = serializer.UnpackContent(rawContent, manifest, passphrase: null, encryptor: null); + Assert.Single(content.SmsConfigs); + var dto = content.SmsConfigs[0]; + Assert.Equal("AC_export_sid", dto.AccountSid); + Assert.Equal("+15557654321", dto.FromNumber); + Assert.NotNull(dto.Secrets); + Assert.True(dto.Secrets!.Values.TryGetValue("AuthToken", out var token)); + Assert.Equal("super-secret-token", token); + } + [Fact] public async Task ExportAsync_with_passphrase_produces_encrypted_bundle() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs index 0ec73ed9..5c16c0a3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs @@ -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(new EphemeralDataProtectionProvider()); + services.AddScoped(sp => new ScadaBridgeDbContext( + sp.GetRequiredService>(), + sp.GetRequiredService())); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -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 PackAndLoadSmsBundleAsync(params SmsConfigDto[] smsConfigs) + { + var content = new BundleContentDto( + TemplateFolders: Array.Empty(), + Templates: Array.Empty(), + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()) + { + SmsConfigs = smsConfigs, + }; + + await using var scope = _provider.CreateAsyncScope(); + var serializer = scope.ServiceProvider.GetRequiredService(); + var manifestBuilder = scope.ServiceProvider.GetRequiredService(); + var importer = scope.ServiceProvider.GetRequiredService(); + + 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(), + 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(StringComparer.Ordinal) + { + ["AuthToken"] = "fresh-token", + })); + var sessionId = await PackAndLoadSmsBundleAsync(dto); + + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("SmsConfiguration", "AC_new_sid", ResolutionAction.Add, null) }, + user: "bob"); + } + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var saved = await ctx.Set() + .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(); + ctx.Set().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(StringComparer.Ordinal) + { + ["AuthToken"] = "new-token", + })); + var sessionId = await PackAndLoadSmsBundleAsync(dto); + + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("SmsConfiguration", "AC_existing_sid", ResolutionAction.Overwrite, null) }, + user: "bob"); + } + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + // Exactly one row for this AccountSid — Overwrite updated in place, no dup. + var saved = await ctx.Set() + .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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs index f68f6115..1f5d8b0c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; @@ -49,6 +50,16 @@ public sealed class RoundTripTests : 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(new EphemeralDataProtectionProvider()); + services.AddScoped(sp => new ScadaBridgeDbContext( + sp.GetRequiredService>(), + sp.GetRequiredService())); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -288,4 +299,103 @@ public sealed class RoundTripTests : IDisposable Assert.Equal(0, result.Skipped); Assert.NotEqual(Guid.Empty, result.BundleImportId); } + + [Fact] + public async Task Export_then_import_restores_sms_config_with_secret_intact() + { + // S10b: full export→wipe→import round-trip for an SMS provider config, + // through the real pipeline (export selection → resolver → exporter → + // importer apply). Confirms the config + its secret survive the cycle. + + // ---- 1. Seed one SMS config with an auth token. ---- + int smsId; + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var sms = new SmsConfiguration("AC_roundtrip_sid", "+15551239999") + { + AuthToken = "round-trip-token", + MessagingServiceSid = "MG_rt", + ApiBaseUrl = "https://api.example", + }; + ctx.Set().Add(sms); + await ctx.SaveChangesAsync(); + smsId = sms.Id; + } + + // ---- 2. Export selecting only the SMS config. ---- + byte[] bundleBytes; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var selection = new ExportSelection( + TemplateIds: Array.Empty(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: false, + SmsConfigurationIds: new[] { smsId }); + + var stream = await exporter.ExportAsync( + selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + bundleBytes = ms.ToArray(); + } + Assert.NotEmpty(bundleBytes); + + // ---- 3. Wipe the source config so the import exercises the Add path. ---- + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Set().RemoveRange(ctx.Set()); + await ctx.SaveChangesAsync(); + } + + // ---- 4. Load → preview → apply (every item gets Add). ---- + Guid sessionId; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + using var ms = new MemoryStream(bundleBytes, writable: false); + var session = await importer.LoadAsync(ms, passphrase: null); + sessionId = session.SessionId; + } + + ImportPreview preview; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + preview = await importer.PreviewAsync(sessionId); + } + Assert.Contains(preview.Items, + i => i.EntityType == "SmsConfiguration" && i.Name == "AC_roundtrip_sid" && i.Kind == ConflictKind.New); + + var resolutions = preview.Items + .Select(it => new ImportResolution(it.EntityType, it.Name, ResolutionAction.Add, null)) + .ToList(); + + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, resolutions, user: "bob"); + } + + // ---- 5. The config is back with its secret. ---- + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var restored = await ctx.Set() + .SingleAsync(s => s.AccountSid == "AC_roundtrip_sid"); + Assert.Equal("+15551239999", restored.FromNumber); + Assert.Equal("MG_rt", restored.MessagingServiceSid); + Assert.Equal("round-trip-token", restored.AuthToken); + } + Assert.Equal(1, result.Added); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs index 295a24f2..130adf4b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs @@ -2,6 +2,7 @@ using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; @@ -64,6 +65,17 @@ public sealed class DependencyResolverTests SiteIds: siteIds ?? Array.Empty(), InstanceIds: instanceIds); + private static ExportSelection SelectSmsConfigs(params int[] ids) => new( + TemplateIds: Array.Empty(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: true, + SmsConfigurationIds: ids); + private void StubTemplate(Template t) { _templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any()).Returns(t); @@ -194,6 +206,26 @@ public sealed class DependencyResolverTests Assert.Equal("Validator", result.SharedScripts[0].Name); } + [Fact] + public async Task Resolve_includes_selected_sms_configs() + { + // Mirrors the SMTP/by-id seed path: a selected SMS config id resolves via + // GetSmsConfigurationByIdAsync into ResolvedExport.SmsConfigs, and a manifest + // row (keyed by AccountSid) is emitted alongside it. + var sms = new SmsConfiguration("AC_test_sid", "+15551230000") { Id = 7 }; + _notifications.GetSmsConfigurationByIdAsync(7, Arg.Any()).Returns(sms); + StubAllSharedScripts(); + StubAllExternalSystems(); + StubAllFolders(); + + var result = await Sut().ResolveAsync(SelectSmsConfigs(7), CancellationToken.None); + + Assert.Single(result.SmsConfigs); + Assert.Equal("AC_test_sid", result.SmsConfigs[0].AccountSid); + Assert.Contains(result.ContentManifest, + e => e.Type == "SmsConfiguration" && e.Name == "AC_test_sid"); + } + [Fact] public async Task Resolve_handles_diamond_dependency_without_duplication() {