9e243493fb
Applies the family-wide admin-UI cleanup playbook to the Central UI so the Blazor surfaces stop diverging from the shared kit: buttons are grouped rather than individually sized, long cell values are contained instead of widening tables, and hard-coded colours give way to theme tokens. The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell, which the kit emits as an inline style on the shell root. Being a descendant of <html>, it beat the [data-bs-theme="dark"] override for the entire app, so the dark accent had never rendered. Declaring --accent in site.css :root instead lets both schemes resolve; light is unchanged because the value already matched the kit's light default. Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so that block is deleted here rather than duplicated. Verified byte-identical before removal; the repo now declares no --bs-btn-* anywhere. NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages, SiteCallsReport) were additionally refactored from holding the selected record to holding its id and re-resolving from the current page each render, with the resolve doubling as the visibility gate. A background refresh that drops the row now closes the modal instead of showing a stale snapshot. This is a behaviour change and is called out rather than buried: a full-suite run turned up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack (GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler was disposed between render and click — a window the previous field-held record made structurally impossible. Treat the modal lifecycle here as unreviewed. Build 0/0; suite green apart from that one intermittent failure.
609 lines
34 KiB
C#
609 lines
34 KiB
C#
using System.Security.Claims;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
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;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Export;
|
|
using TransportExportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportExport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
|
|
|
|
/// <summary>
|
|
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
|
|
///
|
|
/// <para>
|
|
/// Covers the four contract points the design plan calls out:
|
|
/// </para>
|
|
/// <list type="number">
|
|
/// <item>Step 1 renders the template tree plus every flat artifact group.</item>
|
|
/// <item>Step 2 surfaces the dependency-resolved closure (seed vs auto-included).</item>
|
|
/// <item>Step 4 invokes <see cref="IBundleExporter.ExportAsync"/> with the user's
|
|
/// selected ids and authenticated identity.</item>
|
|
/// <item>The page-level <c>RequireDesign</c> policy denies a user lacking the
|
|
/// Design role (router enforcement; the component code-behind never sees
|
|
/// the request).</item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// JS interop is set to loose mode so the TreeView's sessionStorage round-trip
|
|
/// and the transport-bundle download interop don't need stubs per test. The
|
|
/// <c>scadabridgeTransport.downloadBundle</c> call returns void — loose mode is
|
|
/// the lighter wiring than re-stubbing it in every export-path test.
|
|
/// </para>
|
|
/// </summary>
|
|
public class TransportExportPageTests : BunitContext
|
|
{
|
|
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
|
private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
|
|
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
|
|
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
|
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
|
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
|
|
|
|
public TransportExportPageTests()
|
|
{
|
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
|
|
|
// Default empty repos so OnInitializedAsync doesn't throw — individual
|
|
// tests override the bits they care about.
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
|
_templateRepo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
|
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript>()));
|
|
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(new List<ExternalSystemDefinition>()));
|
|
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(new List<DatabaseConnectionDefinition>()));
|
|
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList>()));
|
|
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
|
|
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
|
// Empty site/instance defaults — the M8 site/instance picker calls these on load
|
|
// and the DependencyResolver fans out over sites on resolve.
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
|
_siteRepo.GetInstancesBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
|
|
|
|
Services.AddSingleton(_templateRepo);
|
|
Services.AddSingleton(_externalRepo);
|
|
Services.AddSingleton(_notificationRepo);
|
|
Services.AddSingleton(_inboundApiRepo);
|
|
Services.AddSingleton(_siteRepo);
|
|
Services.AddSingleton(_exporter);
|
|
// DependencyResolver is sealed but its dependencies are the repositories above
|
|
// (template/external/notification/inbound + ISiteRepository for M8 site/instance
|
|
// export) — registering the concrete type is enough.
|
|
Services.AddSingleton<DependencyResolver>();
|
|
Services.AddSingleton<IOptions<TransportOptions>>(
|
|
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
|
{
|
|
SourceEnvironment = "test-cluster",
|
|
}));
|
|
|
|
var principal = BuildPrincipal("alice", "Designer");
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
|
Services.AddAuthorizationCore();
|
|
}
|
|
|
|
private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
|
|
{
|
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, username) };
|
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 1: Step 1 renders the template tree and every flat artifact group.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public void Renders_step1_with_template_tree_and_artifact_checkboxes()
|
|
{
|
|
// A single template + a couple of artifacts so the lists aren't empty.
|
|
var template = new Template("Pump") { Id = 1 };
|
|
var script = new SharedScript("Helpers", "// noop") { Id = 10 };
|
|
var externalSystem = new ExternalSystemDefinition("ERP", "https://erp.example.com", "ApiKey")
|
|
{
|
|
Id = 20,
|
|
};
|
|
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
|
|
var notifList = new NotificationList("Ops") { Id = 40 };
|
|
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
|
|
// export page no longer offers a keys selection list, only API methods.
|
|
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
|
|
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
|
|
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript> { script }));
|
|
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(
|
|
new List<ExternalSystemDefinition> { externalSystem }));
|
|
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(
|
|
new List<DatabaseConnectionDefinition> { db }));
|
|
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
|
|
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
|
.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>())
|
|
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
|
|
|
|
var cut = Render<TransportExportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
|
|
|
// All flat groups (plus templates) are present. There is intentionally NO
|
|
// API-keys group: inbound API keys are not transported (re-arch C4).
|
|
foreach (var groupId in new[]
|
|
{
|
|
"group-templates",
|
|
"group-shared-scripts",
|
|
"group-external-systems",
|
|
"group-db-connections",
|
|
"group-notification-lists",
|
|
"group-smtp-configs",
|
|
"group-sms-configs",
|
|
"group-api-methods",
|
|
})
|
|
{
|
|
Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
|
|
}
|
|
|
|
// The API-keys selection group is gone. The explanatory note that used to sit
|
|
// beside it moved into docs/requirements/Component-Transport.md (UI cleanup
|
|
// sweep 2026-08-11) — the absence of the group is the assertion that matters.
|
|
Assert.Empty(cut.FindAll("[data-testid='group-api-keys']"));
|
|
|
|
// Sanity: each artifact shows its label.
|
|
Assert.Contains("Helpers", cut.Markup);
|
|
Assert.Contains("ERP", cut.Markup);
|
|
Assert.Contains("Hist", cut.Markup);
|
|
Assert.Contains("Ops", 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);
|
|
|
|
// Next button is disabled while no selection exists.
|
|
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
|
|
Assert.True(next.HasAttribute("disabled"));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 2: Step 2 shows resolved dependencies — auto-included templates pulled
|
|
// in because a seed template composes them.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public async Task Step2_shows_resolved_dependencies_after_clicking_next()
|
|
{
|
|
// Seed template "Pump" composes "Motor". The user selects Pump only;
|
|
// the resolver pulls Motor in transitively.
|
|
var pump = new Template("Pump") { Id = 1 };
|
|
pump.Compositions.Add(new TemplateComposition("MotorSlot")
|
|
{
|
|
Id = 100,
|
|
ComposedTemplateId = 2,
|
|
});
|
|
var motor = new Template("Motor") { Id = 2 };
|
|
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { pump, motor }));
|
|
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<Template?>(pump));
|
|
_templateRepo.GetTemplateWithChildrenAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<Template?>(motor));
|
|
|
|
var cut = Render<TransportExportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
|
|
|
// The template-tree renders a checkbox per node — tick the one whose
|
|
// sibling label is "Pump". (TemplateFolderTree uses .tv-checkbox.)
|
|
var pumpRow = cut.FindAll("li[role='treeitem']")
|
|
.First(li => li.TextContent.Contains("Pump"));
|
|
var checkbox = pumpRow.QuerySelector("input.tv-checkbox");
|
|
Assert.NotNull(checkbox);
|
|
checkbox!.Change(true);
|
|
|
|
// Click "Next" to advance to Step 2; the resolver call is awaited
|
|
// inside GoToReviewAsync — bUnit's WaitForState handles the re-render.
|
|
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
|
|
await next.ClickAsync(new());
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
// Step 2 shows the seed/auto split — Motor lands under "Auto-included".
|
|
var autoGroup = cut.Find("[data-testid='auto-group']");
|
|
Assert.Contains("Motor", autoGroup.TextContent);
|
|
});
|
|
|
|
var seedGroup = cut.Find("[data-testid='seed-group']");
|
|
Assert.Contains("Pump", seedGroup.TextContent);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync
|
|
// is invoked with the user-selected ids and the authenticated identity.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
|
|
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<Template?>(template));
|
|
|
|
// Exporter returns a tiny in-memory bundle stream.
|
|
_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("Pump"));
|
|
|
|
// Tick Pump.
|
|
var pumpCheckbox = cut.FindAll("li[role='treeitem']")
|
|
.First(li => li.TextContent.Contains("Pump"))
|
|
.QuerySelector("input.tv-checkbox");
|
|
Assert.NotNull(pumpCheckbox);
|
|
pumpCheckbox!.Change(true);
|
|
|
|
// Advance Step 1 → 2.
|
|
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
|
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
|
|
|
|
// Advance Step 2 → 3.
|
|
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
|
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
|
|
|
|
// Fill matching passphrases. The inputs are wired with @bind:event="oninput",
|
|
// so use Input() rather than Change() to fire the right event.
|
|
var passphraseInput = cut.Find("#passphrase");
|
|
passphraseInput.Input("hunter2hunter2");
|
|
var confirmInput = cut.Find("#passphrase-confirm");
|
|
confirmInput.Input("hunter2hunter2");
|
|
|
|
// Click "Export" — the only enabled button labeled "Export" at this step.
|
|
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
|
|
|
|
// Step 4 renders the download summary once ExportAsync resolves.
|
|
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
|
|
|
|
await _exporter.Received(1).ExportAsync(
|
|
Arg.Is<ExportSelection>(s =>
|
|
s.TemplateIds.Contains(1)
|
|
&& s.IncludeDependencies),
|
|
"alice",
|
|
"test-cluster",
|
|
"hunter2hunter2",
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 4: A user without the Design role fails the RequireDesign policy.
|
|
// The router enforces [Authorize(Policy=...)] at request time — bUnit
|
|
// doesn't model routing, so we verify the policy itself denies the
|
|
// principal (the same gate the router consults).
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public async Task Page_returns_unauthorized_for_user_without_Design_role()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddScadaBridgeAuthorization();
|
|
using var provider = services.BuildServiceProvider();
|
|
var authService = provider.GetRequiredService<IAuthorizationService>();
|
|
|
|
// Administrator user — has a role but it isn't Designer.
|
|
var principal = BuildPrincipal("bob", "Administrator");
|
|
var result = await authService.AuthorizeAsync(
|
|
principal, null, AuthorizationPolicies.RequireDesign);
|
|
|
|
Assert.False(result.Succeeded);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each
|
|
// site and (when expanded) its instances.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public void Renders_step1_sites_group_with_sites_and_instances()
|
|
{
|
|
var site = new Site("North Plant", "north") { Id = 5 };
|
|
var instance = new Instance("north/pump-01") { Id = 50, SiteId = 5 };
|
|
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { site }));
|
|
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instance }));
|
|
|
|
var cut = Render<TransportExportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("North Plant"));
|
|
|
|
// The Sites & Instances group is present alongside the existing artifact groups.
|
|
Assert.NotNull(cut.Find("[data-testid='group-sites']"));
|
|
Assert.Contains("North Plant", cut.Markup);
|
|
Assert.Contains("north", cut.Markup);
|
|
|
|
// The site row carries a site checkbox; expanding reveals the instance checkbox.
|
|
Assert.NotNull(cut.Find("#chk-site-5"));
|
|
var expandToggle = cut.Find("[data-testid='site-row'] button");
|
|
expandToggle.Click();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.NotNull(cut.Find("[data-testid='site-instances']"));
|
|
Assert.NotNull(cut.Find("#chk-instance-50"));
|
|
Assert.Contains("north/pump-01", cut.Markup);
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds;
|
|
// selecting an individual instance flows its id into InstanceIds. Verified at
|
|
// the resolver/exporter boundary (the same contract DependencyResolver reads).
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public async Task Step4_export_carries_selected_site_and_instance_ids()
|
|
{
|
|
var siteA = new Site("North Plant", "north") { Id = 5 };
|
|
var siteB = new Site("South Plant", "south") { Id = 6 };
|
|
var instanceB = new Instance("south/pump-09") { Id = 90, SiteId = 6 };
|
|
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { siteA, siteB }));
|
|
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
|
|
_siteRepo.GetInstancesBySiteIdAsync(6, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instanceB }));
|
|
// Resolver fetches selected entities by id; return them so the closure is non-empty.
|
|
_siteRepo.GetSiteByIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<Site?>(siteA));
|
|
|
|
_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("North Plant"));
|
|
|
|
// Tick site 5 (North Plant) directly.
|
|
cut.Find("#chk-site-5").Change(true);
|
|
|
|
// Expand South Plant and tick its instance individually.
|
|
var southRow = cut.FindAll("[data-testid='site-row']")
|
|
.First(r => r.GetAttribute("data-site-id") == "6");
|
|
southRow.QuerySelector("button")!.Click();
|
|
cut.WaitForState(() => cut.FindAll("#chk-instance-90").Count > 0);
|
|
cut.Find("#chk-instance-90").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.SiteIds.Contains(5)
|
|
&& s.InstanceIds.Contains(90)),
|
|
"alice",
|
|
"test-cluster",
|
|
"hunter2hunter2",
|
|
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
|
|
// contract is unit-pinned independently of the rendering surface.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public void BuildFilename_produces_pattern_and_sanitises_source_environment()
|
|
{
|
|
var fixedTime = new DateTimeOffset(2026, 5, 24, 13, 45, 22, TimeSpan.Zero);
|
|
var filename = TransportExportPage.BuildFilename("dev/cluster a", fixedTime);
|
|
Assert.Equal("scadabundle-dev-cluster-a-2026-05-24-134522.scadabundle", filename);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 7 (M8 C2-SECRET-1): CountSecrets includes site data-connection
|
|
// PrimaryConfiguration and BackupConfiguration fields in the banner total.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public void CountSecrets_includes_data_connection_primary_and_backup_configurations()
|
|
{
|
|
// One data connection with both fields populated — expect 2 secrets counted.
|
|
var dcBoth = new DataConnection("PlcA", "OpcUa", 10) { Id = 1,
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc-a:4840\"}", BackupConfiguration = "{\"endpoint\":\"opc.tcp://plc-a-backup:4840\"}" };
|
|
|
|
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>())
|
|
{
|
|
DataConnections = new List<DataConnection> { dcBoth },
|
|
};
|
|
|
|
Assert.Equal(2, TransportExportPage.CountSecrets(resolved));
|
|
}
|
|
|
|
[Fact]
|
|
public void CountSecrets_counts_only_non_empty_data_connection_configurations()
|
|
{
|
|
// Primary only — backup is null.
|
|
var dcPrimaryOnly = new DataConnection("PlcB", "OpcUa", 10) { Id = 2,
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc-b:4840\"}", BackupConfiguration = null };
|
|
|
|
// Neither field set — should not contribute.
|
|
var dcEmpty = new DataConnection("PlcC", "OpcUa", 10) { Id = 3,
|
|
PrimaryConfiguration = null, BackupConfiguration = 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>())
|
|
{
|
|
DataConnections = new List<DataConnection> { dcPrimaryOnly, dcEmpty },
|
|
};
|
|
|
|
Assert.Equal(1, TransportExportPage.CountSecrets(resolved));
|
|
}
|
|
|
|
[Fact]
|
|
public void CountSecrets_returns_zero_when_no_data_connections()
|
|
{
|
|
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>());
|
|
|
|
Assert.Equal(0, TransportExportPage.CountSecrets(resolved));
|
|
}
|
|
}
|