feat(sms): export wizard SMS-config selection (S10c)
This commit is contained in:
@@ -138,6 +138,13 @@
|
|||||||
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
@* S10c: SMS provider configs, mirroring the SMTP section above. Labelled by
|
||||||
|
AccountSid (the bundle key); the secret AuthToken is never rendered. *@
|
||||||
|
<fieldset class="mb-4" data-testid="group-sms-configs">
|
||||||
|
<legend class="h6">SMS Configurations</legend>
|
||||||
|
@RenderCheckboxList(_smsConfigs, s => s.Id, s => s.AccountSid, _selectedSmsConfigs)
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="mb-4" data-testid="group-api-methods">
|
<fieldset class="mb-4" data-testid="group-api-methods">
|
||||||
<legend class="h6">API Methods</legend>
|
<legend class="h6">API Methods</legend>
|
||||||
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
||||||
@@ -354,6 +361,11 @@
|
|||||||
{
|
{
|
||||||
<li>SmtpConfig: @s.Host</li>
|
<li>SmtpConfig: @s.Host</li>
|
||||||
}
|
}
|
||||||
|
@* S10c: SMS configs in the closure, mirroring SmtpConfig above; AccountSid only, never AuthToken. *@
|
||||||
|
@foreach (var s in _resolved.SmsConfigs.OrderBy(s => s.AccountSid, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>SmsConfig: @s.AccountSid</li>
|
||||||
|
}
|
||||||
@* Inbound API keys are not transported (re-arch C4) — methods only. *@
|
@* Inbound API keys are not transported (re-arch C4) — methods only. *@
|
||||||
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -447,8 +459,8 @@
|
|||||||
{
|
{
|
||||||
<div class="alert alert-warning" role="alert" data-testid="secrets-warning">
|
<div class="alert alert-warning" role="alert" data-testid="secrets-warning">
|
||||||
<strong>@_secretCount</strong> secret @(_secretCount == 1 ? "field" : "fields")
|
<strong>@_secretCount</strong> secret @(_secretCount == 1 ? "field" : "fields")
|
||||||
will be encrypted (external-system credentials, SMTP credentials, and database
|
will be encrypted (external-system credentials, SMTP credentials, SMS auth
|
||||||
connection strings).
|
tokens, and database connection strings).
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -495,7 +507,7 @@
|
|||||||
<div class="alert alert-danger" data-testid="unencrypted-confirm">
|
<div class="alert alert-danger" data-testid="unencrypted-confirm">
|
||||||
<strong>Unencrypted export</strong> — the bundle will contain all secret fields
|
<strong>Unencrypted export</strong> — the bundle will contain all secret fields
|
||||||
in plaintext. Anyone with the file can read external-system credentials, SMTP
|
in plaintext. Anyone with the file can read external-system credentials, SMTP
|
||||||
passwords, and database connection strings. The audit log will record this as
|
passwords, SMS auth tokens, and database connection strings. The audit log will record this as
|
||||||
<code>UnencryptedBundleExport</code>.
|
<code>UnencryptedBundleExport</code>.
|
||||||
<div class="mt-2 d-flex gap-2">
|
<div class="mt-2 d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
|
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ public partial class TransportExport : ComponentBase
|
|||||||
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
||||||
private List<NotificationList> _notificationLists = new();
|
private List<NotificationList> _notificationLists = new();
|
||||||
private List<SmtpConfiguration> _smtpConfigs = new();
|
private List<SmtpConfiguration> _smtpConfigs = new();
|
||||||
|
// S10c: SMS provider configs, mirroring _smtpConfigs. Keyed by AccountSid in the
|
||||||
|
// bundle; the wizard selects by entity id (same as SMTP) and never surfaces AuthToken.
|
||||||
|
private List<SmsConfiguration> _smsConfigs = new();
|
||||||
// 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.
|
||||||
private List<ApiMethod> _apiMethods = new();
|
private List<ApiMethod> _apiMethods = new();
|
||||||
// M8 (E1): site/instance-scoped export. Sites are listed flat; each site's
|
// M8 (E1): site/instance-scoped export. Sites are listed flat; each site's
|
||||||
@@ -90,6 +93,8 @@ public partial class TransportExport : ComponentBase
|
|||||||
private readonly HashSet<int> _selectedDbConnections = new();
|
private readonly HashSet<int> _selectedDbConnections = new();
|
||||||
private readonly HashSet<int> _selectedNotificationLists = new();
|
private readonly HashSet<int> _selectedNotificationLists = new();
|
||||||
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
||||||
|
// S10c: SMS provider config selection, mirroring _selectedSmtpConfigs.
|
||||||
|
private readonly HashSet<int> _selectedSmsConfigs = new();
|
||||||
// No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
|
// No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
|
||||||
private readonly HashSet<int> _selectedApiMethods = new();
|
private readonly HashSet<int> _selectedApiMethods = new();
|
||||||
// M8 (E1): site/instance selection backed by entity primary keys, matching the
|
// M8 (E1): site/instance selection backed by entity primary keys, matching the
|
||||||
@@ -138,6 +143,8 @@ public partial class TransportExport : ComponentBase
|
|||||||
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
|
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
|
||||||
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
|
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
|
||||||
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
||||||
|
// S10c: SMS provider configs, mirroring the SMTP load path above.
|
||||||
|
_smsConfigs = (await NotificationRepo.GetAllSmsConfigurationsAsync()).ToList();
|
||||||
// Inbound API keys are not transported (re-arch C4) — only methods are loaded.
|
// Inbound API keys are not transported (re-arch C4) — only methods are loaded.
|
||||||
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
||||||
|
|
||||||
@@ -199,6 +206,7 @@ public partial class TransportExport : ComponentBase
|
|||||||
|| _selectedDbConnections.Count > 0
|
|| _selectedDbConnections.Count > 0
|
||||||
|| _selectedNotificationLists.Count > 0
|
|| _selectedNotificationLists.Count > 0
|
||||||
|| _selectedSmtpConfigs.Count > 0
|
|| _selectedSmtpConfigs.Count > 0
|
||||||
|
|| _selectedSmsConfigs.Count > 0
|
||||||
|| _selectedApiMethods.Count > 0
|
|| _selectedApiMethods.Count > 0
|
||||||
|| _selectedSites.Count > 0
|
|| _selectedSites.Count > 0
|
||||||
|| _selectedInstances.Count > 0;
|
|| _selectedInstances.Count > 0;
|
||||||
@@ -247,6 +255,9 @@ public partial class TransportExport : ComponentBase
|
|||||||
// already-selected owning site.
|
// already-selected owning site.
|
||||||
SiteIds = _selectedSites.ToList(),
|
SiteIds = _selectedSites.ToList(),
|
||||||
InstanceIds = _selectedInstances.ToList(),
|
InstanceIds = _selectedInstances.ToList(),
|
||||||
|
// S10c: SMS provider config ids feed the resolver's SmsConfigurationIds,
|
||||||
|
// exactly as SmtpConfigurationIds above (init-only, trailing additive shape).
|
||||||
|
SmsConfigurationIds = _selectedSmsConfigs.ToList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +325,11 @@ public partial class TransportExport : ComponentBase
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(smtp.Credentials)) count++;
|
if (!string.IsNullOrEmpty(smtp.Credentials)) count++;
|
||||||
}
|
}
|
||||||
|
// S10c: SMS provider AuthToken is a secret, mirroring SMTP Credentials above.
|
||||||
|
foreach (var sms in resolved.SmsConfigs)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(sms.AuthToken)) count++;
|
||||||
|
}
|
||||||
foreach (var db in resolved.DatabaseConnections)
|
foreach (var db in resolved.DatabaseConnections)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
|
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
|
||||||
@@ -440,6 +456,7 @@ public partial class TransportExport : ComponentBase
|
|||||||
_selectedDbConnections.Clear();
|
_selectedDbConnections.Clear();
|
||||||
_selectedNotificationLists.Clear();
|
_selectedNotificationLists.Clear();
|
||||||
_selectedSmtpConfigs.Clear();
|
_selectedSmtpConfigs.Clear();
|
||||||
|
_selectedSmsConfigs.Clear();
|
||||||
_selectedApiMethods.Clear();
|
_selectedApiMethods.Clear();
|
||||||
_selectedSites.Clear();
|
_selectedSites.Clear();
|
||||||
_selectedInstances.Clear();
|
_selectedInstances.Clear();
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ public class TransportExportPageTests : BunitContext
|
|||||||
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
|
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
|
||||||
var notifList = new NotificationList("Ops") { Id = 40 };
|
var notifList = new NotificationList("Ops") { Id = 40 };
|
||||||
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
|
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
|
||||||
|
// S10c: an SMS provider config, mirroring the SMTP entry above. Labelled by
|
||||||
|
// AccountSid; the AuthToken is a secret and must never reach the markup.
|
||||||
|
var sms = new SmsConfiguration("AC123", "+15551230001") { Id = 60, AuthToken = "super-secret-token" };
|
||||||
// Inbound API keys are not transported between environments (re-arch C4) — the
|
// Inbound API keys are not transported between environments (re-arch C4) — the
|
||||||
// export page no longer offers a keys selection list, only API methods.
|
// export page no longer offers a keys selection list, only API methods.
|
||||||
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
|
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
|
||||||
@@ -147,6 +150,8 @@ public class TransportExportPageTests : BunitContext
|
|||||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
|
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
|
||||||
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
|
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
|
||||||
|
_notificationRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<SmsConfiguration>>(new List<SmsConfiguration> { sms }));
|
||||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
|
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
|
||||||
|
|
||||||
@@ -163,6 +168,7 @@ public class TransportExportPageTests : BunitContext
|
|||||||
"group-db-connections",
|
"group-db-connections",
|
||||||
"group-notification-lists",
|
"group-notification-lists",
|
||||||
"group-smtp-configs",
|
"group-smtp-configs",
|
||||||
|
"group-sms-configs",
|
||||||
"group-api-methods",
|
"group-api-methods",
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
@@ -180,6 +186,9 @@ public class TransportExportPageTests : BunitContext
|
|||||||
Assert.Contains("Hist", cut.Markup);
|
Assert.Contains("Hist", cut.Markup);
|
||||||
Assert.Contains("Ops", cut.Markup);
|
Assert.Contains("Ops", cut.Markup);
|
||||||
Assert.Contains("smtp.example.com", cut.Markup);
|
Assert.Contains("smtp.example.com", cut.Markup);
|
||||||
|
// S10c: the SMS config shows its AccountSid label, never its secret AuthToken.
|
||||||
|
Assert.Contains("AC123", cut.Markup);
|
||||||
|
Assert.DoesNotContain("super-secret-token", cut.Markup);
|
||||||
Assert.Contains("CreateOrder", cut.Markup);
|
Assert.Contains("CreateOrder", cut.Markup);
|
||||||
|
|
||||||
// Next button is disabled while no selection exists.
|
// Next button is disabled while no selection exists.
|
||||||
@@ -427,6 +436,89 @@ public class TransportExportPageTests : BunitContext
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 8 (S10c): the Step 1 SMS section renders available SMS configs by
|
||||||
|
// AccountSid (never AuthToken), and ticking one flows its entity id into
|
||||||
|
// ExportSelection.SmsConfigurationIds — mirroring the SMTP-by-Host contract.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task Step4_export_carries_selected_sms_configuration_ids()
|
||||||
|
{
|
||||||
|
var sms = new SmsConfiguration("AC777", "+15559990001") { Id = 77, AuthToken = "do-not-leak" };
|
||||||
|
_notificationRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<SmsConfiguration>>(new List<SmsConfiguration> { sms }));
|
||||||
|
// The resolver fetches the selected SMS config by id when building the closure.
|
||||||
|
_notificationRepo.GetSmsConfigurationByIdAsync(77, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<SmsConfiguration?>(sms));
|
||||||
|
|
||||||
|
_exporter
|
||||||
|
.ExportAsync(
|
||||||
|
Arg.Any<ExportSelection>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string?>(),
|
||||||
|
Arg.Any<CancellationToken>())
|
||||||
|
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
|
||||||
|
|
||||||
|
var cut = Render<TransportExportPage>();
|
||||||
|
cut.WaitForState(() => cut.Markup.Contains("AC777"));
|
||||||
|
|
||||||
|
// The SMS section renders the available config by AccountSid; its secret
|
||||||
|
// AuthToken is never rendered.
|
||||||
|
Assert.NotNull(cut.Find("[data-testid='group-sms-configs']"));
|
||||||
|
Assert.DoesNotContain("do-not-leak", cut.Markup);
|
||||||
|
|
||||||
|
// Tick the SMS config checkbox (id pattern mirrors the SMTP flat-list wiring).
|
||||||
|
cut.Find("#chk-SmsConfiguration-77").Change(true);
|
||||||
|
|
||||||
|
// Step 1 → 2 → 3 → export.
|
||||||
|
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
|
||||||
|
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
|
||||||
|
|
||||||
|
cut.Find("#passphrase").Input("hunter2hunter2");
|
||||||
|
cut.Find("#passphrase-confirm").Input("hunter2hunter2");
|
||||||
|
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
|
||||||
|
|
||||||
|
await _exporter.Received(1).ExportAsync(
|
||||||
|
Arg.Is<ExportSelection>(s => s.SmsConfigurationIds.Contains(77)),
|
||||||
|
"alice",
|
||||||
|
"test-cluster",
|
||||||
|
"hunter2hunter2",
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 9 (S10c): CountSecrets counts a populated SMS AuthToken, mirroring
|
||||||
|
// the SMTP Credentials contribution to the Step 3 secrets banner.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void CountSecrets_includes_sms_configuration_auth_token()
|
||||||
|
{
|
||||||
|
var smsWithToken = new SmsConfiguration("AC1", "+15551110000") { Id = 1, AuthToken = "tok" };
|
||||||
|
var smsNoToken = new SmsConfiguration("AC2", "+15552220000") { Id = 2, AuthToken = null };
|
||||||
|
|
||||||
|
var resolved = new ResolvedExport(
|
||||||
|
TemplateFolders: Array.Empty<TemplateFolder>(),
|
||||||
|
Templates: Array.Empty<Template>(),
|
||||||
|
SharedScripts: Array.Empty<SharedScript>(),
|
||||||
|
ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
|
||||||
|
ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
|
||||||
|
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
|
||||||
|
NotificationLists: Array.Empty<NotificationList>(),
|
||||||
|
SmtpConfigs: Array.Empty<SmtpConfiguration>(),
|
||||||
|
ApiMethods: Array.Empty<ApiMethod>(),
|
||||||
|
ContentManifest: Array.Empty<ManifestContentEntry>())
|
||||||
|
{
|
||||||
|
SmsConfigs = new List<SmsConfiguration> { smsWithToken, smsNoToken },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only the config with a non-empty AuthToken counts.
|
||||||
|
Assert.Equal(1, TransportExportPage.CountSecrets(resolved));
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Static helpers — exercised directly so the file-naming + secret-count
|
// Static helpers — exercised directly so the file-naming + secret-count
|
||||||
// contract is unit-pinned independently of the rendering surface.
|
// contract is unit-pinned independently of the rendering surface.
|
||||||
|
|||||||
Reference in New Issue
Block a user