diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor
new file mode 100644
index 0000000..2321ffe
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor
@@ -0,0 +1,429 @@
+@page "/design/transport/import"
+@using ScadaLink.Security
+@using ScadaLink.Commons.Types.Transport
+@using ScadaLink.Commons.Interfaces.Transport
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.Extensions.Options
+@using ScadaLink.Transport
+@using ScadaLink.Transport.Import
+@using System.Security.Cryptography
+@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
+
+@*
+ TransportImport wizard (Component #24, Task T22).
+
+ A 5-step linear wizard:
+ Step 1 — Upload : InputFile + manifest summary; LoadAsync without passphrase first.
+ Step 2 — Passphrase : only shown for encrypted bundles; 3-strike lockout.
+ Step 3 — Diff : conflict resolution (Add/Overwrite/Skip/Rename) per ImportPreviewItem.
+ Step 4 — Confirm : type-the-environment-name guard.
+ Step 5 — Result : ApplyAsync result + audit drilldown link.
+
+ The page is Admin-only — Import touches central configuration globally.
+*@
+
+
+
Import Bundle
+
+ @* Step indicator — five numbered pills, mirrors TransportExport. *@
+
+
+
+ 1 Upload
+
+
+ 2 Passphrase
+
+
+ 3 Diff
+
+
+ 4 Confirm
+
+
+ 5 Result
+
+
+
+
+ @if (_errorMessage != null)
+ {
+
@_errorMessage
+ }
+
+ @switch (_step)
+ {
+ case ImportWizardStep.Upload:
+ @RenderStepUpload();
+ break;
+ case ImportWizardStep.Passphrase:
+ @RenderStepPassphrase();
+ break;
+ case ImportWizardStep.Diff:
+ @RenderStepDiff();
+ break;
+ case ImportWizardStep.Confirm:
+ @RenderStepConfirm();
+ break;
+ case ImportWizardStep.Result:
+ @RenderStepResult();
+ break;
+ }
+
+
+@code {
+ private string StepClass(ImportWizardStep s) =>
+ s == _step ? "fw-semibold text-primary"
+ : (int)s < (int)_step ? "text-success"
+ : "text-muted";
+
+ // ============================================================
+ // Step 1 — Upload
+ // ============================================================
+ private RenderFragment RenderStepUpload() => __builder =>
+ {
+
+
+ Select a .scadabundle file produced by an exporter on this
+ or another cluster. The bundle's manifest will be validated immediately;
+ encrypted bundles will prompt for a passphrase on the next step.
+
+
+
+
Bundle file
+
+
+ Maximum bundle size: @Options.Value.MaxBundleSizeMb MB.
+
+
+
+ @if (_uploadInProgress)
+ {
+
Reading bundle…
+ }
+
+ @if (_session is not null)
+ {
+
+ Source environment
+ @_session.Manifest.SourceEnvironment
+
+ Exported by
+ @_session.Manifest.ExportedBy
+
+ Created
+ @_session.Manifest.CreatedAtUtc.ToString("u")
+
+ Content count
+ @_session.Manifest.Contents.Count items
+
+ SHA-256
+ @_session.Manifest.ContentHash
+
+ Encryption
+
+ @if (_session.Manifest.Encryption is null)
+ {
+ Unencrypted
+ }
+ else
+ {
+ @_session.Manifest.Encryption.Algorithm
+ }
+
+
+
+
+ Next
+
+ }
+
+ };
+
+ // ============================================================
+ // Step 2 — Passphrase
+ // ============================================================
+ private RenderFragment RenderStepPassphrase() => __builder =>
+ {
+ var maxAttempts = Options.Value.MaxUnlockAttemptsPerSession;
+ var attemptsLeft = Math.Max(0, maxAttempts - _failedUnlockAttempts);
+
+
+
+ This bundle is encrypted. Enter the passphrase that was used to
+ produce it. You have @attemptsLeft of @maxAttempts attempts before
+ the upload must be restarted.
+
+
+
+ Passphrase
+
+
+
+ @if (_failedUnlockAttempts > 0)
+ {
+
+ Failed unlock attempts: @_failedUnlockAttempts of @maxAttempts.
+
+ }
+
+
+ Back
+
+ @(_uploadInProgress ? "Unlocking…" : "Unlock")
+
+
+
+ };
+
+ // ============================================================
+ // Step 3 — Diff & resolve conflicts
+ // ============================================================
+ private RenderFragment RenderStepDiff() => __builder =>
+ {
+ if (_preview is null || _resolutions is null)
+ {
+ No preview available — please go back and re-upload.
+ return;
+ }
+
+ var (adds, overs, skips, renames, blockers) = CountResolutions();
+ var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
+
+
+
+ Review each artifact in the bundle and choose how it should be applied
+ to this environment. Identical items are skipped automatically; new
+ items default to Add; modified items require an explicit choice.
+
+
+
+ Apply to all modified:
+ BulkSet(ResolutionAction.Skip)">Skip
+ BulkSet(ResolutionAction.Overwrite)">Overwrite
+
+
+
+
+
+
+ Type
+ Name
+ Status
+ Existing
+ Incoming
+ Action
+
+
+
+ @foreach (var item in _preview.Items)
+ {
+ var key = (item.EntityType, item.Name);
+ var current = _resolutions[key];
+
+ @item.EntityType
+ @item.Name
+ @RenderKindBadge(item)
+ @(item.ExistingVersion?.ToString() ?? "—")
+ @(item.IncomingVersion?.ToString() ?? "—")
+ @RenderResolutionControls(item, current)
+
+ @if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
+ {
+
+
+
+ Field diff
+ @item.FieldDiffJson
+
+
+
+ }
+ @if (item.Kind == ConflictKind.Blocker && !string.IsNullOrEmpty(item.BlockerReason))
+ {
+
+
+ @item.BlockerReason
+
+
+ }
+ }
+
+
+
+
+
+
Back
+
+
+ @adds add · @overs overwrite · @skips skip · @renames rename · @blockers blocker
+
+
+ Next
+
+
+
+
+ };
+
+ private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
+ {
+ var (cls, label) = item.Kind switch
+ {
+ ConflictKind.Identical => ("bg-secondary", "Identical"),
+ ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
+ ConflictKind.New => ("bg-success", "New"),
+ ConflictKind.Blocker => ("bg-danger", "Blocker"),
+ _ => ("bg-light text-dark", item.Kind.ToString()),
+ };
+ @label
+ };
+
+ private RenderFragment RenderResolutionControls(ImportPreviewItem item, ImportResolution current) => __builder =>
+ {
+ // Identical → forced Skip; New → forced Add; Blocker → no actions.
+ if (item.Kind == ConflictKind.Identical)
+ {
+ Skip
+ return;
+ }
+ if (item.Kind == ConflictKind.New)
+ {
+ Add
+ return;
+ }
+ if (item.Kind == ConflictKind.Blocker)
+ {
+ —
+ return;
+ }
+
+ var key = (item.EntityType, item.Name);
+
+
+ };
+
+ // ============================================================
+ // Step 4 — Confirm
+ // ============================================================
+ private RenderFragment RenderStepConfirm() => __builder =>
+ {
+ if (_session is null)
+ {
+ No bundle session — please re-upload.
+ return;
+ }
+
+ var (adds, overs, skips, renames, _) = CountResolutions();
+ var changeCount = adds + overs + renames;
+
+
+
+ You are about to apply @changeCount change(s)
+ to this environment (@adds add · @overs overwrite · @skips skip · @renames rename).
+
+
+
+ Affected instances will become stale and require redeployment via the
+
Deployments page.
+
+
+
+
+ Type the source environment name @_session.Manifest.SourceEnvironment to confirm:
+
+
+
+
+
+ Back
+
+ @(_applyInProgress ? "Applying…" : "Apply Import")
+
+
+
+ };
+
+ // ============================================================
+ // Step 5 — Result
+ // ============================================================
+ private RenderFragment RenderStepResult() => __builder =>
+ {
+
+ @if (_validationErrors is not null && _validationErrors.Count > 0)
+ {
+
+
Bundle semantic validation failed.
+
+ @foreach (var err in _validationErrors)
+ {
+ @err
+ }
+
+
+
Back
+ }
+ else if (_result is not null)
+ {
+
+ Import complete.
+ @_result.Added added · @_result.Overwritten overwritten ·
+ @_result.Skipped skipped · @_result.Renamed renamed.
+
+
+
+ Bundle Import Id
+ @_result.BundleImportId
+
+
+
+ }
+ else
+ {
+
+ Import failed. Please re-upload the bundle.
+
+
Back
+ }
+
+ };
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs
new file mode 100644
index 0000000..32fd68c
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs
@@ -0,0 +1,473 @@
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Components.Forms;
+using Microsoft.Extensions.Options;
+using ScadaLink.CentralUI.Auth;
+using ScadaLink.Commons.Interfaces.Transport;
+using ScadaLink.Commons.Types.Transport;
+using ScadaLink.Transport;
+using ScadaLink.Transport.Import;
+
+namespace ScadaLink.CentralUI.Components.Pages.Design;
+
+///
+/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
+///
+/// Five-step state machine:
+///
+/// — read bundle bytes, attempt
+/// a passphrase-less ; if the
+/// bundle is encrypted, advance to Step 2 without yet opening a session.
+/// — collect the passphrase
+/// and retry LoadAsync; 3-strike lockout per the configured
+/// .
+/// — render
+/// items, collect per Modified item; Apply
+/// is blocked while any remains.
+/// — type-the-environment-name
+/// guard prevents accidental cross-cluster overwrites.
+/// — render Apply result + audit
+/// drill-in link; on , surface
+/// the error list and allow returning to Step 3.
+///
+///
+/// The page is gated on RequireAdmin — Import touches central configuration
+/// globally and must not be available to Design-only or Deployment-only users.
+///
+/// Cached bundle bytes: because currently
+/// peeks the manifest by attempting decryption, encrypted bundles require two
+/// LoadAsync invocations. We cache the raw bytes in _bundleBytes after the
+/// first read so the user does not need to re-select the file before entering the
+/// passphrase. The bytes are cleared on Done / Back-to-Upload.
+///
+public partial class TransportImport : ComponentBase
+{
+ public enum ImportWizardStep
+ {
+ Upload = 1,
+ Passphrase = 2,
+ Diff = 3,
+ Confirm = 4,
+ Result = 5,
+ }
+
+ // ---- Injected services ----
+ [Inject] private IBundleImporter BundleImporter { get; set; } = default!;
+ [Inject] private NavigationManager Nav { get; set; } = default!;
+ [Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
+ [Inject] private IOptions Options { get; set; } = default!;
+
+ // ---- Wizard state ----
+ private ImportWizardStep _step = ImportWizardStep.Upload;
+ private string? _errorMessage;
+
+ // ---- Session + cached bytes ----
+ // Bundle bytes are cached so the same file can be re-attempted with a
+ // passphrase without forcing the user to re-pick it. Cleared in ResetAll.
+ private byte[]? _bundleBytes;
+ private BundleSession? _session;
+ private bool _uploadInProgress;
+
+ // ---- Step 2: passphrase ----
+ private string _passphrase = string.Empty;
+ private int _failedUnlockAttempts;
+
+ // ---- Step 3: preview + resolutions ----
+ private ImportPreview? _preview;
+ // Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
+ private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
+
+ // ---- Step 4: confirm ----
+ private string _confirmEnvironmentText = string.Empty;
+
+ // ---- Step 5: apply result ----
+ private bool _applyInProgress;
+ private ImportResult? _result;
+ private IReadOnlyList? _validationErrors;
+
+ // ============================================================
+ // Step 1 — Upload
+ // ============================================================
+
+ ///
+ /// Buffers the selected file, enforces the configured size cap, then calls
+ /// with no passphrase to peek the
+ /// manifest. Encrypted bundles surface as ,
+ /// which we catch and use to advance to Step 2 — the session is opened on
+ /// the second LoadAsync call once the passphrase is provided.
+ ///
+ private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
+ {
+ _errorMessage = null;
+ _uploadInProgress = true;
+ _session = null;
+ _bundleBytes = null;
+ try
+ {
+ var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L;
+ if (e.File.Size > maxBytes)
+ {
+ _errorMessage = $"Bundle exceeds the maximum allowed size of {Options.Value.MaxBundleSizeMb} MB.";
+ return;
+ }
+
+ // OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump
+ // it to the configured cap so the read doesn't throw before we get
+ // to the importer's own length check.
+ using var fileStream = e.File.OpenReadStream(maxBytes);
+ using var ms = new MemoryStream();
+ await fileStream.CopyToAsync(ms);
+ _bundleBytes = ms.ToArray();
+
+ await TryLoadAsync(passphrase: null);
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Failed to read bundle: {ex.Message}";
+ }
+ finally
+ {
+ _uploadInProgress = false;
+ }
+ }
+
+ ///
+ /// Attempts to open a from the cached bytes with
+ /// the given passphrase. On (encrypted bundle
+ /// with no passphrase) leaves the wizard's step caller to advance to the
+ /// passphrase step. Wrong-passphrase failures surface as
+ /// and are counted by the caller.
+ ///
+ private async Task TryLoadAsync(string? passphrase)
+ {
+ if (_bundleBytes is null)
+ {
+ _errorMessage = "No bundle bytes cached — please re-select the file.";
+ return;
+ }
+ try
+ {
+ using var stream = new MemoryStream(_bundleBytes);
+ _session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
+ _errorMessage = null;
+ }
+ catch (ArgumentException)
+ {
+ // Encrypted bundle, no passphrase supplied — caller advances to Step 2.
+ // We deliberately do NOT set _errorMessage here; the page surfaces
+ // an empty Step-2 prompt instead.
+ _session = null;
+ throw;
+ }
+ catch (CryptographicException)
+ {
+ // Wrong passphrase — bubble so the caller can increment the counter.
+ _session = null;
+ throw;
+ }
+ catch (InvalidDataException ex)
+ {
+ _session = null;
+ _errorMessage = $"Bundle is invalid: {ex.Message}";
+ }
+ catch (NotSupportedException ex)
+ {
+ _session = null;
+ _errorMessage = $"Bundle format unsupported: {ex.Message}";
+ }
+ catch (InvalidOperationException ex)
+ {
+ _session = null;
+ _errorMessage = ex.Message;
+ }
+ }
+
+ ///
+ /// Advances from Step 1 to either the passphrase step (encrypted bundle) or
+ /// straight to the diff step (unencrypted bundle). For encrypted bundles
+ /// LoadAsync was already attempted with null and threw
+ /// , so _session is null and we move
+ /// to Step 2. For unencrypted bundles _session is already populated;
+ /// jump directly to Step 3.
+ ///
+ private async Task GoFromUploadAsync()
+ {
+ if (_session is null)
+ {
+ // Peek the manifest to find out if it's encrypted. We re-call LoadAsync
+ // with null passphrase; for encrypted bundles this throws
+ // ArgumentException → advance to Step 2.
+ try
+ {
+ await TryLoadAsync(passphrase: null);
+ }
+ catch (ArgumentException)
+ {
+ _step = ImportWizardStep.Passphrase;
+ return;
+ }
+ catch (CryptographicException)
+ {
+ _errorMessage = "Bundle could not be decrypted.";
+ return;
+ }
+ }
+
+ if (_session is null)
+ {
+ // Some other error already surfaced via _errorMessage.
+ return;
+ }
+ if (_session.Manifest.Encryption is not null)
+ {
+ _step = ImportWizardStep.Passphrase;
+ }
+ else
+ {
+ await LoadPreviewAndAdvanceAsync();
+ }
+ }
+
+ // ============================================================
+ // Step 2 — Passphrase
+ // ============================================================
+
+ ///
+ /// Submits the entered passphrase. On
+ /// increments the per-session counter; once the configured threshold is
+ /// reached the wizard resets to Step 1 with an explanatory error.
+ ///
+ private async Task SubmitPassphraseAsync()
+ {
+ if (string.IsNullOrEmpty(_passphrase))
+ {
+ return;
+ }
+ _uploadInProgress = true;
+ try
+ {
+ await TryLoadAsync(_passphrase);
+ if (_session is not null)
+ {
+ _failedUnlockAttempts = 0;
+ _passphrase = string.Empty;
+ await LoadPreviewAndAdvanceAsync();
+ }
+ }
+ catch (CryptographicException)
+ {
+ _failedUnlockAttempts++;
+ _passphrase = string.Empty;
+ if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession)
+ {
+ _errorMessage =
+ $"Too many failed unlock attempts ({_failedUnlockAttempts}). "
+ + "Please re-upload the bundle.";
+ ResetSessionState();
+ _step = ImportWizardStep.Upload;
+ }
+ else
+ {
+ _errorMessage = "Wrong passphrase. Please try again.";
+ }
+ }
+ catch (ArgumentException)
+ {
+ _errorMessage = "Passphrase required.";
+ }
+ finally
+ {
+ _uploadInProgress = false;
+ }
+ }
+
+ private void BackToUpload()
+ {
+ _step = ImportWizardStep.Upload;
+ _errorMessage = null;
+ }
+
+ // ============================================================
+ // Step 3 — Diff & resolve conflicts
+ // ============================================================
+
+ private async Task LoadPreviewAndAdvanceAsync()
+ {
+ if (_session is null) return;
+ try
+ {
+ _preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
+ _resolutions = BuildDefaultResolutions(_preview);
+ _step = ImportWizardStep.Diff;
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Failed to build import preview: {ex.Message}";
+ }
+ }
+
+ ///
+ /// Builds the default resolution per preview item:
+ ///
+ /// →
+ /// →
+ /// →
+ /// → (UI disables Apply anyway)
+ ///
+ /// Visible to tests via internal so the default-mapping contract is unit-pinned.
+ ///
+ internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
+ ImportPreview preview)
+ {
+ var map = new Dictionary<(string, string), ImportResolution>();
+ foreach (var item in preview.Items)
+ {
+ var action = item.Kind switch
+ {
+ ConflictKind.Identical => ResolutionAction.Skip,
+ ConflictKind.New => ResolutionAction.Add,
+ ConflictKind.Modified => ResolutionAction.Overwrite,
+ ConflictKind.Blocker => ResolutionAction.Skip,
+ _ => ResolutionAction.Skip,
+ };
+ map[(item.EntityType, item.Name)] = new ImportResolution(
+ item.EntityType, item.Name, action, RenameTo: null);
+ }
+ return map;
+ }
+
+ private void SetResolution((string EntityType, string Name) key, ResolutionAction action)
+ {
+ if (_resolutions is null) return;
+ var existing = _resolutions[key];
+ _resolutions[key] = existing with { Action = action };
+ }
+
+ private void SetRenameTo((string EntityType, string Name) key, string? renameTo)
+ {
+ if (_resolutions is null) return;
+ var existing = _resolutions[key];
+ _resolutions[key] = existing with { RenameTo = renameTo };
+ }
+
+ private void BulkSet(ResolutionAction action)
+ {
+ if (_resolutions is null || _preview is null) return;
+ foreach (var item in _preview.Items)
+ {
+ if (item.Kind != ConflictKind.Modified) continue;
+ var key = (item.EntityType, item.Name);
+ _resolutions[key] = _resolutions[key] with { Action = action };
+ }
+ }
+
+ private (int Adds, int Overs, int Skips, int Renames, int Blockers) CountResolutions()
+ {
+ if (_preview is null || _resolutions is null) return (0, 0, 0, 0, 0);
+ var adds = 0;
+ var overs = 0;
+ var skips = 0;
+ var renames = 0;
+ var blockers = 0;
+ foreach (var item in _preview.Items)
+ {
+ if (item.Kind == ConflictKind.Blocker)
+ {
+ blockers++;
+ continue;
+ }
+ var action = _resolutions[(item.EntityType, item.Name)].Action;
+ switch (action)
+ {
+ case ResolutionAction.Add: adds++; break;
+ case ResolutionAction.Overwrite: overs++; break;
+ case ResolutionAction.Skip: skips++; break;
+ case ResolutionAction.Rename: renames++; break;
+ }
+ }
+ return (adds, overs, skips, renames, blockers);
+ }
+
+ private void GoToConfirm()
+ {
+ if (_preview is null) return;
+ if (_preview.Items.Any(i => i.Kind == ConflictKind.Blocker))
+ {
+ _errorMessage = "Cannot proceed while blockers exist — resolve or remove blocker rows first.";
+ return;
+ }
+ _confirmEnvironmentText = string.Empty;
+ _step = ImportWizardStep.Confirm;
+ }
+
+ private void BackToDiff()
+ {
+ _step = ImportWizardStep.Diff;
+ _errorMessage = null;
+ _validationErrors = null;
+ _result = null;
+ }
+
+ // ============================================================
+ // Step 4 + 5 — Confirm & Apply
+ // ============================================================
+
+ ///
+ /// Invokes with the collected
+ /// resolutions and the authenticated user identity. Distinguishes
+ /// (recoverable — surface the
+ /// error list and let the operator return to Step 3) from generic
+ /// exceptions (display generic error + force re-upload).
+ ///
+ private async Task ApplyAsync()
+ {
+ if (_session is null || _resolutions is null) return;
+ if (_confirmEnvironmentText != _session.Manifest.SourceEnvironment) return;
+
+ _applyInProgress = true;
+ _errorMessage = null;
+ _validationErrors = null;
+ _result = null;
+ try
+ {
+ var user = await Auth.GetCurrentUsernameAsync();
+ _result = await BundleImporter.ApplyAsync(
+ _session.SessionId,
+ _resolutions.Values.ToList(),
+ user,
+ CancellationToken.None);
+ _step = ImportWizardStep.Result;
+ }
+ catch (SemanticValidationException ex)
+ {
+ _validationErrors = ex.Errors;
+ _step = ImportWizardStep.Result;
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Import failed: {ex.Message}. Please re-upload the bundle.";
+ _step = ImportWizardStep.Result;
+ }
+ finally
+ {
+ _applyInProgress = false;
+ }
+ }
+
+ // ============================================================
+ // Reset helpers
+ // ============================================================
+
+ private void ResetSessionState()
+ {
+ _session = null;
+ _bundleBytes = null;
+ _preview = null;
+ _resolutions = null;
+ _passphrase = string.Empty;
+ _confirmEnvironmentText = string.Empty;
+ _result = null;
+ _validationErrors = null;
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs
new file mode 100644
index 0000000..1e5cfa7
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs
@@ -0,0 +1,367 @@
+using System.Security.Claims;
+using System.Security.Cryptography;
+using Bunit;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using ScadaLink.Commons.Interfaces.Transport;
+using ScadaLink.Commons.Types.Transport;
+using ScadaLink.Security;
+using ScadaLink.Transport;
+using TransportImportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportImport;
+
+namespace ScadaLink.CentralUI.Tests.Pages.Design;
+
+///
+/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22).
+///
+///
+/// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result).
+/// Selecting a file via InputFile is hard to drive cleanly from bUnit
+/// (JS interop + DotNetStreamReference), so the state-machine tests reach into
+/// the page instance via cut.Instance and the InternalsVisibleTo
+/// declaration on ScadaLink.CentralUI.csproj . The BundleImporter
+/// mock controls every load/preview/apply contract so each step's behaviour can
+/// be exercised in isolation. The full happy-path round-trip is covered by the
+/// integration tests in ScadaLink.Transport.IntegrationTests .
+///
+///
+public class TransportImportPageTests : BunitContext
+{
+ private readonly IBundleImporter _importer = Substitute.For();
+
+ public TransportImportPageTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ Services.AddSingleton(_importer);
+ Services.AddSingleton>(
+ Microsoft.Extensions.Options.Options.Create(new TransportOptions
+ {
+ MaxBundleSizeMb = 10,
+ MaxUnlockAttemptsPerSession = 3,
+ }));
+
+ var principal = BuildPrincipal("alice", "Admin");
+ Services.AddSingleton(new TestAuthStateProvider(principal));
+ Services.AddAuthorizationCore();
+ }
+
+ private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
+ {
+ var claims = new List { new(JwtTokenService.UsernameClaimType, username) };
+ claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
+ return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ }
+
+ private static BundleSession BuildEncryptedSession(string sourceEnv = "prod-cluster") =>
+ new()
+ {
+ SessionId = Guid.NewGuid(),
+ Manifest = new BundleManifest(
+ BundleFormatVersion: 1,
+ SchemaVersion: "1.0",
+ CreatedAtUtc: DateTimeOffset.UtcNow,
+ SourceEnvironment: sourceEnv,
+ ExportedBy: "bob",
+ ScadaLinkVersion: "1.0.0",
+ ContentHash: "sha256:0000",
+ Encryption: new EncryptionMetadata(
+ Algorithm: "AES-256-GCM",
+ Kdf: "PBKDF2-SHA256",
+ Iterations: 600_000,
+ SaltB64: "abc",
+ IvB64: "def"),
+ Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
+ Contents: Array.Empty()),
+ DecryptedContent = Array.Empty(),
+ ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
+ };
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 1: Step 1 renders the InputFile upload control.
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void Renders_step1_upload_input()
+ {
+ var cut = Render();
+ // Bootstrap classes are applied by InputFile via the CSS class attribute.
+ Assert.NotNull(cut.Find("input[type='file']"));
+ // The Bootstrap step indicator should highlight Step 1.
+ Assert.Contains("Upload", cut.Markup);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 2: Wrong passphrase increments the failure counter without
+ // advancing past Step 2.
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public async Task Decryption_failure_increments_attempt_counter()
+ {
+ // Set up the importer to throw CryptographicException for wrong passphrases.
+ _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Throws(new CryptographicException("authentication tag mismatch"));
+
+ var cut = Render();
+ await cut.InvokeAsync(() =>
+ {
+ // Seed the wizard at the passphrase step with cached bytes.
+ SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
+ SetField(cut.Instance, "_passphrase", "wrong-pass");
+ });
+
+ // Drive a passphrase submission.
+ await cut.InvokeAsync(async () =>
+ {
+ await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
+ });
+
+ Assert.Equal(1, GetField(cut.Instance, "_failedUnlockAttempts"));
+ Assert.Equal(
+ TransportImportPage.ImportWizardStep.Passphrase,
+ GetField(cut.Instance, "_step"));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns
+ // to Step 1 with an explanatory error.
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public async Task Three_failed_unlocks_force_reupload()
+ {
+ _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Throws(new CryptographicException("authentication tag mismatch"));
+
+ var cut = Render();
+ await cut.InvokeAsync(() =>
+ {
+ SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
+ });
+
+ for (var i = 0; i < 3; i++)
+ {
+ await cut.InvokeAsync(async () =>
+ {
+ SetField(cut.Instance, "_passphrase", $"wrong-{i}");
+ await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
+ });
+ }
+
+ Assert.Equal(
+ TransportImportPage.ImportWizardStep.Upload,
+ GetField(cut.Instance, "_step"));
+ var errorMessage = GetField(cut.Instance, "_errorMessage");
+ Assert.NotNull(errorMessage);
+ Assert.Contains("Too many failed unlock attempts", errorMessage);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 4: Confirm step requires an exact match (case-sensitive) on the
+ // source environment name before Apply is enabled.
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public async Task Confirm_step_requires_exact_environment_name_match()
+ {
+ var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
+ _importer.PreviewAsync(session.SessionId, Arg.Any())
+ .Returns(new ImportPreview(session.SessionId, new List
+ {
+ new("Template", "Pump", null, 1, ConflictKind.New, null, null),
+ }));
+
+ var cut = Render();
+ await cut.InvokeAsync(() =>
+ {
+ SetField(cut.Instance, "_session", session);
+ SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List
+ {
+ new("Template", "Pump", null, 1, ConflictKind.New, null, null),
+ }));
+ SetField(cut.Instance, "_resolutions", new Dictionary<(string EntityType, string Name), ImportResolution>
+ {
+ [("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
+ });
+ SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
+ });
+
+ // Wrong text → Apply button is disabled.
+ await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "wrong"));
+ cut.Render();
+ var applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
+ Assert.True(applyBtn.HasAttribute("disabled"));
+
+ // Case mismatch → still disabled.
+ await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "PROD-CLUSTER"));
+ cut.Render();
+ applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
+ Assert.True(applyBtn.HasAttribute("disabled"));
+
+ // Exact match → enabled.
+ await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
+ cut.Render();
+ applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
+ Assert.False(applyBtn.HasAttribute("disabled"));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 5: ApplyAsync is invoked with the chosen resolutions and the
+ // authenticated user identity.
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions()
+ {
+ var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
+ var resolutions = new Dictionary<(string EntityType, string Name), ImportResolution>
+ {
+ [("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Overwrite, null),
+ };
+ var expectedResult = new ImportResult(
+ BundleImportId: Guid.NewGuid(),
+ Added: 0,
+ Overwritten: 1,
+ Skipped: 0,
+ Renamed: 0,
+ StaleInstanceIds: Array.Empty(),
+ AuditEventCorrelation: Guid.NewGuid().ToString());
+
+ _importer.ApplyAsync(
+ session.SessionId,
+ Arg.Any>(),
+ "alice",
+ Arg.Any())
+ .Returns(expectedResult);
+
+ var cut = Render();
+ await cut.InvokeAsync(() =>
+ {
+ SetField(cut.Instance, "_session", session);
+ SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List
+ {
+ new("Template", "Pump", 1, 2, ConflictKind.Modified, null, null),
+ }));
+ SetField(cut.Instance, "_resolutions", resolutions);
+ SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
+ SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster");
+ });
+
+ await cut.InvokeAsync(async () =>
+ {
+ await InvokeAsyncMethod(cut.Instance, "ApplyAsync");
+ });
+
+ await _importer.Received(1).ApplyAsync(
+ session.SessionId,
+ Arg.Is>(rs =>
+ rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
+ && r.Action == ResolutionAction.Overwrite)),
+ "alice",
+ Arg.Any());
+
+ Assert.Equal(
+ TransportImportPage.ImportWizardStep.Result,
+ GetField(cut.Instance, "_step"));
+ Assert.Equal(expectedResult, GetField(cut.Instance, "_result"));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 6: A user without the Admin role fails the RequireAdmin policy.
+ // The router enforces [Authorize(Policy=...)] at request time — bUnit
+ // doesn't model routing, so we verify the policy itself denies the
+ // principal.
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public async Task Page_returns_unauthorized_for_user_without_Admin_role()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddScadaLinkAuthorization();
+ using var provider = services.BuildServiceProvider();
+ var authService = provider.GetRequiredService();
+
+ // Design-only user — has a role but it isn't Admin.
+ var principal = BuildPrincipal("bob", "Design");
+ var result = await authService.AuthorizeAsync(
+ principal, null, AuthorizationPolicies.RequireAdmin);
+
+ Assert.False(result.Succeeded);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the
+ // expected default ResolutionAction.
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void BuildDefaultResolutions_maps_kinds_to_actions()
+ {
+ var preview = new ImportPreview(Guid.NewGuid(), new List
+ {
+ new("Template", "A", 1, 1, ConflictKind.Identical, null, null),
+ new("Template", "B", null, 1, ConflictKind.New, null, null),
+ new("Template", "C", 1, 2, ConflictKind.Modified, null, null),
+ new("Reference", "D", null, null, ConflictKind.Blocker, null, "missing dep"),
+ });
+
+ var map = TransportImportPage.BuildDefaultResolutions(preview);
+
+ Assert.Equal(ResolutionAction.Skip, map[("Template", "A")].Action);
+ Assert.Equal(ResolutionAction.Add, map[("Template", "B")].Action);
+ Assert.Equal(ResolutionAction.Overwrite, map[("Template", "C")].Action);
+ Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Reflection helpers — the wizard's per-instance state is private (the
+ // razor partial pattern). We poke at it via reflection rather than
+ // widening the surface of the production class with test-only accessors.
+ // ─────────────────────────────────────────────────────────────────────
+
+ private static void SetField(object obj, string name, object? value)
+ {
+ var field = obj.GetType().GetField(
+ name,
+ System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.NonPublic
+ | System.Reflection.BindingFlags.Public)
+ ?? throw new InvalidOperationException($"Field '{name}' not found on {obj.GetType()}.");
+ field.SetValue(obj, value);
+ }
+
+ private static T GetField(object obj, string name)
+ {
+ var field = obj.GetType().GetField(
+ name,
+ System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.NonPublic
+ | System.Reflection.BindingFlags.Public)
+ ?? throw new InvalidOperationException($"Field '{name}' not found on {obj.GetType()}.");
+ return (T)field.GetValue(obj)!;
+ }
+
+ private static async Task InvokeAsyncMethod(object obj, string name)
+ {
+ var method = obj.GetType().GetMethod(
+ name,
+ System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.NonPublic
+ | System.Reflection.BindingFlags.Public)
+ ?? throw new InvalidOperationException($"Method '{name}' not found on {obj.GetType()}.");
+ var task = (Task)method.Invoke(obj, Array.Empty())!;
+ await task;
+ }
+
+ ///
+ /// Seeds the wizard at Step 2 (Passphrase) with cached bundle bytes — the
+ /// shape after an encrypted-bundle upload completed Step 1's peek and
+ /// surfaced an ArgumentException ("passphrase required").
+ ///
+ private static void SeedAtPassphraseStep(TransportImportPage instance, byte[] bytes)
+ {
+ SetField(instance, "_bundleBytes", bytes);
+ SetField(instance, "_session", null);
+ SetField(instance, "_step", TransportImportPage.ImportWizardStep.Passphrase);
+ SetField(instance, "_failedUnlockAttempts", 0);
+ }
+}