feat(centralui): TransportImport wizard under Design nav group
This commit is contained in:
@@ -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.
|
||||
*@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Import Bundle</h4>
|
||||
|
||||
@* Step indicator — five numbered pills, mirrors TransportExport. *@
|
||||
<nav aria-label="Import wizard steps" class="mb-4">
|
||||
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
|
||||
<li class="@StepClass(ImportWizardStep.Upload)">
|
||||
<span class="badge rounded-pill me-1">1</span> Upload
|
||||
</li>
|
||||
<li class="@StepClass(ImportWizardStep.Passphrase)">
|
||||
<span class="badge rounded-pill me-1">2</span> Passphrase
|
||||
</li>
|
||||
<li class="@StepClass(ImportWizardStep.Diff)">
|
||||
<span class="badge rounded-pill me-1">3</span> Diff
|
||||
</li>
|
||||
<li class="@StepClass(ImportWizardStep.Confirm)">
|
||||
<span class="badge rounded-pill me-1">4</span> Confirm
|
||||
</li>
|
||||
<li class="@StepClass(ImportWizardStep.Result)">
|
||||
<span class="badge rounded-pill me-1">5</span> Result
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger" data-testid="error-message">@_errorMessage</div>
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
</div>
|
||||
|
||||
@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 =>
|
||||
{
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
Select a <code>.scadabundle</code> 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.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="bundle-input" class="form-label">Bundle file</label>
|
||||
<InputFile id="bundle-input" OnChange="OnFileSelectedAsync"
|
||||
class="form-control" accept=".scadabundle,application/zip" />
|
||||
<div class="form-text">
|
||||
Maximum bundle size: @Options.Value.MaxBundleSizeMb MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_uploadInProgress)
|
||||
{
|
||||
<div class="text-muted small fst-italic">Reading bundle…</div>
|
||||
}
|
||||
|
||||
@if (_session is not null)
|
||||
{
|
||||
<dl class="row small mt-3" data-testid="manifest-summary">
|
||||
<dt class="col-sm-3">Source environment</dt>
|
||||
<dd class="col-sm-9"><code>@_session.Manifest.SourceEnvironment</code></dd>
|
||||
|
||||
<dt class="col-sm-3">Exported by</dt>
|
||||
<dd class="col-sm-9">@_session.Manifest.ExportedBy</dd>
|
||||
|
||||
<dt class="col-sm-3">Created</dt>
|
||||
<dd class="col-sm-9">@_session.Manifest.CreatedAtUtc.ToString("u")</dd>
|
||||
|
||||
<dt class="col-sm-3">Content count</dt>
|
||||
<dd class="col-sm-9">@_session.Manifest.Contents.Count items</dd>
|
||||
|
||||
<dt class="col-sm-3">SHA-256</dt>
|
||||
<dd class="col-sm-9"><code class="small">@_session.Manifest.ContentHash</code></dd>
|
||||
|
||||
<dt class="col-sm-3">Encryption</dt>
|
||||
<dd class="col-sm-9">
|
||||
@if (_session.Manifest.Encryption is null)
|
||||
{
|
||||
<span class="text-warning">Unencrypted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-success">@_session.Manifest.Encryption.Algorithm</span>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button class="btn btn-primary" @onclick="GoFromUploadAsync">Next</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 2 — Passphrase
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepPassphrase() => __builder =>
|
||||
{
|
||||
var maxAttempts = Options.Value.MaxUnlockAttemptsPerSession;
|
||||
var attemptsLeft = Math.Max(0, maxAttempts - _failedUnlockAttempts);
|
||||
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="import-passphrase" class="form-label">Passphrase</label>
|
||||
<input id="import-passphrase" type="password" class="form-control"
|
||||
autocomplete="current-password"
|
||||
@bind="_passphrase" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (_failedUnlockAttempts > 0)
|
||||
{
|
||||
<div class="alert alert-warning small" data-testid="unlock-attempts">
|
||||
Failed unlock attempts: @_failedUnlockAttempts of @maxAttempts.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
|
||||
<button class="btn btn-primary"
|
||||
disabled="@(string.IsNullOrEmpty(_passphrase) || _uploadInProgress)"
|
||||
@onclick="SubmitPassphraseAsync">
|
||||
@(_uploadInProgress ? "Unlocking…" : "Unlock")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 3 — Diff & resolve conflicts
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepDiff() => __builder =>
|
||||
{
|
||||
if (_preview is null || _resolutions is null)
|
||||
{
|
||||
<div class="alert alert-warning">No preview available — please go back and re-upload.</div>
|
||||
return;
|
||||
}
|
||||
|
||||
var (adds, overs, skips, renames, blockers) = CountResolutions();
|
||||
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
|
||||
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center" data-testid="bulk-actions">
|
||||
<span class="small text-body-secondary">Apply to all modified:</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive" style="max-height: 480px; overflow-y: auto;">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Existing</th>
|
||||
<th>Incoming</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in _preview.Items)
|
||||
{
|
||||
var key = (item.EntityType, item.Name);
|
||||
var current = _resolutions[key];
|
||||
<tr data-testid="diff-row">
|
||||
<td><span class="badge bg-secondary">@item.EntityType</span></td>
|
||||
<td>@item.Name</td>
|
||||
<td>@RenderKindBadge(item)</td>
|
||||
<td>@(item.ExistingVersion?.ToString() ?? "—")</td>
|
||||
<td>@(item.IncomingVersion?.ToString() ?? "—")</td>
|
||||
<td>@RenderResolutionControls(item, current)</td>
|
||||
</tr>
|
||||
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<details>
|
||||
<summary class="small">Field diff</summary>
|
||||
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (item.Kind == ConflictKind.Blocker && !string.IsNullOrEmpty(item.BlockerReason))
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="alert alert-danger small mb-0">@item.BlockerReason</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
|
||||
<div>
|
||||
<span class="me-3 small text-body-secondary" data-testid="diff-summary">
|
||||
@adds add · @overs overwrite · @skips skip · @renames rename · @blockers blocker
|
||||
</span>
|
||||
<button class="btn btn-primary"
|
||||
disabled="@hasBlockers"
|
||||
@onclick="GoToConfirm">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
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()),
|
||||
};
|
||||
<span class="badge @cls">@label</span>
|
||||
};
|
||||
|
||||
private RenderFragment RenderResolutionControls(ImportPreviewItem item, ImportResolution current) => __builder =>
|
||||
{
|
||||
// Identical → forced Skip; New → forced Add; Blocker → no actions.
|
||||
if (item.Kind == ConflictKind.Identical)
|
||||
{
|
||||
<span class="text-muted small">Skip</span>
|
||||
return;
|
||||
}
|
||||
if (item.Kind == ConflictKind.New)
|
||||
{
|
||||
<span class="text-muted small">Add</span>
|
||||
return;
|
||||
}
|
||||
if (item.Kind == ConflictKind.Blocker)
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
return;
|
||||
}
|
||||
|
||||
var key = (item.EntityType, item.Name);
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
@foreach (var action in new[] { ResolutionAction.Overwrite, ResolutionAction.Skip, ResolutionAction.Rename })
|
||||
{
|
||||
var inputId = $"res-{item.EntityType}-{item.Name}-{action}";
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="radio"
|
||||
id="@inputId"
|
||||
name="@($"res-{item.EntityType}-{item.Name}")"
|
||||
checked="@(current.Action == action)"
|
||||
@onchange="() => SetResolution(key, action)" />
|
||||
<label class="form-check-label small" for="@inputId">@action</label>
|
||||
</div>
|
||||
}
|
||||
@if (current.Action == ResolutionAction.Rename)
|
||||
{
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
style="max-width: 14rem;"
|
||||
placeholder="New name"
|
||||
value="@(current.RenameTo ?? string.Empty)"
|
||||
@onchange="e => SetRenameTo(key, e.Value?.ToString())" />
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 4 — Confirm
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepConfirm() => __builder =>
|
||||
{
|
||||
if (_session is null)
|
||||
{
|
||||
<div class="alert alert-warning">No bundle session — please re-upload.</div>
|
||||
return;
|
||||
}
|
||||
|
||||
var (adds, overs, skips, renames, _) = CountResolutions();
|
||||
var changeCount = adds + overs + renames;
|
||||
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
You are about to apply <strong>@changeCount</strong> change(s)
|
||||
to this environment (@adds add · @overs overwrite · @skips skip · @renames rename).
|
||||
</p>
|
||||
|
||||
<div class="alert alert-info small">
|
||||
Affected instances will become stale and require redeployment via the
|
||||
<a href="/deployment/deployments">Deployments</a> page.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="confirm-env" class="form-label">
|
||||
Type the source environment name <code>@_session.Manifest.SourceEnvironment</code> to confirm:
|
||||
</label>
|
||||
<input id="confirm-env" type="text" class="form-control"
|
||||
@bind="_confirmEnvironmentText" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
|
||||
<button class="btn btn-danger"
|
||||
disabled="@(_confirmEnvironmentText != _session.Manifest.SourceEnvironment || _applyInProgress)"
|
||||
@onclick="ApplyAsync">
|
||||
@(_applyInProgress ? "Applying…" : "Apply Import")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 5 — Result
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepResult() => __builder =>
|
||||
{
|
||||
<div>
|
||||
@if (_validationErrors is not null && _validationErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger" data-testid="validation-errors">
|
||||
<strong>Bundle semantic validation failed.</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var err in _validationErrors)
|
||||
{
|
||||
<li>@err</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
|
||||
}
|
||||
else if (_result is not null)
|
||||
{
|
||||
<div class="alert alert-success" data-testid="result-summary">
|
||||
<strong>Import complete.</strong>
|
||||
@_result.Added added · @_result.Overwritten overwritten ·
|
||||
@_result.Skipped skipped · @_result.Renamed renamed.
|
||||
</div>
|
||||
|
||||
<dl class="row small">
|
||||
<dt class="col-sm-3">Bundle Import Id</dt>
|
||||
<dd class="col-sm-9"><code>@_result.BundleImportId</code></dd>
|
||||
</dl>
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<a class="btn btn-outline-primary" href="/deployment/deployments">
|
||||
View on Deployments →
|
||||
</a>
|
||||
<a class="btn btn-outline-secondary"
|
||||
href="@($"/audit/configuration?bundleImportId={_result.BundleImportId}")">
|
||||
Audit trail →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
Import failed. Please re-upload the bundle.
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
|
||||
///
|
||||
/// Five-step state machine:
|
||||
/// <list type="number">
|
||||
/// <item><see cref="ImportWizardStep.Upload"/> — read bundle bytes, attempt
|
||||
/// a passphrase-less <see cref="IBundleImporter.LoadAsync"/>; if the
|
||||
/// bundle is encrypted, advance to Step 2 without yet opening a session.</item>
|
||||
/// <item><see cref="ImportWizardStep.Passphrase"/> — collect the passphrase
|
||||
/// and retry LoadAsync; 3-strike lockout per the configured
|
||||
/// <see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>.</item>
|
||||
/// <item><see cref="ImportWizardStep.Diff"/> — render <see cref="ImportPreview"/>
|
||||
/// items, collect <see cref="ImportResolution"/> per Modified item; Apply
|
||||
/// is blocked while any <see cref="ConflictKind.Blocker"/> remains.</item>
|
||||
/// <item><see cref="ImportWizardStep.Confirm"/> — type-the-environment-name
|
||||
/// guard prevents accidental cross-cluster overwrites.</item>
|
||||
/// <item><see cref="ImportWizardStep.Result"/> — render Apply result + audit
|
||||
/// drill-in link; on <see cref="SemanticValidationException"/>, surface
|
||||
/// the error list and allow returning to Step 3.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The page is gated on <c>RequireAdmin</c> — Import touches central configuration
|
||||
/// globally and must not be available to Design-only or Deployment-only users.
|
||||
///
|
||||
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
|
||||
/// peeks the manifest by attempting decryption, encrypted bundles require two
|
||||
/// LoadAsync invocations. We cache the raw bytes in <c>_bundleBytes</c> 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.
|
||||
/// </summary>
|
||||
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<TransportOptions> 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<string>? _validationErrors;
|
||||
|
||||
// ============================================================
|
||||
// Step 1 — Upload
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Buffers the selected file, enforces the configured size cap, then calls
|
||||
/// <see cref="IBundleImporter.LoadAsync"/> with no passphrase to peek the
|
||||
/// manifest. Encrypted bundles surface as <see cref="ArgumentException"/>,
|
||||
/// which we catch and use to advance to Step 2 — the session is opened on
|
||||
/// the second LoadAsync call once the passphrase is provided.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to open a <see cref="BundleSession"/> from the cached bytes with
|
||||
/// the given passphrase. On <see cref="ArgumentException"/> (encrypted bundle
|
||||
/// with no passphrase) leaves the wizard's step caller to advance to the
|
||||
/// passphrase step. Wrong-passphrase failures surface as
|
||||
/// <see cref="CryptographicException"/> and are counted by the caller.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>null</c> and threw
|
||||
/// <see cref="ArgumentException"/>, so <c>_session</c> is null and we move
|
||||
/// to Step 2. For unencrypted bundles <c>_session</c> is already populated;
|
||||
/// jump directly to Step 3.
|
||||
/// </summary>
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Submits the entered passphrase. On <see cref="CryptographicException"/>
|
||||
/// increments the per-session counter; once the configured threshold is
|
||||
/// reached the wizard resets to Step 1 with an explanatory error.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the default resolution per preview item:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ConflictKind.Identical"/> → <see cref="ResolutionAction.Skip"/></item>
|
||||
/// <item><see cref="ConflictKind.New"/> → <see cref="ResolutionAction.Add"/></item>
|
||||
/// <item><see cref="ConflictKind.Modified"/> → <see cref="ResolutionAction.Overwrite"/></item>
|
||||
/// <item><see cref="ConflictKind.Blocker"/> → <see cref="ResolutionAction.Skip"/> (UI disables Apply anyway)</item>
|
||||
/// </list>
|
||||
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
|
||||
/// </summary>
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Invokes <see cref="IBundleImporter.ApplyAsync"/> with the collected
|
||||
/// resolutions and the authenticated user identity. Distinguishes
|
||||
/// <see cref="SemanticValidationException"/> (recoverable — surface the
|
||||
/// error list and let the operator return to Step 3) from generic
|
||||
/// exceptions (display generic error + force re-upload).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22).
|
||||
///
|
||||
/// <para>
|
||||
/// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result).
|
||||
/// Selecting a file via <c>InputFile</c> is hard to drive cleanly from bUnit
|
||||
/// (JS interop + DotNetStreamReference), so the state-machine tests reach into
|
||||
/// the page instance via <c>cut.Instance</c> and the <c>InternalsVisibleTo</c>
|
||||
/// declaration on <c>ScadaLink.CentralUI.csproj</c>. The <c>BundleImporter</c>
|
||||
/// 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 <c>ScadaLink.Transport.IntegrationTests</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class TransportImportPageTests : BunitContext
|
||||
{
|
||||
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
|
||||
|
||||
public TransportImportPageTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
Services.AddSingleton(_importer);
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
MaxBundleSizeMb = 10,
|
||||
MaxUnlockAttemptsPerSession = 3,
|
||||
}));
|
||||
|
||||
var principal = BuildPrincipal("alice", "Admin");
|
||||
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"));
|
||||
}
|
||||
|
||||
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<ManifestContentEntry>()),
|
||||
DecryptedContent = Array.Empty<byte>(),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 1: Step 1 renders the InputFile upload control.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Renders_step1_upload_input()
|
||||
{
|
||||
var cut = Render<TransportImportPage>();
|
||||
// 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<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Throws(new CryptographicException("authentication tag mismatch"));
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
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<int>(cut.Instance, "_failedUnlockAttempts"));
|
||||
Assert.Equal(
|
||||
TransportImportPage.ImportWizardStep.Passphrase,
|
||||
GetField<TransportImportPage.ImportWizardStep>(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<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Throws(new CryptographicException("authentication tag mismatch"));
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
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<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
|
||||
var errorMessage = GetField<string?>(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<CancellationToken>())
|
||||
.Returns(new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
|
||||
}));
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
SetField(cut.Instance, "_session", session);
|
||||
SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
||||
{
|
||||
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<int>(),
|
||||
AuditEventCorrelation: Guid.NewGuid().ToString());
|
||||
|
||||
_importer.ApplyAsync(
|
||||
session.SessionId,
|
||||
Arg.Any<IReadOnlyList<ImportResolution>>(),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(expectedResult);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
SetField(cut.Instance, "_session", session);
|
||||
SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
||||
{
|
||||
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<IReadOnlyList<ImportResolution>>(rs =>
|
||||
rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
|
||||
&& r.Action == ResolutionAction.Overwrite)),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
Assert.Equal(
|
||||
TransportImportPage.ImportWizardStep.Result,
|
||||
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
|
||||
Assert.Equal(expectedResult, GetField<ImportResult?>(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<IAuthorizationService>();
|
||||
|
||||
// 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<ImportPreviewItem>
|
||||
{
|
||||
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<T>(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<object?>())!;
|
||||
await task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user