368 lines
18 KiB
C#
368 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22).
|
|
///
|
|
/// <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>ScadaLink.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>ScadaLink.Transport.IntegrationTests</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public class TransportImportPageTests : BunitContext
|
|
{
|
|
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
|
|
|
|
public TransportImportPageTests()
|
|
{
|
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
|
|
|
Services.AddSingleton(_importer);
|
|
Services.AddSingleton<IOptions<TransportOptions>>(
|
|
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
|
{
|
|
MaxBundleSizeMb = 10,
|
|
MaxUnlockAttemptsPerSession = 3,
|
|
}));
|
|
|
|
var principal = BuildPrincipal("alice", "Admin");
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
|
Services.AddAuthorizationCore();
|
|
}
|
|
|
|
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",
|
|
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<ManifestContentEntry>()),
|
|
DecryptedContent = Array.Empty<byte>(),
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Test 1: Step 1 renders the InputFile upload control.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[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>())
|
|
.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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public async Task Three_failed_unlocks_force_reupload()
|
|
{
|
|
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[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>())
|
|
.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>());
|
|
|
|
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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[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<IAuthorizationService>();
|
|
|
|
// 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<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);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// 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>
|
|
/// 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").
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|