205a06a0f1
Exports can now carry a detached ECDSA P-256 signature over the exact
manifest.json bytes in a new optional manifest.sig zip entry. The manifest
already pins the content hash, so a verified signature covers the whole
bundle, including the plaintext source environment the confirm step relies
on. Off by default (ScadaBridge:Transport:Signing): with the defaults,
exports are unsigned and imports accept signed and unsigned bundles.
- The bundle format does not change: bundleFormatVersion stays 1,
manifest.json and the AES-GCM associated data are untouched, and older
importers open entries by name and ignore manifest.sig. Three entries fit
within the default MaxBundleEntryCount of 4.
- Import policy runs after the content-hash check and before any passphrase
work. A signature that fails to verify against a trusted key is always
refused. RequireSignedBundles additionally refuses unsigned bundles and
bundles signed by an untrusted key. A refusal is BundleSignatureException
(Commons), shown on the UI upload step and on CLI preview/import.
- The signature status, key id and signer are recorded on BundleSession and
shown in the import wizard. They are also returned on PreviewBundleResult
and recorded on the BundleImported audit row. BundleExported records
Signed/SigningKeyId.
- Key material is pluggable. IBundleSigningKeySource and IBundleTrustStore
are TryAdd'd; the defaults read Signing:PrivateKey (meant to be a
${secret:} reference) and Signing:TrustedKeys. Key ids are SPKI
fingerprints. Startup validation rejects unparseable or non-P-256 keys.
- Compatibility proof: the checked-in fixtures pre-signing-plaintext and
pre-signing-encrypted were exported by origin/main 3ff463b6, before this
change, and are imported end to end with signing off and with signing
configured.
The design leaves key-per-env vs per-org, trust distribution and rotation,
and the production default for RequireSignedBundles open. These are listed
in Component-Transport.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
1074 lines
55 KiB
C#
1074 lines
55 KiB
C#
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
|
|
|
|
/// <summary>
|
|
/// bUnit + logic tests for the TransportImport wizard.
|
|
///
|
|
/// <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>ZB.MOM.WW.ScadaBridge.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>ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public class TransportImportPageTests : BunitContext
|
|
{
|
|
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
|
|
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
|
|
// The Map step injects ISiteRepository to populate target-site +
|
|
// target-connection dropdowns. Register a substitute so every test (not just
|
|
// the Map tests) can render the wizard without a missing-service failure.
|
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
|
|
|
/// <summary>Client address the wizard reports for the per-IP unlock throttle (register row 44).</summary>
|
|
private const string TestClientIp = "203.0.113.7";
|
|
|
|
/// <summary>Initializes the test context with stubbed importer, audit, repository and authentication services.</summary>
|
|
public TransportImportPageTests()
|
|
{
|
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
|
|
|
Services.AddSingleton(_importer);
|
|
Services.AddSingleton(_auditService);
|
|
|
|
// Register row 44: the wizard passes the circuit's client IP to LoadAsync so
|
|
// the per-IP unlock throttle can attribute the attempt. bUnit has no HTTP
|
|
// context, so use the provider's explicit-address test seam.
|
|
Services.AddSingleton(new CircuitClientAddressProvider(TestClientIp));
|
|
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Array.Empty<Site>());
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Array.Empty<DataConnection>());
|
|
Services.AddSingleton(_siteRepo);
|
|
Services.AddSingleton<IOptions<TransportOptions>>(
|
|
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
|
{
|
|
MaxBundleSizeMb = 10,
|
|
MaxUnlockAttemptsPerSession = 3,
|
|
}));
|
|
|
|
// Provide a SQLite in-memory ScadaBridgeDbContext so the page's
|
|
// DbContext.SaveChangesAsync() calls in the audit path succeed.
|
|
var dbOptions = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.UseSqlite("DataSource=:memory:")
|
|
.ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning))
|
|
.Options;
|
|
var dbContext = new ScadaBridgeDbContext(dbOptions);
|
|
dbContext.Database.OpenConnection();
|
|
dbContext.Database.EnsureCreated();
|
|
Services.AddSingleton(dbContext);
|
|
|
|
var principal = BuildPrincipal("alice", "Administrator");
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
|
Services.AddAuthorizationCore();
|
|
Services.AddTestConfigurationMutations();
|
|
}
|
|
|
|
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",
|
|
ScadaBridgeVersion: "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),
|
|
Contents: Array.Empty<ManifestContentEntry>()),
|
|
DecryptedContent = Array.Empty<byte>(),
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 1: Step 1 renders the InputFile upload control.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that the wizard renders the step 1 upload input.</summary>
|
|
[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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that a decryption failure increments the passphrase attempt counter.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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>(), Arg.Any<string?>())
|
|
.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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that three failed unlock attempts force the user to upload the bundle again.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Three_failed_unlocks_force_reupload()
|
|
{
|
|
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
|
.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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that the confirm step requires an exact environment name match.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that the apply step calls the bundle importer's <c>ApplyAsync</c> with the chosen resolutions.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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>(),
|
|
Arg.Any<BundleNameMap>())
|
|
.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>(),
|
|
Arg.Any<BundleNameMap>());
|
|
|
|
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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that the page is unauthorized for a user without the Admin role.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Page_returns_unauthorized_for_user_without_Admin_role()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddScadaBridgeAuthorization();
|
|
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", "Designer");
|
|
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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that default resolutions map each conflict kind to the expected action.</summary>
|
|
[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);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 8: the Map sub-section renders one row per required site /
|
|
// connection mapping with the auto-match defaults pre-selected, and the
|
|
// section is hidden entirely when the preview carries no required mappings.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that the map section renders the required rows with auto-match defaults selected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Map_section_renders_required_rows_with_automatch_defaults()
|
|
{
|
|
// Target environment has one site (site-b) with one connection (opc-main),
|
|
// which the bundle's source site "site-a" / connection "opc-a" auto-match to.
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
|
|
|
|
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
|
var preview = new ImportPreview(
|
|
session.SessionId,
|
|
new List<ImportPreviewItem>(),
|
|
new List<RequiredSiteMapping> { new("site-a", "Site A", "site-b") },
|
|
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
|
|
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
|
await cut.InvokeAsync(async () =>
|
|
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
|
cut.Render();
|
|
|
|
// Section present with one site row and one connection row.
|
|
Assert.NotNull(cut.Find("[data-testid='map-section']"));
|
|
Assert.Single(cut.FindAll("[data-testid='map-site-row']"));
|
|
Assert.Single(cut.FindAll("[data-testid='map-conn-row']"));
|
|
|
|
// Auto-match defaults: site-a → site-b, opc-a → opc-main.
|
|
var siteSelect = cut.Find("[data-testid='map-site-select-site-a']");
|
|
var selectedSiteOpt = siteSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
|
|
Assert.Equal("site-b", selectedSiteOpt.GetAttribute("value"));
|
|
|
|
var connSelect = cut.Find("[data-testid='map-conn-select-site-a-opc-a']");
|
|
var selectedConnOpt = connSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
|
|
Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value"));
|
|
}
|
|
|
|
/// <summary>Verifies that the map section is hidden when no mappings are required.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Map_section_hidden_when_no_required_mappings()
|
|
{
|
|
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
|
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
|
{
|
|
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
|
|
});
|
|
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
|
await cut.InvokeAsync(async () =>
|
|
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
|
cut.Render();
|
|
|
|
Assert.Empty(cut.FindAll("[data-testid='map-section']"));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 9: the operator's Map choices fold into the BundleNameMap
|
|
// passed to ApplyAsync — a chosen target → MapToExisting, "Create new" →
|
|
// CreateNew. Captured via the substituted IBundleImporter.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that apply passes a <c>BundleNameMap</c> built from the operator's map choices.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Apply_passes_BundleNameMap_built_from_map_choices()
|
|
{
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
|
|
|
|
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
|
var preview = new ImportPreview(
|
|
session.SessionId,
|
|
new List<ImportPreviewItem>(),
|
|
// site-a auto-matches site-b; site-c has no auto-match (→ Create new default).
|
|
new List<RequiredSiteMapping>
|
|
{
|
|
new("site-a", "Site A", "site-b"),
|
|
new("site-c", "Site C", null),
|
|
},
|
|
// opc-a (under site-a) auto-matches opc-main.
|
|
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
|
|
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
|
|
|
var expectedResult = new ImportResult(
|
|
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty<int>(), Guid.NewGuid().ToString());
|
|
_importer.ApplyAsync(
|
|
session.SessionId,
|
|
Arg.Any<IReadOnlyList<ImportResolution>>(),
|
|
"alice",
|
|
Arg.Any<CancellationToken>(),
|
|
Arg.Any<BundleNameMap>())
|
|
.Returns(expectedResult);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
|
await cut.InvokeAsync(async () =>
|
|
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
|
|
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
|
|
|
|
await _importer.Received(1).ApplyAsync(
|
|
session.SessionId,
|
|
Arg.Any<IReadOnlyList<ImportResolution>>(),
|
|
"alice",
|
|
Arg.Any<CancellationToken>(),
|
|
Arg.Is<BundleNameMap>(m =>
|
|
m.Sites.Count == 2
|
|
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-a"
|
|
&& s.Action == MappingAction.MapToExisting && s.TargetSiteIdentifier == "site-b")
|
|
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-c"
|
|
&& s.Action == MappingAction.CreateNew && s.TargetSiteIdentifier == null)
|
|
&& m.Connections.Count == 1
|
|
&& m.Connections.Any(c => c.SourceSiteIdentifier == "site-a"
|
|
&& c.SourceConnectionName == "opc-a"
|
|
&& c.Action == MappingAction.MapToExisting && c.TargetConnectionName == "opc-main")));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 10: a Modified row whose FieldDiffJson carries a code
|
|
// lineDiff renders +/- lines via LineDiffView.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that a modified row with a code line diff renders added and removed lines.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines()
|
|
{
|
|
const string fieldDiffJson = """
|
|
{
|
|
"changes": [
|
|
{
|
|
"field": "Code",
|
|
"oldValue": "a\nb\nc",
|
|
"newValue": "a\nB\nc",
|
|
"lineDiff": {
|
|
"hunks": [
|
|
{ "op": "context", "text": "a", "oldLineNo": 1, "newLineNo": 1 },
|
|
{ "op": "remove", "text": "b", "oldLineNo": 2 },
|
|
{ "op": "add", "text": "B", "newLineNo": 2 },
|
|
{ "op": "context", "text": "c", "oldLineNo": 3, "newLineNo": 3 }
|
|
],
|
|
"truncated": false,
|
|
"addedCount": 1,
|
|
"removedCount": 1
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
|
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
|
{
|
|
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
|
|
});
|
|
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
|
await cut.InvokeAsync(async () =>
|
|
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
|
cut.Render();
|
|
|
|
// The code line-diff block is present and shows one add + one remove line.
|
|
Assert.NotNull(cut.Find("[data-testid='code-line-diff']"));
|
|
Assert.NotNull(cut.Find("[data-testid='line-diff']"));
|
|
Assert.Single(cut.FindAll("[data-testid='line-diff-add']"));
|
|
Assert.Single(cut.FindAll("[data-testid='line-diff-remove']"));
|
|
// The raw JSON <pre> fallback is NOT used for code-field diffs.
|
|
Assert.DoesNotContain("\"lineDiff\"", cut.Find("[data-testid='code-line-diff']").InnerHtml);
|
|
// No truncation marker for a complete diff.
|
|
Assert.Empty(cut.FindAll("[data-testid='line-diff-truncated']"));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 11: truncation marker shows when the lineDiff is truncated.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that a modified row with a truncated line diff shows the truncation marker.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker()
|
|
{
|
|
const string fieldDiffJson = """
|
|
{
|
|
"changes": [
|
|
{
|
|
"field": "Code",
|
|
"oldValue": "x",
|
|
"newValue": "y",
|
|
"lineDiff": {
|
|
"hunks": [
|
|
{ "op": "remove", "text": "x", "oldLineNo": 1 },
|
|
{ "op": "add", "text": "y", "newLineNo": 1 }
|
|
],
|
|
"truncated": true,
|
|
"addedCount": 12,
|
|
"removedCount": 8
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
|
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
|
{
|
|
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
|
|
});
|
|
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
|
await cut.InvokeAsync(async () =>
|
|
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
|
cut.Render();
|
|
|
|
var marker = cut.Find("[data-testid='line-diff-truncated']");
|
|
Assert.Contains("truncated", marker.TextContent);
|
|
Assert.Contains("+12", marker.TextContent);
|
|
Assert.Contains("-8", marker.TextContent);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 12: changing a source site's dropdown to a target
|
|
// that lacks the previously auto-matched connection name resets that
|
|
// connection choice to CreateNew (""), and BuildNameMap therefore emits
|
|
// a CreateNew entry — not a MapToExisting for an absent connection.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that changing a site choice resets connection choices the new target site lacks.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task OnSiteChoiceChanged_resets_stale_connection_choices_when_new_target_lacks_connection()
|
|
{
|
|
// Target environment: site-b has connection "opc-main"; site-c has NO connections.
|
|
var siteB = new Site("Site B", "site-b") { Id = 7 };
|
|
var siteC = new Site("Site C", "site-c") { Id = 8 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Site> { siteB, siteC });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(8, Arg.Any<CancellationToken>())
|
|
.Returns(Array.Empty<DataConnection>());
|
|
|
|
// Bundle: source site "site-a" auto-matches site-b; connection "opc-a" auto-matches "opc-main".
|
|
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
|
var preview = new ImportPreview(
|
|
session.SessionId,
|
|
new List<ImportPreviewItem>(),
|
|
new List<RequiredSiteMapping> { new("site-a", "Site A", "site-b") },
|
|
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
|
|
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
|
// Drive LoadPreviewAndAdvanceAsync to seed auto-match state (site-a → site-b, opc-a → opc-main).
|
|
await cut.InvokeAsync(async () =>
|
|
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
|
|
|
// Verify the auto-match seed: connection choice is "opc-main".
|
|
var connChoicesBefore = GetField<Dictionary<(string, string), string>>(
|
|
cut.Instance, "_connectionChoices");
|
|
Assert.Equal("opc-main", connChoicesBefore[("site-a", "opc-a")]);
|
|
|
|
// Operator changes site-a's target from site-b → site-c (which has no connections).
|
|
await cut.InvokeAsync(async () =>
|
|
{
|
|
var method = cut.Instance.GetType().GetMethod(
|
|
"OnSiteChoiceChangedAsync",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
await (Task)method.Invoke(cut.Instance, new object[] { "site-a", "site-c" })!;
|
|
});
|
|
|
|
// Connection choice for (site-a, opc-a) must now be CreateNew ("").
|
|
var connChoicesAfter = GetField<Dictionary<(string, string), string>>(
|
|
cut.Instance, "_connectionChoices");
|
|
Assert.Equal(string.Empty, connChoicesAfter[("site-a", "opc-a")]);
|
|
|
|
// BuildNameMap must NOT emit MapToExisting for "opc-a" — it should emit CreateNew.
|
|
var nameMap = TransportImportPageTests.InvokeBuildNameMap(cut.Instance);
|
|
var connMapping = nameMap.Connections.Single(c => c.SourceConnectionName == "opc-a");
|
|
Assert.Equal(MappingAction.CreateNew, connMapping.Action);
|
|
Assert.Null(connMapping.TargetConnectionName);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 13: BackToUpload clears session/preview/Map state
|
|
// so a subsequent re-upload flow cannot inherit stale data.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
/// <summary>Verifies that going back to upload clears the session, preview and map state.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task BackToUpload_clears_session_and_preview_state()
|
|
{
|
|
// Seed the wizard at the Diff step with non-trivial session + preview + Map state.
|
|
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
|
var preview = new ImportPreview(
|
|
session.SessionId,
|
|
new List<ImportPreviewItem>
|
|
{
|
|
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
|
|
},
|
|
new List<RequiredSiteMapping> { new("site-a", "Site A", null) },
|
|
new List<RequiredConnectionMapping> { new("site-a", "conn-x", null) });
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
SetField(cut.Instance, "_session", session);
|
|
SetField(cut.Instance, "_preview", preview);
|
|
SetField(cut.Instance, "_resolutions",
|
|
new Dictionary<(string, string), ImportResolution>
|
|
{
|
|
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
|
|
});
|
|
SetField(cut.Instance, "_siteChoices",
|
|
new Dictionary<string, string> { ["site-a"] = "site-b" });
|
|
SetField(cut.Instance, "_connectionChoices",
|
|
new Dictionary<(string, string), string> { [("site-a", "conn-x")] = "conn-y" });
|
|
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Diff);
|
|
});
|
|
|
|
// Invoke BackToUpload.
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
var method = cut.Instance.GetType().GetMethod(
|
|
"BackToUpload",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
method.Invoke(cut.Instance, Array.Empty<object?>());
|
|
});
|
|
|
|
// Step must be back to Upload.
|
|
Assert.Equal(
|
|
TransportImportPage.ImportWizardStep.Upload,
|
|
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
|
|
|
|
// Session, preview, resolutions, and Map state must all be cleared.
|
|
Assert.Null(GetField<object?>(cut.Instance, "_session"));
|
|
Assert.Null(GetField<object?>(cut.Instance, "_preview"));
|
|
Assert.Null(GetField<object?>(cut.Instance, "_resolutions"));
|
|
|
|
var siteChoices = GetField<Dictionary<string, string>>(cut.Instance, "_siteChoices");
|
|
Assert.Empty(siteChoices);
|
|
|
|
var connChoices = GetField<Dictionary<(string, string), string>>(cut.Instance, "_connectionChoices");
|
|
Assert.Empty(connChoices);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Register row 43: an abandoned wizard must not strand its server-side
|
|
// BundleSession. These tests swap the NSubstitute importer for one backed
|
|
// by a REAL BundleSessionStore so the assertion is "the slot is actually
|
|
// released", not "a mock was called".
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Minimal <see cref="IBundleImporter"/> over a real <see cref="BundleSessionStore"/>.
|
|
/// <see cref="Abandon"/> and <see cref="ApplyAsync"/> mirror the production
|
|
/// importer's zero-then-remove release; every other member is unused by these
|
|
/// tests. Sessions are seeded directly with <see cref="OpenSession"/> because
|
|
/// driving <c>InputFile</c> from bUnit is impractical (see the class remarks).
|
|
/// </summary>
|
|
private sealed class StoreBackedImporter : IBundleImporter
|
|
{
|
|
/// <summary>Gets the backing bundle session store.</summary>
|
|
public BundleSessionStore Store { get; } = new(
|
|
Microsoft.Extensions.Options.Options.Create(new TransportOptions()),
|
|
TimeProvider.System);
|
|
|
|
/// <summary>Gets the number of times <c>Abandon</c> has been called.</summary>
|
|
public int AbandonCalls { get; private set; }
|
|
|
|
/// <summary>Seeds an encrypted session directly into the store.</summary>
|
|
/// <param name="sourceEnv">The source environment name recorded on the session manifest.</param>
|
|
/// <returns>The opened session.</returns>
|
|
public BundleSession OpenSession(string sourceEnv = "prod-cluster")
|
|
{
|
|
var seed = BuildEncryptedSession(sourceEnv);
|
|
var session = new BundleSession
|
|
{
|
|
SessionId = seed.SessionId,
|
|
Manifest = seed.Manifest,
|
|
// Non-zero bytes so the zeroing assertion is meaningful.
|
|
DecryptedContent = new byte[] { 0x11, 0x22, 0x33, 0x44 },
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
|
};
|
|
return Store.Open(session);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<BundleSession> LoadAsync(
|
|
Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
|
|
=> throw new NotSupportedException("These tests seed sessions via OpenSession().");
|
|
|
|
/// <inheritdoc />
|
|
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
|
|
=> Task.FromResult(new ImportPreview(sessionId, new List<ImportPreviewItem>()));
|
|
|
|
/// <inheritdoc />
|
|
public Task<ImportResult> ApplyAsync(
|
|
Guid sessionId,
|
|
IReadOnlyList<ImportResolution> resolutions,
|
|
string user,
|
|
CancellationToken ct = default,
|
|
BundleNameMap? nameMap = null)
|
|
{
|
|
Release(sessionId);
|
|
return Task.FromResult(new ImportResult(
|
|
BundleImportId: Guid.NewGuid(),
|
|
Added: 1,
|
|
Overwritten: 0,
|
|
Skipped: 0,
|
|
Renamed: 0,
|
|
StaleInstanceIds: Array.Empty<int>(),
|
|
AuditEventCorrelation: Guid.NewGuid().ToString()));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Abandon(Guid sessionId)
|
|
{
|
|
AbandonCalls++;
|
|
Release(sessionId);
|
|
}
|
|
|
|
private void Release(Guid sessionId)
|
|
{
|
|
var session = Store.Get(sessionId);
|
|
if (session is { DecryptedContent.Length: > 0 })
|
|
{
|
|
Array.Clear(session.DecryptedContent, 0, session.DecryptedContent.Length);
|
|
}
|
|
Store.Remove(sessionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that disposing the wizard releases its bundle session.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Disposing_the_wizard_releases_its_bundle_session()
|
|
{
|
|
// Circuit teardown / navigation away. Before the fix, Dispose deleted only
|
|
// the staged temp file, so the session (and its decrypted content) held one
|
|
// of MaxConcurrentImportSessions for the full 30-minute TTL.
|
|
var importer = new StoreBackedImporter();
|
|
Services.AddSingleton<IBundleImporter>(importer);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
var session = importer.OpenSession();
|
|
var content = session.DecryptedContent;
|
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
|
|
|
cut.Instance.Dispose();
|
|
|
|
Assert.Null(importer.Store.Get(session.SessionId));
|
|
Assert.All(content, b => Assert.Equal(0, b));
|
|
Assert.Null(GetField<object?>(cut.Instance, "_session"));
|
|
}
|
|
|
|
/// <summary>Verifies that going back to upload releases the bundle session.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task BackToUpload_releases_the_bundle_session()
|
|
{
|
|
// The wizard's only cancel affordance ("Back" on steps 2/3/5) routes through
|
|
// BackToUpload → ResetSessionState, which used to null the local field only.
|
|
var importer = new StoreBackedImporter();
|
|
Services.AddSingleton<IBundleImporter>(importer);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
var session = importer.OpenSession();
|
|
var content = session.DecryptedContent;
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
SetField(cut.Instance, "_session", session);
|
|
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Diff);
|
|
});
|
|
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
var method = cut.Instance.GetType().GetMethod(
|
|
"BackToUpload",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
method.Invoke(cut.Instance, Array.Empty<object?>());
|
|
});
|
|
|
|
Assert.Null(importer.Store.Get(session.SessionId));
|
|
Assert.All(content, b => Assert.Equal(0, b));
|
|
}
|
|
|
|
/// <summary>Verifies that applying then disposing releases the session exactly once without throwing.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Apply_then_dispose_releases_the_session_once_without_throwing()
|
|
{
|
|
// ApplyAsync already removes the session; the wizard's own release must be
|
|
// idempotent so circuit teardown after a completed import is a silent no-op.
|
|
var importer = new StoreBackedImporter();
|
|
Services.AddSingleton<IBundleImporter>(importer);
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
var session = importer.OpenSession(sourceEnv: "prod-cluster");
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
SetField(cut.Instance, "_session", session);
|
|
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);
|
|
SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster");
|
|
});
|
|
|
|
await cut.InvokeAsync(() => InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
|
|
Assert.Equal(
|
|
TransportImportPage.ImportWizardStep.Result,
|
|
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
|
|
Assert.Null(importer.Store.Get(session.SessionId));
|
|
|
|
// Must not throw, and must not resurrect or double-release anything.
|
|
cut.Instance.Dispose();
|
|
cut.Instance.Dispose();
|
|
|
|
Assert.Null(importer.Store.Get(session.SessionId));
|
|
}
|
|
|
|
/// <summary>Verifies that disposing one wizard leaves a concurrent circuit's session open.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Disposing_one_wizard_leaves_a_concurrent_circuits_session_open()
|
|
{
|
|
// Sessions are keyed by a GUID held only by the circuit that opened them,
|
|
// so one operator abandoning their import must not evict another's.
|
|
var importer = new StoreBackedImporter();
|
|
Services.AddSingleton<IBundleImporter>(importer);
|
|
|
|
var first = Render<TransportImportPage>();
|
|
var second = Render<TransportImportPage>();
|
|
var mine = importer.OpenSession();
|
|
var theirs = importer.OpenSession(sourceEnv: "other-cluster");
|
|
await first.InvokeAsync(() => SetField(first.Instance, "_session", mine));
|
|
await second.InvokeAsync(() => SetField(second.Instance, "_session", theirs));
|
|
|
|
first.Instance.Dispose();
|
|
|
|
Assert.Null(importer.Store.Get(mine.SessionId));
|
|
var survivor = importer.Store.Get(theirs.SessionId);
|
|
Assert.NotNull(survivor);
|
|
Assert.Contains(survivor!.DecryptedContent, b => b != 0);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// 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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#45: a signature-policy refusal is shown as its own reason and is NOT
|
|
/// counted as a wrong passphrase (the importer checks the signature before decrypting).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Signature_rejection_shows_reason_without_counting_a_failed_unlock()
|
|
{
|
|
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
|
.Throws(new BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles."));
|
|
|
|
var cut = Render<TransportImportPage>();
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
|
|
SetField(cut.Instance, "_passphrase", "any");
|
|
});
|
|
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync"));
|
|
|
|
Assert.Equal(0, GetField<int>(cut.Instance, "_failedUnlockAttempts"));
|
|
var error = GetField<string?>(cut.Instance, "_errorMessage");
|
|
Assert.NotNull(error);
|
|
Assert.Contains("Bundle signature rejected", error);
|
|
Assert.Contains("requires signed bundles", error);
|
|
}
|
|
|
|
/// <summary>ScadaBridge#45: the upload step's manifest summary shows the bundle's signature status.</summary>
|
|
/// <param name="status">The session's signature status.</param>
|
|
/// <param name="expected">Text the Signature row must contain.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData(BundleSignatureStatus.Verified, "Verified")]
|
|
[InlineData(BundleSignatureStatus.UntrustedKey, "untrusted key")]
|
|
[InlineData(BundleSignatureStatus.Unsigned, "Unsigned")]
|
|
public async Task Manifest_summary_shows_signature_status(BundleSignatureStatus status, string expected)
|
|
{
|
|
var cut = Render<TransportImportPage>();
|
|
var session = BuildEncryptedSession();
|
|
session = new BundleSession
|
|
{
|
|
SessionId = session.SessionId,
|
|
Manifest = session.Manifest,
|
|
DecryptedContent = session.DecryptedContent,
|
|
ExpiresAt = session.ExpiresAt,
|
|
SignatureStatus = status,
|
|
SignerKeyId = status == BundleSignatureStatus.Unsigned ? null : "sha256:feed",
|
|
SignerName = status == BundleSignatureStatus.Verified ? "dev-cluster" : null,
|
|
};
|
|
await cut.InvokeAsync(() =>
|
|
{
|
|
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01 });
|
|
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Upload);
|
|
SetField(cut.Instance, "_session", session);
|
|
});
|
|
cut.Render();
|
|
|
|
var row = cut.Find("[data-testid=manifest-signature]");
|
|
Assert.Contains(expected, row.TextContent);
|
|
if (status == BundleSignatureStatus.Verified)
|
|
{
|
|
Assert.Contains("dev-cluster", row.TextContent);
|
|
}
|
|
if (status == BundleSignatureStatus.UntrustedKey)
|
|
{
|
|
Assert.Contains("sha256:feed", row.TextContent);
|
|
}
|
|
}
|
|
|
|
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>
|
|
/// Invokes the private <c>BuildNameMap()</c> method on the page instance and
|
|
/// returns the resulting <see cref="BundleNameMap"/>. Used by Test 12 to assert
|
|
/// the map shape after a site-choice change without going through ApplyAsync.
|
|
/// </summary>
|
|
private static BundleNameMap InvokeBuildNameMap(TransportImportPage instance)
|
|
{
|
|
var method = instance.GetType().GetMethod(
|
|
"BuildNameMap",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
|
|
?? throw new InvalidOperationException("Method 'BuildNameMap' not found.");
|
|
return (BundleNameMap)method.Invoke(instance, Array.Empty<object?>())!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
|
|
/// shape after an encrypted-bundle upload completed Step 1's peek and
|
|
/// surfaced an ArgumentException ("passphrase required"). The
|
|
/// wizard now stages the upload to a temp file and only retains the
|
|
/// path on the component, so the test helper writes the bytes to a per-
|
|
/// test temp file and sets the path field instead of the byte[] field.
|
|
/// </summary>
|
|
private static void SeedAtPassphraseStep(TransportImportPage instance, byte[] bytes)
|
|
{
|
|
var dir = Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging");
|
|
Directory.CreateDirectory(dir);
|
|
var path = Path.Combine(dir, $"test-{Guid.NewGuid():N}.scadabundle");
|
|
File.WriteAllBytes(path, bytes);
|
|
SetField(instance, "_bundleTempPath", path);
|
|
SetField(instance, "_session", null);
|
|
SetField(instance, "_step", TransportImportPage.ImportWizardStep.Passphrase);
|
|
SetField(instance, "_failedUnlockAttempts", 0);
|
|
}
|
|
}
|