using System.Security.Claims; using System.Security.Cryptography; using Bunit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; using ScadaLink.Security; using ScadaLink.Transport; using TransportImportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportImport; namespace ScadaLink.CentralUI.Tests.Pages.Design; /// /// 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 ScadaLink.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 ScadaLink.Transport.IntegrationTests. /// /// public class TransportImportPageTests : BunitContext { private readonly IBundleImporter _importer = Substitute.For(); public TransportImportPageTests() { JSInterop.Mode = JSRuntimeMode.Loose; Services.AddSingleton(_importer); Services.AddSingleton>( Microsoft.Extensions.Options.Options.Create(new TransportOptions { MaxBundleSizeMb = 10, MaxUnlockAttemptsPerSession = 3, })); var principal = BuildPrincipal("alice", "Admin"); 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", ScadaLinkVersion: "1.0.0", ContentHash: "sha256:0000", Encryption: new EncryptionMetadata( Algorithm: "AES-256-GCM", Kdf: "PBKDF2-SHA256", Iterations: 600_000, SaltB64: "abc", IvB64: "def"), Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0), Contents: Array.Empty()), 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()) .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()); 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.AddScadaLinkAuthorization(); using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); // Design-only user — has a role but it isn't Admin. var principal = BuildPrincipal("bob", "Design"); var result = await authService.AuthorizeAsync( principal, null, AuthorizationPolicies.RequireAdmin); Assert.False(result.Succeeded); } // ───────────────────────────────────────────────────────────────────── // Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the // expected default ResolutionAction. // ───────────────────────────────────────────────────────────────────── [Fact] public void BuildDefaultResolutions_maps_kinds_to_actions() { var preview = new ImportPreview(Guid.NewGuid(), new List { new("Template", "A", 1, 1, ConflictKind.Identical, null, null), new("Template", "B", null, 1, ConflictKind.New, null, null), new("Template", "C", 1, 2, ConflictKind.Modified, null, null), new("Reference", "D", null, null, ConflictKind.Blocker, null, "missing dep"), }); var map = TransportImportPage.BuildDefaultResolutions(preview); Assert.Equal(ResolutionAction.Skip, map[("Template", "A")].Action); Assert.Equal(ResolutionAction.Add, map[("Template", "B")].Action); Assert.Equal(ResolutionAction.Overwrite, map[("Template", "C")].Action); Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action); } // ───────────────────────────────────────────────────────────────────── // Reflection helpers — the wizard's per-instance state is private (the // razor partial pattern). We poke at it via reflection rather than // widening the surface of the production class with test-only accessors. // ───────────────────────────────────────────────────────────────────── private static void SetField(object obj, string name, object? value) { var field = obj.GetType().GetField( name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public) ?? throw new InvalidOperationException($"Field '{name}' not found on {obj.GetType()}."); field.SetValue(obj, value); } private static T GetField(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; } /// /// Seeds the wizard at Step 2 (Passphrase) with cached bundle bytes — the /// shape after an encrypted-bundle upload completed Step 1's peek and /// surfaced an ArgumentException ("passphrase required"). /// private static void SeedAtPassphraseStep(TransportImportPage instance, byte[] bytes) { SetField(instance, "_bundleBytes", bytes); SetField(instance, "_session", null); SetField(instance, "_step", TransportImportPage.ImportWizardStep.Passphrase); SetField(instance, "_failedUnlockAttempts", 0); } }