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 ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.ConfigurationDatabase;
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();
private readonly IAuditService _auditService = Substitute.For();
public TransportImportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddSingleton(_importer);
Services.AddSingleton(_auditService);
Services.AddSingleton>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
MaxBundleSizeMb = 10,
MaxUnlockAttemptsPerSession = 3,
}));
// Provide a SQLite in-memory ScadaLinkDbContext 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 ScadaLinkDbContext(dbOptions);
dbContext.Database.OpenConnection();
dbContext.Database.EnsureCreated();
Services.AddSingleton(dbContext);
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