feat(sms): complete SmsConfig bundle export/import wiring + GetSmsConfigurationByIdAsync (S10b)
This commit is contained in:
@@ -61,6 +61,9 @@ public static class BundleCommands
|
|||||||
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
|
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
|
||||||
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
|
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
|
||||||
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host 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
|
// 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
|
// --api-keys option. Re-create keys and re-grant their method scopes on the
|
||||||
// destination via the admin UI/CLI.
|
// destination via the admin UI/CLI.
|
||||||
@@ -91,6 +94,7 @@ public static class BundleCommands
|
|||||||
cmd.Add(dbConnectionsOption);
|
cmd.Add(dbConnectionsOption);
|
||||||
cmd.Add(notificationListsOption);
|
cmd.Add(notificationListsOption);
|
||||||
cmd.Add(smtpConfigsOption);
|
cmd.Add(smtpConfigsOption);
|
||||||
|
cmd.Add(smsConfigsOption);
|
||||||
cmd.Add(apiMethodsOption);
|
cmd.Add(apiMethodsOption);
|
||||||
cmd.Add(sitesOption);
|
cmd.Add(sitesOption);
|
||||||
cmd.Add(instancesOption);
|
cmd.Add(instancesOption);
|
||||||
@@ -118,7 +122,8 @@ public static class BundleCommands
|
|||||||
Passphrase: passphrase,
|
Passphrase: passphrase,
|
||||||
SourceEnvironment: sourceEnv,
|
SourceEnvironment: sourceEnv,
|
||||||
SiteNames: result.GetValue(sitesOption),
|
SiteNames: result.GetValue(sitesOption),
|
||||||
InstanceNames: result.GetValue(instancesOption));
|
InstanceNames: result.GetValue(instancesOption),
|
||||||
|
SmsConfigurationNames: result.GetValue(smsConfigsOption));
|
||||||
|
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
|||||||
@@ -1744,6 +1744,7 @@ scadabridge --url <url> bundle export --output <path> [--passphrase <string>] [-
|
|||||||
| `--db-connections` | no | Comma-separated database-connection names to include |
|
| `--db-connections` | no | Comma-separated database-connection names to include |
|
||||||
| `--notification-lists` | no | Comma-separated notification-list names to include |
|
| `--notification-lists` | no | Comma-separated notification-list names to include |
|
||||||
| `--smtp-configs` | no | Comma-separated SMTP host 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 |
|
| `--api-methods` | no | Comma-separated API-method names to include |
|
||||||
| `--sites` | no | Comma-separated site identifiers (preferred) or friendly 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) |
|
| `--instances` | no | Comma-separated instance unique-names to include (also pulls their site + bound data connections) |
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ public interface INotificationRepository
|
|||||||
/// <returns>The SMS configuration, or null if not found.</returns>
|
/// <returns>The SMS configuration, or null if not found.</returns>
|
||||||
Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default);
|
Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Gets an SMS configuration by ID.</summary>
|
||||||
|
/// <param name="id">The SMS configuration ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The SMS configuration, or null if not found.</returns>
|
||||||
|
Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Gets all SMS configurations.</summary>
|
/// <summary>Gets all SMS configurations.</summary>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A read-only list of SMS configurations.</returns>
|
/// <returns>A read-only list of SMS configurations.</returns>
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ public sealed record ExportBundleCommand(
|
|||||||
// Defaulted null so every existing positional caller keeps compiling; the
|
// Defaulted null so every existing positional caller keeps compiling; the
|
||||||
// handler normalizes null to "select nothing" (or everything under All=true).
|
// handler normalizes null to "select nothing" (or everything under All=true).
|
||||||
IReadOnlyList<string>? SiteNames = null,
|
IReadOnlyList<string>? SiteNames = null,
|
||||||
IReadOnlyList<string>? InstanceNames = null);
|
IReadOnlyList<string>? 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<string>? SmsConfigurationNames = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle body returned as base64-encoded ZIP. <see cref="ByteCount"/> is the
|
/// Bundle body returned as base64-encoded ZIP. <see cref="ByteCount"/> is the
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ public sealed record BundleSummary(
|
|||||||
// written by older exporters (which omit these JSON properties) deserialize fine.
|
// written by older exporters (which omit these JSON properties) deserialize fine.
|
||||||
int Sites = 0,
|
int Sites = 0,
|
||||||
int DataConnections = 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);
|
||||||
|
|||||||
@@ -17,11 +17,18 @@ public sealed record ExportSelection(
|
|||||||
// Additive (M8 A1): site/instance-scoped export. Defaulted to empty so every
|
// Additive (M8 A1): site/instance-scoped export. Defaulted to empty so every
|
||||||
// existing positional caller keeps compiling; older callers select no sites/instances.
|
// existing positional caller keeps compiling; older callers select no sites/instances.
|
||||||
IReadOnlyList<int>? SiteIds = null,
|
IReadOnlyList<int>? SiteIds = null,
|
||||||
IReadOnlyList<int>? InstanceIds = null)
|
IReadOnlyList<int>? 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<int>? SmsConfigurationIds = null)
|
||||||
{
|
{
|
||||||
/// <summary>Sites selected for site/instance-scoped export (M8). Never null.</summary>
|
/// <summary>Sites selected for site/instance-scoped export (M8). Never null.</summary>
|
||||||
public IReadOnlyList<int> SiteIds { get; init; } = SiteIds ?? Array.Empty<int>();
|
public IReadOnlyList<int> SiteIds { get; init; } = SiteIds ?? Array.Empty<int>();
|
||||||
|
|
||||||
/// <summary>Instances selected for site/instance-scoped export (M8). Never null.</summary>
|
/// <summary>Instances selected for site/instance-scoped export (M8). Never null.</summary>
|
||||||
public IReadOnlyList<int> InstanceIds { get; init; } = InstanceIds ?? Array.Empty<int>();
|
public IReadOnlyList<int> InstanceIds { get; init; } = InstanceIds ?? Array.Empty<int>();
|
||||||
|
|
||||||
|
/// <summary>SMS provider configurations selected for export (S10b). Never null.</summary>
|
||||||
|
public IReadOnlyList<int> SmsConfigurationIds { get; init; } = SmsConfigurationIds ?? Array.Empty<int>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ public class NotificationRepository : INotificationRepository
|
|||||||
public async Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
|
public async Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<SmsConfiguration>().FirstOrDefaultAsync(cancellationToken);
|
=> await _context.Set<SmsConfiguration>().FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
=> await _context.Set<SmsConfiguration>().FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<SmsConfiguration>().ToListAsync(cancellationToken);
|
=> await _context.Set<SmsConfiguration>().ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -2721,6 +2721,8 @@ public class ManagementActor : ReceiveActor
|
|||||||
var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync();
|
var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync();
|
||||||
var notificationLists = await notifRepo.GetAllNotificationListsAsync();
|
var notificationLists = await notifRepo.GetAllNotificationListsAsync();
|
||||||
var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync();
|
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.
|
// Inbound API keys are not transported between environments (re-arch C4); only methods.
|
||||||
var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
|
var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
|
||||||
// M8 (B4): site/instance-scoped selection. Sites match on SiteIdentifier
|
// 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
|
// M8 (B4): site/instance-scoped selection. Under All=true include every
|
||||||
// site + instance, mirroring how All includes every template/etc.
|
// site + instance, mirroring how All includes every template/etc.
|
||||||
SiteIds: ResolveSiteIds(),
|
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<IBundleExporter>();
|
var exporter = sp.GetRequiredService<IBundleExporter>();
|
||||||
await using var stream = await exporter.ExportAsync(
|
await using var stream = await exporter.ExportAsync(
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ public class SiteNotificationRepository : INotificationRepository
|
|||||||
public Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
|
public Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ public sealed class BundleExporter : IBundleExporter
|
|||||||
Sites = resolved.Sites,
|
Sites = resolved.Sites,
|
||||||
DataConnections = resolved.DataConnections,
|
DataConnections = resolved.DataConnections,
|
||||||
Instances = resolved.Instances,
|
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);
|
var contentDto = _entitySerializer.ToBundleContent(aggregate);
|
||||||
|
|
||||||
@@ -112,7 +117,11 @@ public sealed class BundleExporter : IBundleExporter
|
|||||||
// are 0 today and become correct once those waves land.
|
// are 0 today and become correct once those waves land.
|
||||||
Sites: contentDto.Sites.Count,
|
Sites: contentDto.Sites.Count,
|
||||||
DataConnections: contentDto.DataConnections.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
|
// 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both
|
||||||
// ContentHash and EncryptionMetadata against the bytes it actually
|
// ContentHash and EncryptionMetadata against the bytes it actually
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ public sealed class DependencyResolver
|
|||||||
if (sm is not null) smtpConfigs[sm.Id] = sm;
|
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<int, SmsConfiguration>();
|
||||||
|
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 keys are intentionally NOT resolved into the bundle: per the
|
||||||
// inbound-API-key re-architecture (C4) keys are not transported between
|
// inbound-API-key re-architecture (C4) keys are not transported between
|
||||||
// environments. Only API methods travel.
|
// environments. Only API methods travel.
|
||||||
@@ -234,6 +243,7 @@ public sealed class DependencyResolver
|
|||||||
dbConnections.Values,
|
dbConnections.Values,
|
||||||
notificationLists.Values,
|
notificationLists.Values,
|
||||||
smtpConfigs.Values,
|
smtpConfigs.Values,
|
||||||
|
smsConfigs.Values,
|
||||||
apiMethods.Values,
|
apiMethods.Values,
|
||||||
orderedSites,
|
orderedSites,
|
||||||
orderedDataConnections,
|
orderedDataConnections,
|
||||||
@@ -256,6 +266,9 @@ public sealed class DependencyResolver
|
|||||||
Sites = orderedSites,
|
Sites = orderedSites,
|
||||||
DataConnections = orderedDataConnections,
|
DataConnections = orderedDataConnections,
|
||||||
Instances = orderedInstances,
|
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<DatabaseConnectionDefinition> dbConnections,
|
IEnumerable<DatabaseConnectionDefinition> dbConnections,
|
||||||
IEnumerable<NotificationList> notificationLists,
|
IEnumerable<NotificationList> notificationLists,
|
||||||
IEnumerable<SmtpConfiguration> smtpConfigs,
|
IEnumerable<SmtpConfiguration> smtpConfigs,
|
||||||
|
IEnumerable<SmsConfiguration> smsConfigs,
|
||||||
IEnumerable<ApiMethod> apiMethods,
|
IEnumerable<ApiMethod> apiMethods,
|
||||||
IReadOnlyList<Site> sites,
|
IReadOnlyList<Site> sites,
|
||||||
IReadOnlyList<DataConnection> dataConnections,
|
IReadOnlyList<DataConnection> dataConnections,
|
||||||
@@ -604,6 +618,12 @@ public sealed class DependencyResolver
|
|||||||
{
|
{
|
||||||
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>()));
|
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>()));
|
||||||
}
|
}
|
||||||
|
// 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<string>()));
|
||||||
|
}
|
||||||
// Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries.
|
// Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries.
|
||||||
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
|
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,4 +56,12 @@ public sealed record ResolvedExport(
|
|||||||
/// properties.
|
/// properties.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
|
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SMS provider configurations in the closure, ordered by
|
||||||
|
/// <see cref="SmsConfiguration.AccountSid"/> (S10b). Mirrors
|
||||||
|
/// <see cref="SmtpConfigs"/>; init-only with an empty default so callers that
|
||||||
|
/// only resolve other selections keep compiling.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<SmsConfiguration> SmsConfigs { get; init; } = Array.Empty<SmsConfiguration>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,40 @@ public sealed class ArtifactDiff
|
|||||||
return BuildItem("SmtpConfiguration", incoming.Host, changes);
|
return BuildItem("SmtpConfiguration", incoming.Host, changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares an incoming SMS provider configuration against the existing one in
|
||||||
|
/// the database (S10b). Mirrors <see cref="CompareSmtpConfiguration"/>: keyed by
|
||||||
|
/// the natural key <c>AccountSid</c>, with the auth token diffed presence-only
|
||||||
|
/// (it lives in <see cref="SecretsBlock"/>, never compared by value).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="incoming">The incoming SMS configuration from the bundle.</param>
|
||||||
|
/// <param name="existing">The existing SMS configuration in the database, or null if new.</param>
|
||||||
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||||
|
public ImportPreviewItem CompareSmsConfiguration(SmsConfigDto incoming, SmsConfiguration? existing)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(incoming);
|
||||||
|
if (existing is null) return New("SmsConfiguration", incoming.AccountSid);
|
||||||
|
|
||||||
|
var changes = new List<FieldChange>();
|
||||||
|
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 ? "<present>" : null,
|
||||||
|
incomingHasSecret ? "<present>" : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildItem("SmsConfiguration", incoming.AccountSid, changes);
|
||||||
|
}
|
||||||
|
|
||||||
// CompareApiKey was removed in re-arch C4: inbound API keys are not transported
|
// CompareApiKey was removed in re-arch C4: inbound API keys are not transported
|
||||||
// between environments, so the import preview never diffs keys.
|
// between environments, so the import preview never diffs keys.
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,15 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
|
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 ----
|
// ---- ApiKeys ----
|
||||||
// Inbound API keys are not transported between environments (re-arch C4).
|
// 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
|
// 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 ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||||
await ApplyNotificationListsAsync(content.NotificationLists, 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 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
|
// 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.
|
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
|
||||||
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
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;
|
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<SmsConfigDto> 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
|
// ApplyApiKeysAsync was removed in re-arch C4: inbound API keys are not
|
||||||
// transported between environments, so a bundle never re-creates keys. Any keys
|
// 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.
|
// present in a legacy (pre-C4) bundle are counted and ignored in ApplyAsync.
|
||||||
|
|||||||
@@ -213,6 +213,18 @@ public class NotificationRepositoryTests : IDisposable
|
|||||||
Assert.Equal("smtp.example.test", loaded!.Host);
|
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]
|
[Fact]
|
||||||
public async Task DeleteNotificationList_RemovesEntity()
|
public async Task DeleteNotificationList_RemovesEntity()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -52,6 +53,16 @@ public sealed class BundleExporterTests : IDisposable
|
|||||||
services.AddDbContext<ScadaBridgeDbContext>(opts =>
|
services.AddDbContext<ScadaBridgeDbContext>(opts =>
|
||||||
opts.UseInMemoryDatabase(dbName));
|
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<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||||
|
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||||
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
// Repositories the resolver pulls from. M8 (B4): the resolver now injects
|
// Repositories the resolver pulls from. M8 (B4): the resolver now injects
|
||||||
// ISiteRepository to walk the site/data-connection/instance closure, so it
|
// ISiteRepository to walk the site/data-connection/instance closure, so it
|
||||||
// must be registered or the BuildServiceProvider-time graph resolution for
|
// 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<ScadaBridgeDbContext>();
|
||||||
|
var sms = new SmsConfiguration("AC_export_sid", "+15557654321")
|
||||||
|
{
|
||||||
|
AuthToken = "super-secret-token",
|
||||||
|
MessagingServiceSid = "MG_svc",
|
||||||
|
ApiBaseUrl = "https://api.twilio.example",
|
||||||
|
};
|
||||||
|
ctx.Set<SmsConfiguration>().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<IBundleExporter>();
|
||||||
|
var selection = new ExportSelection(
|
||||||
|
TemplateIds: Array.Empty<int>(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
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<BundleSerializer>();
|
||||||
|
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]
|
[Fact]
|
||||||
public async Task ExportAsync_with_passphrase_produces_encrypted_bundle()
|
public async Task ExportAsync_with_passphrase_produces_encrypted_bundle()
|
||||||
{
|
{
|
||||||
|
|||||||
+147
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -61,6 +62,16 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.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<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
@@ -768,6 +779,142 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
Assert.Equal(1, result.Overwritten);
|
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
|
// Re-arch C4 backward-compat: a LEGACY (pre-C4) bundle still carries an
|
||||||
// ApiKeys section. The importer must ignore those keys gracefully — it must
|
// ApiKeys section. The importer must ignore those keys gracefully — it must
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -49,6 +50,16 @@ public sealed class RoundTripTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.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<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
@@ -288,4 +299,103 @@ public sealed class RoundTripTests : IDisposable
|
|||||||
Assert.Equal(0, result.Skipped);
|
Assert.Equal(0, result.Skipped);
|
||||||
Assert.NotEqual(Guid.Empty, result.BundleImportId);
|
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<ScadaBridgeDbContext>();
|
||||||
|
var sms = new SmsConfiguration("AC_roundtrip_sid", "+15551239999")
|
||||||
|
{
|
||||||
|
AuthToken = "round-trip-token",
|
||||||
|
MessagingServiceSid = "MG_rt",
|
||||||
|
ApiBaseUrl = "https://api.example",
|
||||||
|
};
|
||||||
|
ctx.Set<SmsConfiguration>().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<IBundleExporter>();
|
||||||
|
var selection = new ExportSelection(
|
||||||
|
TemplateIds: Array.Empty<int>(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
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<ScadaBridgeDbContext>();
|
||||||
|
ctx.Set<SmsConfiguration>().RemoveRange(ctx.Set<SmsConfiguration>());
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. Load → preview → apply (every item gets Add). ----
|
||||||
|
Guid sessionId;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
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<IBundleImporter>();
|
||||||
|
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<IBundleImporter>();
|
||||||
|
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<ScadaBridgeDbContext>();
|
||||||
|
var restored = await ctx.Set<SmsConfiguration>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using NSubstitute;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
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.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
@@ -64,6 +65,17 @@ public sealed class DependencyResolverTests
|
|||||||
SiteIds: siteIds ?? Array.Empty<int>(),
|
SiteIds: siteIds ?? Array.Empty<int>(),
|
||||||
InstanceIds: instanceIds);
|
InstanceIds: instanceIds);
|
||||||
|
|
||||||
|
private static ExportSelection SelectSmsConfigs(params int[] ids) => new(
|
||||||
|
TemplateIds: Array.Empty<int>(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
IncludeDependencies: true,
|
||||||
|
SmsConfigurationIds: ids);
|
||||||
|
|
||||||
private void StubTemplate(Template t)
|
private void StubTemplate(Template t)
|
||||||
{
|
{
|
||||||
_templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any<CancellationToken>()).Returns(t);
|
_templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any<CancellationToken>()).Returns(t);
|
||||||
@@ -194,6 +206,26 @@ public sealed class DependencyResolverTests
|
|||||||
Assert.Equal("Validator", result.SharedScripts[0].Name);
|
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<CancellationToken>()).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]
|
[Fact]
|
||||||
public async Task Resolve_handles_diamond_dependency_without_duplication()
|
public async Task Resolve_handles_diamond_dependency_without_duplication()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user