513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
1006 lines
52 KiB
C#
1006 lines
52 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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
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);
|
|
}
|
|
}
|