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.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 TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design; /// /// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22). /// /// /// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result). /// Selecting a file via InputFile is hard to drive cleanly from bUnit /// (JS interop + DotNetStreamReference), so the state-machine tests reach into /// the page instance via cut.Instance and the InternalsVisibleTo /// declaration on ZB.MOM.WW.ScadaBridge.CentralUI.csproj. The BundleImporter /// 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 ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests. /// /// public class TransportImportPageTests : BunitContext { private readonly IBundleImporter _importer = Substitute.For(); private readonly IAuditService _auditService = Substitute.For(); // M8 E2: 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(); public TransportImportPageTests() { JSInterop.Mode = JSRuntimeMode.Loose; Services.AddSingleton(_importer); Services.AddSingleton(_auditService); _siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Array.Empty()); _siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any(), Arg.Any()) .Returns(Array.Empty()); Services.AddSingleton(_siteRepo); Services.AddSingleton>( 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() .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(new TestAuthStateProvider(principal)); Services.AddAuthorizationCore(); } private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles) { var claims = new List { 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()), DecryptedContent = Array.Empty(), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), }; // ───────────────────────────────────────────────────────────────────── // Test 1: Step 1 renders the InputFile upload control. // ───────────────────────────────────────────────────────────────────── [Fact] public void Renders_step1_upload_input() { var cut = Render(); // Bootstrap classes are applied by InputFile via the CSS class attribute. Assert.NotNull(cut.Find("input[type='file']")); // The Bootstrap step indicator should highlight Step 1. Assert.Contains("Upload", cut.Markup); } // ───────────────────────────────────────────────────────────────────── // Test 2: Wrong passphrase increments the failure counter without // advancing past Step 2. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Decryption_failure_increments_attempt_counter() { // Set up the importer to throw CryptographicException for wrong passphrases. _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new CryptographicException("authentication tag mismatch")); var cut = Render(); 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(cut.Instance, "_failedUnlockAttempts")); Assert.Equal( TransportImportPage.ImportWizardStep.Passphrase, GetField(cut.Instance, "_step")); } // ───────────────────────────────────────────────────────────────────── // Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns // to Step 1 with an explanatory error. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Three_failed_unlocks_force_reupload() { _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new CryptographicException("authentication tag mismatch")); var cut = Render(); 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(cut.Instance, "_step")); var errorMessage = GetField(cut.Instance, "_errorMessage"); Assert.NotNull(errorMessage); Assert.Contains("Too many failed unlock attempts", errorMessage); } // ───────────────────────────────────────────────────────────────────── // Test 4: Confirm step requires an exact match (case-sensitive) on the // source environment name before Apply is enabled. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Confirm_step_requires_exact_environment_name_match() { var session = BuildEncryptedSession(sourceEnv: "prod-cluster"); _importer.PreviewAsync(session.SessionId, Arg.Any()) .Returns(new ImportPreview(session.SessionId, new List { new("Template", "Pump", null, 1, ConflictKind.New, null, null), })); var cut = Render(); await cut.InvokeAsync(() => { SetField(cut.Instance, "_session", session); SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List { new("Template", "Pump", null, 1, ConflictKind.New, null, null), })); SetField(cut.Instance, "_resolutions", new Dictionary<(string EntityType, string Name), ImportResolution> { [("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null), }); SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm); }); // Wrong text → Apply button is disabled. await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "wrong")); cut.Render(); var applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import")); Assert.True(applyBtn.HasAttribute("disabled")); // Case mismatch → still disabled. await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "PROD-CLUSTER")); cut.Render(); applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import")); Assert.True(applyBtn.HasAttribute("disabled")); // Exact match → enabled. await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster")); cut.Render(); applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import")); Assert.False(applyBtn.HasAttribute("disabled")); } // ───────────────────────────────────────────────────────────────────── // Test 5: ApplyAsync is invoked with the chosen resolutions and the // authenticated user identity. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions() { var session = BuildEncryptedSession(sourceEnv: "prod-cluster"); var resolutions = new Dictionary<(string EntityType, string Name), ImportResolution> { [("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Overwrite, null), }; var expectedResult = new ImportResult( BundleImportId: Guid.NewGuid(), Added: 0, Overwritten: 1, Skipped: 0, Renamed: 0, StaleInstanceIds: Array.Empty(), AuditEventCorrelation: Guid.NewGuid().ToString()); _importer.ApplyAsync( session.SessionId, Arg.Any>(), "alice", Arg.Any(), Arg.Any()) .Returns(expectedResult); var cut = Render(); await cut.InvokeAsync(() => { SetField(cut.Instance, "_session", session); SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List { 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>(rs => rs.Any(r => r.EntityType == "Template" && r.Name == "Pump" && r.Action == ResolutionAction.Overwrite)), "alice", Arg.Any(), Arg.Any()); Assert.Equal( TransportImportPage.ImportWizardStep.Result, GetField(cut.Instance, "_step")); Assert.Equal(expectedResult, GetField(cut.Instance, "_result")); } // ───────────────────────────────────────────────────────────────────── // Test 6: A user without the Admin role fails the RequireAdmin policy. // The router enforces [Authorize(Policy=...)] at request time — bUnit // doesn't model routing, so we verify the policy itself denies the // principal. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Page_returns_unauthorized_for_user_without_Admin_role() { var services = new ServiceCollection(); services.AddLogging(); services.AddScadaBridgeAuthorization(); using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); // 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. // ───────────────────────────────────────────────────────────────────── [Fact] public void BuildDefaultResolutions_maps_kinds_to_actions() { var preview = new ImportPreview(Guid.NewGuid(), new List { 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 (M8 E2): 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. // ───────────────────────────────────────────────────────────────────── [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()) .Returns(new List { new("Site B", "site-b") { Id = 7 } }); _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) .Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } }); var session = BuildEncryptedSession(sourceEnv: "prod-cluster"); var preview = new ImportPreview( session.SessionId, new List(), new List { new("site-a", "Site A", "site-b") }, new List { new("site-a", "opc-a", "opc-main") }); _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); var cut = Render(); 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")); } [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 { new("Template", "Pump", null, 1, ConflictKind.New, null, null), }); _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); var cut = Render(); 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 (M8 E2): the operator's Map choices fold into the BundleNameMap // passed to ApplyAsync — a chosen target → MapToExisting, "Create new" → // CreateNew. Captured via the substituted IBundleImporter. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Apply_passes_BundleNameMap_built_from_map_choices() { _siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(new List { new("Site B", "site-b") { Id = 7 } }); _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) .Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } }); var session = BuildEncryptedSession(sourceEnv: "prod-cluster"); var preview = new ImportPreview( session.SessionId, new List(), // site-a auto-matches site-b; site-c has no auto-match (→ Create new default). new List { new("site-a", "Site A", "site-b"), new("site-c", "Site C", null), }, // opc-a (under site-a) auto-matches opc-main. new List { new("site-a", "opc-a", "opc-main") }); _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); var expectedResult = new ImportResult( Guid.NewGuid(), 0, 0, 0, 0, Array.Empty(), Guid.NewGuid().ToString()); _importer.ApplyAsync( session.SessionId, Arg.Any>(), "alice", Arg.Any(), Arg.Any()) .Returns(expectedResult); var cut = Render(); 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>(), "alice", Arg.Any(), Arg.Is(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 (M8 E2): a Modified row whose FieldDiffJson carries a code // lineDiff renders +/- lines via LineDiffView. // ───────────────────────────────────────────────────────────────────── [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 { new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null), }); _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); var cut = Render(); 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
 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 (M8 E2): truncation marker shows when the lineDiff is truncated.
    // ─────────────────────────────────────────────────────────────────────
    [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
        {
            new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
        });
        _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);

        var cut = Render();
        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 (M8 E2 Fix 1): 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.
    // ─────────────────────────────────────────────────────────────────────
    [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())
            .Returns(new List { siteB, siteC });
        _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any())
            .Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } });
        _siteRepo.GetDataConnectionsBySiteIdAsync(8, Arg.Any())
            .Returns(Array.Empty());

        // 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(),
            new List { new("site-a", "Site A", "site-b") },
            new List { new("site-a", "opc-a", "opc-main") });
        _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);

        var cut = Render();
        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>(
            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>(
            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 (M8 E2 Fix 2): BackToUpload clears session/preview/Map state
    // so a subsequent re-upload flow cannot inherit stale data.
    // ─────────────────────────────────────────────────────────────────────
    [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
            {
                new("Template", "Pump", null, 1, ConflictKind.New, null, null),
            },
            new List { new("site-a", "Site A", null) },
            new List { new("site-a", "conn-x", null) });

        var cut = Render();
        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 { ["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());
        });

        // Step must be back to Upload.
        Assert.Equal(
            TransportImportPage.ImportWizardStep.Upload,
            GetField(cut.Instance, "_step"));

        // Session, preview, resolutions, and Map state must all be cleared.
        Assert.Null(GetField(cut.Instance, "_session"));
        Assert.Null(GetField(cut.Instance, "_preview"));
        Assert.Null(GetField(cut.Instance, "_resolutions"));

        var siteChoices = GetField>(cut.Instance, "_siteChoices");
        Assert.Empty(siteChoices);

        var connChoices = GetField>(cut.Instance, "_connectionChoices");
        Assert.Empty(connChoices);
    }

    // ─────────────────────────────────────────────────────────────────────
    // 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(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())!;
        await task;
    }

    /// 
    /// Invokes the private BuildNameMap() method on the page instance and
    /// returns the resulting . Used by Test 12 to assert
    /// the map shape after a site-choice change without going through ApplyAsync.
    /// 
    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())!;
    }

    /// 
    /// 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"). CentralUI-031:
    /// 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.
    /// 
    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);
    }
}