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