feat(centralui): TransportExport wizard under Design nav group
Implements Task T21 of the Transport feature. A four-step Blazor wizard
(Select → Review → Encrypt → Download) under /design/transport/export,
gated on AuthorizationPolicies.RequireDesign:
1. Select — TemplateFolderTree (checkbox-mode) plus flat checkbox
lists for shared scripts, external systems, DB connections,
notification lists, SMTP configs, API keys, API methods.
2. Review — runs DependencyResolver, surfaces seed vs auto-included.
"Include all dependencies" toggle re-resolves on flip.
3. Encrypt — passphrase + confirm with strength meter, secret-count
warning over the resolved closure, explicit unencrypted
opt-out path (calls BundleExporter with passphrase=null
so the audit row tags UnencryptedBundleExport).
4. Download— calls IBundleExporter.ExportAsync, streams bytes to the
browser via JS interop (wwwroot/js/transport.js), displays
filename + size + SHA-256 + encryption status.
Source environment is sourced from new TransportOptions.SourceEnvironment
(bound from ScadaLink:Transport:SourceEnvironment, defaults "scadalink"),
filename pattern scadabundle-{env}-{yyyy-MM-dd-HHmmss}.scadabundle.
Tests (bUnit + policy): step 1 group rendering, step 2 dependency
expansion (Pump composes Motor), step 4 full walkthrough verifying
ExportAsync receives the selected ids + authenticated identity, and a
RequireDesign policy-deny test for users without the Design role. Also
unit-pins the filename-sanitisation contract.
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
@page "/design/transport/export"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Templates
|
||||
@using ScadaLink.Commons.Entities.Scripts
|
||||
@using ScadaLink.Commons.Entities.ExternalSystems
|
||||
@using ScadaLink.Commons.Entities.Notifications
|
||||
@using ScadaLink.Commons.Entities.InboundApi
|
||||
@using ScadaLink.CentralUI.Components.Shared
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
|
||||
@*
|
||||
TransportExport wizard (Component #24, Task T21).
|
||||
|
||||
A 4-step linear wizard:
|
||||
Step 1 — Select : templates (tree, checkbox-mode) + flat artifact lists.
|
||||
Step 2 — Review : resolved closure split into seed vs auto-included.
|
||||
Step 3 — Encrypt : passphrase + confirm, or explicit unencrypted opt-out.
|
||||
Step 4 — Download : streams the bundle bytes to the browser via JS interop.
|
||||
|
||||
The page is Design-role gated; deeper interactions (audit row, secrets
|
||||
warning) come from BundleExporter itself.
|
||||
*@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h4 class="mb-3">Export Bundle</h4>
|
||||
|
||||
@* Step indicator — Bootstrap progress with discrete numbered pills. *@
|
||||
<nav aria-label="Export wizard steps" class="mb-4">
|
||||
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
|
||||
<li class="@StepClass(ExportWizardStep.Select)">
|
||||
<span class="badge rounded-pill me-1">1</span> Select
|
||||
</li>
|
||||
<li class="@StepClass(ExportWizardStep.Review)">
|
||||
<span class="badge rounded-pill me-1">2</span> Review
|
||||
</li>
|
||||
<li class="@StepClass(ExportWizardStep.Encrypt)">
|
||||
<span class="badge rounded-pill me-1">3</span> Encrypt
|
||||
</li>
|
||||
<li class="@StepClass(ExportWizardStep.Download)">
|
||||
<span class="badge rounded-pill me-1">4</span> Download
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@switch (_step)
|
||||
{
|
||||
case ExportWizardStep.Select:
|
||||
@RenderStepSelect();
|
||||
break;
|
||||
case ExportWizardStep.Review:
|
||||
@RenderStepReview();
|
||||
break;
|
||||
case ExportWizardStep.Encrypt:
|
||||
@RenderStepEncrypt();
|
||||
break;
|
||||
case ExportWizardStep.Download:
|
||||
@RenderStepDownload();
|
||||
break;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string StepClass(ExportWizardStep s) =>
|
||||
s == _step ? "fw-semibold text-primary"
|
||||
: (int)s < (int)_step ? "text-success"
|
||||
: "text-muted";
|
||||
|
||||
// ============================================================
|
||||
// Step 1 — Select
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepSelect() => __builder =>
|
||||
{
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<label for="export-filter" class="form-label">Search</label>
|
||||
<input id="export-filter" type="search" class="form-control"
|
||||
placeholder="Filter all artifacts…"
|
||||
@bind="_filter" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-templates">
|
||||
<legend class="h6">Templates</legend>
|
||||
@if (_templates.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic">No templates.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height: 320px; overflow-y: auto; padding: 4px; border: 1px solid var(--bs-border-color); border-radius: 4px;">
|
||||
<TemplateFolderTree Folders="_folders"
|
||||
Templates="_templates"
|
||||
SelectionMode="TreeViewSelectionMode.Checkbox"
|
||||
SelectedKeys="_selectedTemplateKeys"
|
||||
SelectedKeysChanged="OnTemplateSelectionChanged"
|
||||
Filter="@_filter" />
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-shared-scripts">
|
||||
<legend class="h6">Shared Scripts</legend>
|
||||
@RenderCheckboxList(_sharedScripts, s => s.Id, s => s.Name, _selectedSharedScripts)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-external-systems">
|
||||
<legend class="h6">External Systems</legend>
|
||||
@RenderCheckboxList(_externalSystems, e => e.Id, e => e.Name, _selectedExternalSystems)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-db-connections">
|
||||
<legend class="h6">Database Connections</legend>
|
||||
@RenderCheckboxList(_dbConnections, d => d.Id, d => d.Name, _selectedDbConnections)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-notification-lists">
|
||||
<legend class="h6">Notification Lists</legend>
|
||||
@RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-smtp-configs">
|
||||
<legend class="h6">SMTP Configurations</legend>
|
||||
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-keys">
|
||||
<legend class="h6">API Keys</legend>
|
||||
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-methods">
|
||||
<legend class="h6">API Methods</legend>
|
||||
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
<button class="btn btn-primary"
|
||||
disabled="@(!HasAnySelection || _resolving)"
|
||||
@onclick="GoToReviewAsync">
|
||||
@(_resolving ? "Resolving…" : "Next")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private void OnTemplateSelectionChanged(HashSet<object> keys)
|
||||
{
|
||||
// TemplateFolderTree hands back a fresh HashSet each time; mirror it
|
||||
// into our owned set so subsequent renders see the same instance the
|
||||
// tree is binding against.
|
||||
_selectedTemplateKeys.Clear();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
_selectedTemplateKeys.Add(k);
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment RenderCheckboxList<T>(
|
||||
IReadOnlyList<T> items,
|
||||
Func<T, int> idOf,
|
||||
Func<T, string> nameOf,
|
||||
HashSet<int> selected) => __builder =>
|
||||
{
|
||||
var visible = items.Where(x => MatchesFilter(nameOf(x))).ToList();
|
||||
if (visible.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic">No matches.</div>
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="d-flex flex-column gap-1">
|
||||
@foreach (var item in visible)
|
||||
{
|
||||
var id = idOf(item);
|
||||
var inputId = $"chk-{typeof(T).Name}-{id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="@inputId"
|
||||
checked="@selected.Contains(id)"
|
||||
@onchange="e => Toggle(selected, id, ((bool?)e.Value) == true)" />
|
||||
<label class="form-check-label" for="@inputId">@nameOf(item)</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 2 — Review
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepReview() => __builder =>
|
||||
{
|
||||
if (_resolved is null)
|
||||
{
|
||||
<div class="alert alert-warning">Nothing resolved yet — please go back to step 1.</div>
|
||||
return;
|
||||
}
|
||||
|
||||
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
|
||||
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
|
||||
var seedExternalSystems = new HashSet<int>(_selectedExternalSystems);
|
||||
|
||||
var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id);
|
||||
var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id);
|
||||
var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id);
|
||||
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
The resolver walked your selection's dependency graph and produced the closure
|
||||
below. Items under <em>Auto-included</em> were pulled in because the items you
|
||||
ticked reference them; unticking
|
||||
<em>Include all dependencies</em> exports the seed alone.
|
||||
</p>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="include-deps"
|
||||
checked="@_includeDependencies"
|
||||
@onchange="async e => { _includeDependencies = ((bool?)e.Value) == true; await ReresolveAsync(); }" />
|
||||
<label class="form-check-label" for="include-deps">Include all dependencies</label>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6" data-testid="seed-group">
|
||||
<h6>Selected by you</h6>
|
||||
<ul class="small list-unstyled mb-0">
|
||||
@foreach (var t in _resolved.Templates.Where(t => seedTemplateIds.Contains(t.Id)).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Template: @t.Name</li>
|
||||
}
|
||||
@foreach (var s in _resolved.SharedScripts.Where(s => seedSharedScripts.Contains(s.Id)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>SharedScript: @s.Name</li>
|
||||
}
|
||||
@foreach (var e in _resolved.ExternalSystems.Where(e => seedExternalSystems.Contains(e.Id)).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ExternalSystem: @e.Name</li>
|
||||
}
|
||||
@foreach (var d in _resolved.DatabaseConnections.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>DatabaseConnection: @d.Name</li>
|
||||
}
|
||||
@foreach (var n in _resolved.NotificationLists.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>NotificationList: @n.Name</li>
|
||||
}
|
||||
@foreach (var s in _resolved.SmtpConfigs.OrderBy(s => s.Host, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>SmtpConfig: @s.Host</li>
|
||||
}
|
||||
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiKey: @k.Name</li>
|
||||
}
|
||||
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiMethod: @m.Name</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6" data-testid="auto-group">
|
||||
<h6>Auto-included (dependencies)</h6>
|
||||
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0)
|
||||
{
|
||||
<div class="small text-muted fst-italic">No additional dependencies.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="small list-unstyled mb-0">
|
||||
@foreach (var f in _resolved.TemplateFolders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>TemplateFolder: @f.Name</li>
|
||||
}
|
||||
@foreach (var t in autoTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Template: @t.Name</li>
|
||||
}
|
||||
@foreach (var s in autoShared.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>SharedScript: @s.Name</li>
|
||||
}
|
||||
@foreach (var e in autoExternals.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ExternalSystem: @e.Name</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToSelect">Back</button>
|
||||
<button class="btn btn-primary" @onclick="GoToEncrypt">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 3 — Encrypt
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepEncrypt() => __builder =>
|
||||
{
|
||||
var strength = PassphraseStrength(_passphrase);
|
||||
var strengthLabel = strength switch
|
||||
{
|
||||
0 => "Too short",
|
||||
1 => "Weak",
|
||||
2 => "Fair",
|
||||
3 => "Good",
|
||||
_ => "Strong",
|
||||
};
|
||||
var strengthColor = strength switch
|
||||
{
|
||||
<= 1 => "bg-danger",
|
||||
2 => "bg-warning",
|
||||
3 => "bg-info",
|
||||
_ => "bg-success",
|
||||
};
|
||||
|
||||
<div>
|
||||
@if (_secretCount > 0)
|
||||
{
|
||||
<div class="alert alert-warning" role="alert" data-testid="secrets-warning">
|
||||
<strong>@_secretCount</strong> secret @(_secretCount == 1 ? "field" : "fields")
|
||||
will be encrypted (external-system credentials, SMTP credentials, and database
|
||||
connection strings).
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info small" role="alert" data-testid="secrets-warning">
|
||||
The selected closure contains no secret fields, but the bundle's content
|
||||
payload will still be encrypted in full when a passphrase is supplied.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="passphrase" class="form-label">Passphrase</label>
|
||||
<input id="passphrase" type="password" class="form-control"
|
||||
autocomplete="new-password"
|
||||
@bind="_passphrase" @bind:event="oninput" />
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
<div class="progress-bar @strengthColor"
|
||||
role="progressbar"
|
||||
style="width: @(strength * 25)%;"
|
||||
aria-valuenow="@(strength * 25)" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<div class="form-text">Strength: @strengthLabel · minimum 8 characters.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="passphrase-confirm" class="form-label">Confirm passphrase</label>
|
||||
<input id="passphrase-confirm" type="password" class="form-control"
|
||||
autocomplete="new-password"
|
||||
@bind="_passphraseConfirm" @bind:event="oninput" />
|
||||
@if (!string.IsNullOrEmpty(_passphraseConfirm) && _passphrase != _passphraseConfirm)
|
||||
{
|
||||
<div class="form-text text-danger">Passphrases do not match.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="small">
|
||||
<a href="javascript:void(0)" class="link-danger" @onclick="OpenUnencryptedConfirm">
|
||||
Export without encryption…
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@if (_showUnencryptedConfirm)
|
||||
{
|
||||
<div class="alert alert-danger" data-testid="unencrypted-confirm">
|
||||
<strong>Unencrypted export</strong> — the bundle will contain all secret fields
|
||||
in plaintext. Anyone with the file can read external-system credentials, SMTP
|
||||
passwords, and database connection strings. The audit log will record this as
|
||||
<code>UnencryptedBundleExport</code>.
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
|
||||
Yes, export without encryption
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
||||
<button class="btn btn-primary"
|
||||
disabled="@(!PassphraseValid)"
|
||||
@onclick="StartEncryptedExportAsync">
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 4 — Download
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepDownload() => __builder =>
|
||||
{
|
||||
<div>
|
||||
@if (_downloadInProgress)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
<p class="text-body-secondary">Building bundle…</p>
|
||||
}
|
||||
else if (_downloadError != null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>Export failed:</strong> @_downloadError
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-success" data-testid="download-summary">
|
||||
<strong>Bundle ready.</strong> Your browser is downloading the file.
|
||||
</div>
|
||||
|
||||
<dl class="row small">
|
||||
<dt class="col-sm-3">Filename</dt>
|
||||
<dd class="col-sm-9"><code>@_downloadFilename</code></dd>
|
||||
|
||||
<dt class="col-sm-3">Size</dt>
|
||||
<dd class="col-sm-9">@FormatBytes(_downloadSize)</dd>
|
||||
|
||||
<dt class="col-sm-3">SHA-256</dt>
|
||||
<dd class="col-sm-9"><code>@_downloadSha256</code></dd>
|
||||
|
||||
<dt class="col-sm-3">Encryption</dt>
|
||||
<dd class="col-sm-9">
|
||||
@if (_exportUnencrypted)
|
||||
{
|
||||
<span class="text-danger">Unencrypted (audited as <code>UnencryptedBundleExport</code>)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-success">AES-256-GCM with PBKDF2-SHA256</span>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<button class="btn btn-primary" @onclick="Done">Done</button>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes < 1024) return $"{bytes} B";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.0} KB";
|
||||
return $"{bytes / (1024.0 * 1024.0):0.00} MB";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.JSInterop;
|
||||
using ScadaLink.CentralUI.Auth;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.Transport;
|
||||
using ScadaLink.Transport.Export;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Pages.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the TransportExport wizard (Transport feature, Task T21).
|
||||
///
|
||||
/// Four-step state machine:
|
||||
/// <list type="number">
|
||||
/// <item><see cref="ExportWizardStep.Select"/> — pick templates + flat artifact lists.</item>
|
||||
/// <item><see cref="ExportWizardStep.Review"/> — show resolved closure (seed + auto-included).</item>
|
||||
/// <item><see cref="ExportWizardStep.Encrypt"/> — passphrase + secret-count warning, or explicit unencrypted opt-out.</item>
|
||||
/// <item><see cref="ExportWizardStep.Download"/> — call <see cref="IBundleExporter"/>, render the file via JS interop.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The wizard transitions are linear; "Back" returns to the previous step
|
||||
/// without clearing selection state. "Done" on Step 4 resets to Step 1 fresh.
|
||||
///
|
||||
/// <c>SourceEnvironment</c> is sourced from <see cref="TransportOptions.SourceEnvironment"/>
|
||||
/// (bound from <c>ScadaLink:Transport:SourceEnvironment</c>) so a multi-cluster
|
||||
/// deployment can label its bundles distinctly. Defaults to <c>"scadalink"</c>.
|
||||
/// </summary>
|
||||
public partial class TransportExport : ComponentBase
|
||||
{
|
||||
public enum ExportWizardStep
|
||||
{
|
||||
Select = 1,
|
||||
Review = 2,
|
||||
Encrypt = 3,
|
||||
Download = 4,
|
||||
}
|
||||
|
||||
// ---- Injected services ----
|
||||
[Inject] private IBundleExporter BundleExporter { get; set; } = default!;
|
||||
[Inject] private ITemplateEngineRepository TemplateRepo { get; set; } = default!;
|
||||
[Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!;
|
||||
[Inject] private INotificationRepository NotificationRepo { get; set; } = default!;
|
||||
[Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!;
|
||||
[Inject] private DependencyResolver DepResolver { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
||||
[Inject] private IOptions<TransportOptions> TransportOptions { get; set; } = default!;
|
||||
|
||||
// ---- Wizard state ----
|
||||
private ExportWizardStep _step = ExportWizardStep.Select;
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
// ---- Step 1: source data ----
|
||||
private List<Template> _templates = new();
|
||||
private List<TemplateFolder> _folders = new();
|
||||
private List<SharedScript> _sharedScripts = new();
|
||||
private List<ExternalSystemDefinition> _externalSystems = new();
|
||||
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
||||
private List<NotificationList> _notificationLists = new();
|
||||
private List<SmtpConfiguration> _smtpConfigs = new();
|
||||
private List<ApiKey> _apiKeys = new();
|
||||
private List<ApiMethod> _apiMethods = new();
|
||||
|
||||
// ---- Step 1: selection state ----
|
||||
// TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key.
|
||||
// Templates are selected via the tree; the other artifacts use flat checkbox lists
|
||||
// backed by integer-id HashSets so wiring is uniform across categories.
|
||||
private readonly HashSet<object> _selectedTemplateKeys = new();
|
||||
private readonly HashSet<int> _selectedSharedScripts = new();
|
||||
private readonly HashSet<int> _selectedExternalSystems = new();
|
||||
private readonly HashSet<int> _selectedDbConnections = new();
|
||||
private readonly HashSet<int> _selectedNotificationLists = new();
|
||||
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
||||
private readonly HashSet<int> _selectedApiKeys = new();
|
||||
private readonly HashSet<int> _selectedApiMethods = new();
|
||||
private string _filter = string.Empty;
|
||||
private bool _includeDependencies = true;
|
||||
|
||||
// ---- Step 2: dependency-resolved closure ----
|
||||
private ResolvedExport? _resolved;
|
||||
private bool _resolving;
|
||||
|
||||
// ---- Step 3: encryption ----
|
||||
private string _passphrase = string.Empty;
|
||||
private string _passphraseConfirm = string.Empty;
|
||||
private bool _showUnencryptedConfirm;
|
||||
private bool _exportUnencrypted;
|
||||
private int _secretCount;
|
||||
|
||||
// ---- Step 4: download result ----
|
||||
private string? _downloadFilename;
|
||||
private long _downloadSize;
|
||||
private string? _downloadSha256;
|
||||
private bool _downloadInProgress;
|
||||
private string? _downloadError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAllAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAllAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_templates = (await TemplateRepo.GetAllTemplatesAsync()).ToList();
|
||||
_folders = (await TemplateRepo.GetAllFoldersAsync()).ToList();
|
||||
_sharedScripts = (await TemplateRepo.GetAllSharedScriptsAsync()).ToList();
|
||||
_externalSystems = (await ExternalRepo.GetAllExternalSystemsAsync()).ToList();
|
||||
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
|
||||
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
|
||||
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
||||
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList();
|
||||
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load export source data: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selection helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Project the tree's checkbox-keys back to template ids. Template keys are
|
||||
/// the strings produced by <c>TemplateTreeNode.Key</c> — folder ids are
|
||||
/// excluded (folders aren't directly exportable; their templates are).
|
||||
/// </summary>
|
||||
private IReadOnlyList<int> SelectedTemplateIds()
|
||||
{
|
||||
var ids = new List<int>();
|
||||
foreach (var key in _selectedTemplateKeys)
|
||||
{
|
||||
if (key is string s && s.StartsWith("t:", StringComparison.Ordinal)
|
||||
&& int.TryParse(s.AsSpan(2), out var id))
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the user has ticked at least one item in any category. Drives
|
||||
/// the "Next" button on Step 1.
|
||||
/// </summary>
|
||||
internal bool HasAnySelection =>
|
||||
SelectedTemplateIds().Count > 0
|
||||
|| _selectedSharedScripts.Count > 0
|
||||
|| _selectedExternalSystems.Count > 0
|
||||
|| _selectedDbConnections.Count > 0
|
||||
|| _selectedNotificationLists.Count > 0
|
||||
|| _selectedSmtpConfigs.Count > 0
|
||||
|| _selectedApiKeys.Count > 0
|
||||
|| _selectedApiMethods.Count > 0;
|
||||
|
||||
private bool PassphraseValid =>
|
||||
!string.IsNullOrEmpty(_passphrase)
|
||||
&& _passphrase.Length >= 8
|
||||
&& _passphrase == _passphraseConfirm;
|
||||
|
||||
/// <summary>
|
||||
/// Coarse strength score 0-4 based on length + character-class diversity. Used
|
||||
/// to colour an inline strength meter; never used to gate the export — the
|
||||
/// importer enforces its own strength + lockout policies.
|
||||
/// </summary>
|
||||
internal static int PassphraseStrength(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return 0;
|
||||
var score = 0;
|
||||
if (s.Length >= 8) score++;
|
||||
if (s.Length >= 16) score++;
|
||||
if (s.Any(char.IsUpper) && s.Any(char.IsLower)) score++;
|
||||
if (s.Any(char.IsDigit) && s.Any(ch => !char.IsLetterOrDigit(ch))) score++;
|
||||
return Math.Min(4, score);
|
||||
}
|
||||
|
||||
// ---- Wizard transitions ----
|
||||
|
||||
private ExportSelection BuildSelection()
|
||||
{
|
||||
return new ExportSelection(
|
||||
TemplateIds: SelectedTemplateIds(),
|
||||
SharedScriptIds: _selectedSharedScripts.ToList(),
|
||||
ExternalSystemIds: _selectedExternalSystems.ToList(),
|
||||
DatabaseConnectionIds: _selectedDbConnections.ToList(),
|
||||
NotificationListIds: _selectedNotificationLists.ToList(),
|
||||
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
|
||||
ApiKeyIds: _selectedApiKeys.ToList(),
|
||||
ApiMethodIds: _selectedApiMethods.ToList(),
|
||||
IncludeDependencies: _includeDependencies);
|
||||
}
|
||||
|
||||
private async Task GoToReviewAsync()
|
||||
{
|
||||
if (!HasAnySelection) return;
|
||||
_resolving = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
var selection = BuildSelection();
|
||||
_resolved = await DepResolver.ResolveAsync(selection, CancellationToken.None);
|
||||
_step = ExportWizardStep.Review;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to resolve dependencies: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReresolveAsync()
|
||||
{
|
||||
// Re-run resolution from Step 2 when the user toggles IncludeDependencies.
|
||||
_resolving = true;
|
||||
try
|
||||
{
|
||||
_resolved = await DepResolver.ResolveAsync(BuildSelection(), CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GoToEncrypt()
|
||||
{
|
||||
// Recompute the secret-field count from the resolved closure so the
|
||||
// warning banner stays honest if the user backed up and re-resolved.
|
||||
if (_resolved is not null)
|
||||
{
|
||||
_secretCount = CountSecrets(_resolved);
|
||||
}
|
||||
_step = ExportWizardStep.Encrypt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count the secret fields that <see cref="BundleSecretEncryptor"/> will
|
||||
/// envelope-encrypt. Surfaces in the Step 3 warning banner so the user
|
||||
/// knows exactly what an unencrypted export would leak.
|
||||
/// </summary>
|
||||
internal static int CountSecrets(ResolvedExport resolved)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var es in resolved.ExternalSystems)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(es.AuthConfiguration)) count++;
|
||||
}
|
||||
foreach (var smtp in resolved.SmtpConfigs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(smtp.Credentials)) count++;
|
||||
}
|
||||
foreach (var db in resolved.DatabaseConnections)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void BackToSelect() => _step = ExportWizardStep.Select;
|
||||
private void BackToReview() => _step = ExportWizardStep.Review;
|
||||
|
||||
private void OpenUnencryptedConfirm()
|
||||
{
|
||||
_showUnencryptedConfirm = true;
|
||||
}
|
||||
|
||||
private async Task ConfirmUnencryptedExport()
|
||||
{
|
||||
_showUnencryptedConfirm = false;
|
||||
_exportUnencrypted = true;
|
||||
await StartExportAsync(passphrase: null);
|
||||
}
|
||||
|
||||
private void CancelUnencryptedConfirm()
|
||||
{
|
||||
_showUnencryptedConfirm = false;
|
||||
}
|
||||
|
||||
private async Task StartEncryptedExportAsync()
|
||||
{
|
||||
if (!PassphraseValid) return;
|
||||
_exportUnencrypted = false;
|
||||
await StartExportAsync(_passphrase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final export step: invokes <see cref="IBundleExporter.ExportAsync"/>,
|
||||
/// captures the bundle bytes, computes a display-side SHA-256 (matching
|
||||
/// the manifest's content hash naming), and pushes the file to the browser
|
||||
/// via JS interop. Errors surface inline; the page never throws to the user.
|
||||
/// </summary>
|
||||
private async Task StartExportAsync(string? passphrase)
|
||||
{
|
||||
_step = ExportWizardStep.Download;
|
||||
_downloadInProgress = true;
|
||||
_downloadError = null;
|
||||
try
|
||||
{
|
||||
var user = await Auth.GetCurrentUsernameAsync();
|
||||
var sourceEnv = TransportOptions.Value.SourceEnvironment;
|
||||
if (string.IsNullOrWhiteSpace(sourceEnv))
|
||||
{
|
||||
sourceEnv = "scadalink";
|
||||
}
|
||||
|
||||
var selection = BuildSelection();
|
||||
await using var stream = await BundleExporter.ExportAsync(
|
||||
selection, user, sourceEnv, passphrase, CancellationToken.None);
|
||||
|
||||
byte[] bytes;
|
||||
if (stream is MemoryStream ms)
|
||||
{
|
||||
bytes = ms.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
using var copy = new MemoryStream();
|
||||
await stream.CopyToAsync(copy);
|
||||
bytes = copy.ToArray();
|
||||
}
|
||||
|
||||
_downloadSize = bytes.LongLength;
|
||||
_downloadSha256 = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
_downloadFilename = BuildFilename(sourceEnv);
|
||||
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
await JS.InvokeVoidAsync("scadalinkTransport.downloadBundle", _downloadFilename, base64);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_downloadError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filename pattern <c>scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle</c>.
|
||||
/// Source environment characters are sanitised to a filename-safe alphabet so
|
||||
/// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce
|
||||
/// browser-rejected filenames.
|
||||
/// </summary>
|
||||
internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
|
||||
{
|
||||
var safe = SanitizeForFilename(sourceEnvironment);
|
||||
var ts = (nowUtc ?? DateTimeOffset.UtcNow).ToString("yyyy-MM-dd-HHmmss");
|
||||
return $"scadabundle-{safe}-{ts}.scadabundle";
|
||||
}
|
||||
|
||||
private static string SanitizeForFilename(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return "scadalink";
|
||||
var chars = input.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private void Done()
|
||||
{
|
||||
// Reset every wizard piece so the operator can immediately start a fresh
|
||||
// export without page-refresh-induced data reload.
|
||||
_step = ExportWizardStep.Select;
|
||||
_selectedTemplateKeys.Clear();
|
||||
_selectedSharedScripts.Clear();
|
||||
_selectedExternalSystems.Clear();
|
||||
_selectedDbConnections.Clear();
|
||||
_selectedNotificationLists.Clear();
|
||||
_selectedSmtpConfigs.Clear();
|
||||
_selectedApiKeys.Clear();
|
||||
_selectedApiMethods.Clear();
|
||||
_filter = string.Empty;
|
||||
_includeDependencies = true;
|
||||
_resolved = null;
|
||||
_passphrase = string.Empty;
|
||||
_passphraseConfirm = string.Empty;
|
||||
_exportUnencrypted = false;
|
||||
_showUnencryptedConfirm = false;
|
||||
_downloadFilename = null;
|
||||
_downloadSize = 0;
|
||||
_downloadSha256 = null;
|
||||
_downloadError = null;
|
||||
}
|
||||
|
||||
// ---- Flat-list filter helpers (search box reuses TemplateFolderTree.Filter for the tree) ----
|
||||
private bool MatchesFilter(string name) =>
|
||||
string.IsNullOrWhiteSpace(_filter)
|
||||
|| name.Contains(_filter, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void Toggle(HashSet<int> set, int id, bool value)
|
||||
{
|
||||
if (value) set.Add(id);
|
||||
else set.Remove(id);
|
||||
}
|
||||
|
||||
// ---- Step 2 grouping helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Items that are in <paramref name="all"/> but NOT in <paramref name="seed"/> —
|
||||
/// the auto-included dependencies the resolver pulled in for the user.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf)
|
||||
{
|
||||
return all.Where(x => !seed.Contains(idOf(x))).ToList();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
<ProjectReference Include="../ScadaLink.DeploymentManager/ScadaLink.DeploymentManager.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.Transport/ScadaLink.Transport.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
20
src/ScadaLink.CentralUI/wwwroot/js/transport.js
Normal file
20
src/ScadaLink.CentralUI/wwwroot/js/transport.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Transport bundle export — browser-side download trigger.
|
||||
//
|
||||
// The TransportExport wizard (T21) computes a bundle byte[] server-side, encodes
|
||||
// it to base64, and hands it to this helper to push to the user's browser as a
|
||||
// file download. The base64 detour avoids binary marshalling headaches over the
|
||||
// SignalR JS-interop boundary — Blazor Server cannot stream a byte[] back to
|
||||
// the browser directly without a [JSInvokable] dance.
|
||||
//
|
||||
// The link is built fresh and removed each call so back-to-back exports don't
|
||||
// keep stale URLs around in the DOM.
|
||||
window.scadalinkTransport = {
|
||||
downloadBundle: function (filename, base64Data) {
|
||||
const link = document.createElement('a');
|
||||
link.href = "data:application/octet-stream;base64," + base64Data;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
@@ -80,6 +80,7 @@
|
||||
<script src="_content/ScadaLink.CentralUI/js/nav-state.js"></script>
|
||||
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
|
||||
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
|
||||
<script src="_content/ScadaLink.CentralUI/js/transport.js"></script>
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,4 +8,13 @@ public sealed class TransportOptions
|
||||
public int MaxUnlockAttemptsPerIpPerHour { get; set; } = 10;
|
||||
public int Pbkdf2Iterations { get; set; } = 600_000;
|
||||
public int SchemaVersionMajor { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name of the cluster/environment producing bundles. Stamped
|
||||
/// into <c>BundleManifest.SourceEnvironment</c> and surfaced in the export
|
||||
/// filename (<c>scadabundle-{SourceEnvironment}-{yyyy-MM-dd-HHmmss}.scadabundle</c>).
|
||||
/// Bound from <c>Transport:SourceEnvironment</c> in <c>appsettings*.json</c>;
|
||||
/// the default placeholder is fine for single-cluster deployments.
|
||||
/// </summary>
|
||||
public string SourceEnvironment { get; set; } = "scadalink";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.Security;
|
||||
using ScadaLink.Transport;
|
||||
using ScadaLink.Transport.Export;
|
||||
using TransportExportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportExport;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Pages.Design;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
|
||||
///
|
||||
/// <para>
|
||||
/// Covers the four contract points the design plan calls out:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>Step 1 renders the template tree plus every flat artifact group.</item>
|
||||
/// <item>Step 2 surfaces the dependency-resolved closure (seed vs auto-included).</item>
|
||||
/// <item>Step 4 invokes <see cref="IBundleExporter.ExportAsync"/> with the user's
|
||||
/// selected ids and authenticated identity.</item>
|
||||
/// <item>The page-level <c>RequireDesign</c> policy denies a user lacking the
|
||||
/// Design role (router enforcement; the component code-behind never sees
|
||||
/// the request).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// JS interop is set to loose mode so the TreeView's sessionStorage round-trip
|
||||
/// and the transport-bundle download interop don't need stubs per test. The
|
||||
/// <c>scadalinkTransport.downloadBundle</c> call returns void — loose mode is
|
||||
/// the lighter wiring than re-stubbing it in every export-path test.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class TransportExportPageTests : BunitContext
|
||||
{
|
||||
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
|
||||
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
|
||||
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
|
||||
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
|
||||
|
||||
public TransportExportPageTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
// Default empty repos so OnInitializedAsync doesn't throw — individual
|
||||
// tests override the bits they care about.
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
||||
_templateRepo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
||||
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript>()));
|
||||
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(new List<ExternalSystemDefinition>()));
|
||||
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(new List<DatabaseConnectionDefinition>()));
|
||||
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList>()));
|
||||
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
|
||||
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey>()));
|
||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
|
||||
Services.AddSingleton(_templateRepo);
|
||||
Services.AddSingleton(_externalRepo);
|
||||
Services.AddSingleton(_notificationRepo);
|
||||
Services.AddSingleton(_inboundApiRepo);
|
||||
Services.AddSingleton(_exporter);
|
||||
// DependencyResolver is sealed but its only dependencies are the four
|
||||
// repositories above — registering the concrete type is enough.
|
||||
Services.AddSingleton<DependencyResolver>();
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
SourceEnvironment = "test-cluster",
|
||||
}));
|
||||
|
||||
var principal = BuildPrincipal("alice", "Design");
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, username) };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 1: Step 1 renders the template tree and every flat artifact group.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Renders_step1_with_template_tree_and_artifact_checkboxes()
|
||||
{
|
||||
// A single template + a couple of artifacts so the lists aren't empty.
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
var script = new SharedScript("Helpers", "// noop") { Id = 10 };
|
||||
var externalSystem = new ExternalSystemDefinition("ERP", "https://erp.example.com", "ApiKey")
|
||||
{
|
||||
Id = 20,
|
||||
};
|
||||
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
|
||||
var notifList = new NotificationList("Ops") { Id = 40 };
|
||||
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
|
||||
var apiKey = new ApiKey("ext-system", "key-hash") { Id = 60 };
|
||||
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
|
||||
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
|
||||
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript> { script }));
|
||||
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(
|
||||
new List<ExternalSystemDefinition> { externalSystem }));
|
||||
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(
|
||||
new List<DatabaseConnectionDefinition> { db }));
|
||||
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
|
||||
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
|
||||
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey> { apiKey }));
|
||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
||||
|
||||
// All six flat groups (plus templates) are present.
|
||||
foreach (var groupId in new[]
|
||||
{
|
||||
"group-templates",
|
||||
"group-shared-scripts",
|
||||
"group-external-systems",
|
||||
"group-db-connections",
|
||||
"group-notification-lists",
|
||||
"group-smtp-configs",
|
||||
"group-api-keys",
|
||||
"group-api-methods",
|
||||
})
|
||||
{
|
||||
Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
|
||||
}
|
||||
|
||||
// Sanity: each artifact shows its label.
|
||||
Assert.Contains("Helpers", cut.Markup);
|
||||
Assert.Contains("ERP", cut.Markup);
|
||||
Assert.Contains("Hist", cut.Markup);
|
||||
Assert.Contains("Ops", cut.Markup);
|
||||
Assert.Contains("smtp.example.com", cut.Markup);
|
||||
Assert.Contains("ext-system", cut.Markup);
|
||||
Assert.Contains("CreateOrder", cut.Markup);
|
||||
|
||||
// Next button is disabled while no selection exists.
|
||||
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
|
||||
Assert.True(next.HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 2: Step 2 shows resolved dependencies — auto-included templates pulled
|
||||
// in because a seed template composes them.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Step2_shows_resolved_dependencies_after_clicking_next()
|
||||
{
|
||||
// Seed template "Pump" composes "Motor". The user selects Pump only;
|
||||
// the resolver pulls Motor in transitively.
|
||||
var pump = new Template("Pump") { Id = 1 };
|
||||
pump.Compositions.Add(new TemplateComposition("MotorSlot")
|
||||
{
|
||||
Id = 100,
|
||||
ComposedTemplateId = 2,
|
||||
});
|
||||
var motor = new Template("Motor") { Id = 2 };
|
||||
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { pump, motor }));
|
||||
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Template?>(pump));
|
||||
_templateRepo.GetTemplateWithChildrenAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Template?>(motor));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
||||
|
||||
// The template-tree renders a checkbox per node — tick the one whose
|
||||
// sibling label is "Pump". (TemplateFolderTree uses .tv-checkbox.)
|
||||
var pumpRow = cut.FindAll("li[role='treeitem']")
|
||||
.First(li => li.TextContent.Contains("Pump"));
|
||||
var checkbox = pumpRow.QuerySelector("input.tv-checkbox");
|
||||
Assert.NotNull(checkbox);
|
||||
checkbox!.Change(true);
|
||||
|
||||
// Click "Next" to advance to Step 2; the resolver call is awaited
|
||||
// inside GoToReviewAsync — bUnit's WaitForState handles the re-render.
|
||||
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
|
||||
await next.ClickAsync(new());
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Step 2 shows the seed/auto split — Motor lands under "Auto-included".
|
||||
var autoGroup = cut.Find("[data-testid='auto-group']");
|
||||
Assert.Contains("Motor", autoGroup.TextContent);
|
||||
});
|
||||
|
||||
var seedGroup = cut.Find("[data-testid='seed-group']");
|
||||
Assert.Contains("Pump", seedGroup.TextContent);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync
|
||||
// is invoked with the user-selected ids and the authenticated identity.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
|
||||
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Template?>(template));
|
||||
|
||||
// Exporter returns a tiny in-memory bundle stream.
|
||||
_exporter
|
||||
.ExportAsync(
|
||||
Arg.Any<ExportSelection>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
||||
|
||||
// Tick Pump.
|
||||
var pumpCheckbox = cut.FindAll("li[role='treeitem']")
|
||||
.First(li => li.TextContent.Contains("Pump"))
|
||||
.QuerySelector("input.tv-checkbox");
|
||||
Assert.NotNull(pumpCheckbox);
|
||||
pumpCheckbox!.Change(true);
|
||||
|
||||
// Advance Step 1 → 2.
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
|
||||
|
||||
// Advance Step 2 → 3.
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
|
||||
|
||||
// Fill matching passphrases. The inputs are wired with @bind:event="oninput",
|
||||
// so use Input() rather than Change() to fire the right event.
|
||||
var passphraseInput = cut.Find("#passphrase");
|
||||
passphraseInput.Input("hunter2hunter2");
|
||||
var confirmInput = cut.Find("#passphrase-confirm");
|
||||
confirmInput.Input("hunter2hunter2");
|
||||
|
||||
// Click "Export" — the only enabled button labeled "Export" at this step.
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
|
||||
|
||||
// Step 4 renders the download summary once ExportAsync resolves.
|
||||
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
|
||||
|
||||
await _exporter.Received(1).ExportAsync(
|
||||
Arg.Is<ExportSelection>(s =>
|
||||
s.TemplateIds.Contains(1)
|
||||
&& s.IncludeDependencies),
|
||||
"alice",
|
||||
"test-cluster",
|
||||
"hunter2hunter2",
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 4: A user without the Design role fails the RequireDesign policy.
|
||||
// The router enforces [Authorize(Policy=...)] at request time — bUnit
|
||||
// doesn't model routing, so we verify the policy itself denies the
|
||||
// principal (the same gate the router consults).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Page_returns_unauthorized_for_user_without_Design_role()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddScadaLinkAuthorization();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
// Audit-only user — has a role but it isn't Design.
|
||||
var principal = BuildPrincipal("bob", "Audit");
|
||||
var result = await authService.AuthorizeAsync(
|
||||
principal, null, AuthorizationPolicies.RequireDesign);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Static helpers — exercised directly so the file-naming + secret-count
|
||||
// contract is unit-pinned independently of the rendering surface.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void BuildFilename_produces_pattern_and_sanitises_source_environment()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2026, 5, 24, 13, 45, 22, TimeSpan.Zero);
|
||||
var filename = TransportExportPage.BuildFilename("dev/cluster a", fixedTime);
|
||||
Assert.Equal("scadabundle-dev-cluster-a-2026-05-24-134522.scadabundle", filename);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user