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. *@ + + + @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. +

+ +
+ + +
+ 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 + } +
+
+ +
+ +
+ } +
+ }; + + // ============================================================ + // 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. +

+ +
+ + +
+ + @if (_failedUnlockAttempts > 0) + { +
+ Failed unlock attempts: @_failedUnlockAttempts of @maxAttempts. +
+ } + +
+ + +
+
+ }; + + // ============================================================ + // 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: + + +
+ +
+ + + + + + + + + + + + + @foreach (var item in _preview.Items) + { + var key = (item.EntityType, item.Name); + var current = _resolutions[key]; + + + + + + + + + @if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson)) + { + + + + } + @if (item.Kind == ConflictKind.Blocker && !string.IsNullOrEmpty(item.BlockerReason)) + { + + + + } + } + +
TypeNameStatusExistingIncomingAction
@item.EntityType@item.Name@RenderKindBadge(item)@(item.ExistingVersion?.ToString() ?? "—")@(item.IncomingVersion?.ToString() ?? "—")@RenderResolutionControls(item, current)
+
+ Field diff +
@item.FieldDiffJson
+
+
+
@item.BlockerReason
+
+
+ +
+ +
+ + @adds add · @overs overwrite · @skips skip · @renames rename · @blockers blocker + + +
+
+
+ }; + + 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); + +
+ @foreach (var action in new[] { ResolutionAction.Overwrite, ResolutionAction.Skip, ResolutionAction.Rename }) + { + var inputId = $"res-{item.EntityType}-{item.Name}-{action}"; +
+ + +
+ } + @if (current.Action == ResolutionAction.Rename) + { + + } +
+ }; + + // ============================================================ + // 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. +
+ +
+ + +
+ +
+ + +
+
+ }; + + // ============================================================ + // Step 5 — Result + // ============================================================ + private RenderFragment RenderStepResult() => __builder => + { +
+ @if (_validationErrors is not null && _validationErrors.Count > 0) + { +
+ Bundle semantic validation failed. + +
+ + } + 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
+
+ +
+ + View on Deployments → + + + Audit trail → + +
+ } + else + { +
+ Import failed. Please re-upload the bundle. +
+ + } +
+ }; +} 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); + } +}