refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
using TransportExportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportExport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
|
||||
///
|
||||
/// <para>
|
||||
/// Covers the four contract points the design plan calls out:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>Step 1 renders the template tree plus every flat artifact group.</item>
|
||||
/// <item>Step 2 surfaces the dependency-resolved closure (seed vs auto-included).</item>
|
||||
/// <item>Step 4 invokes <see cref="IBundleExporter.ExportAsync"/> with the user's
|
||||
/// selected ids and authenticated identity.</item>
|
||||
/// <item>The page-level <c>RequireDesign</c> policy denies a user lacking the
|
||||
/// Design role (router enforcement; the component code-behind never sees
|
||||
/// the request).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// JS interop is set to loose mode so the TreeView's sessionStorage round-trip
|
||||
/// and the transport-bundle download interop don't need stubs per test. The
|
||||
/// <c>scadabridgeTransport.downloadBundle</c> call returns void — loose mode is
|
||||
/// the lighter wiring than re-stubbing it in every export-path test.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class TransportExportPageTests : BunitContext
|
||||
{
|
||||
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
|
||||
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
|
||||
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
|
||||
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
|
||||
|
||||
public TransportExportPageTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
// Default empty repos so OnInitializedAsync doesn't throw — individual
|
||||
// tests override the bits they care about.
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
||||
_templateRepo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
||||
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript>()));
|
||||
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(new List<ExternalSystemDefinition>()));
|
||||
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(new List<DatabaseConnectionDefinition>()));
|
||||
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList>()));
|
||||
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
|
||||
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey>()));
|
||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
|
||||
Services.AddSingleton(_templateRepo);
|
||||
Services.AddSingleton(_externalRepo);
|
||||
Services.AddSingleton(_notificationRepo);
|
||||
Services.AddSingleton(_inboundApiRepo);
|
||||
Services.AddSingleton(_exporter);
|
||||
// DependencyResolver is sealed but its only dependencies are the four
|
||||
// repositories above — registering the concrete type is enough.
|
||||
Services.AddSingleton<DependencyResolver>();
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
SourceEnvironment = "test-cluster",
|
||||
}));
|
||||
|
||||
var principal = BuildPrincipal("alice", "Design");
|
||||
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"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 1: Step 1 renders the template tree and every flat artifact group.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Renders_step1_with_template_tree_and_artifact_checkboxes()
|
||||
{
|
||||
// A single template + a couple of artifacts so the lists aren't empty.
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
var script = new SharedScript("Helpers", "// noop") { Id = 10 };
|
||||
var externalSystem = new ExternalSystemDefinition("ERP", "https://erp.example.com", "ApiKey")
|
||||
{
|
||||
Id = 20,
|
||||
};
|
||||
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
|
||||
var notifList = new NotificationList("Ops") { Id = 40 };
|
||||
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
|
||||
var apiKey = new ApiKey("ext-system", "key-hash") { Id = 60 };
|
||||
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
|
||||
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
|
||||
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript> { script }));
|
||||
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(
|
||||
new List<ExternalSystemDefinition> { externalSystem }));
|
||||
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(
|
||||
new List<DatabaseConnectionDefinition> { db }));
|
||||
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
|
||||
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
|
||||
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey> { apiKey }));
|
||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
||||
|
||||
// All six flat groups (plus templates) are present.
|
||||
foreach (var groupId in new[]
|
||||
{
|
||||
"group-templates",
|
||||
"group-shared-scripts",
|
||||
"group-external-systems",
|
||||
"group-db-connections",
|
||||
"group-notification-lists",
|
||||
"group-smtp-configs",
|
||||
"group-api-keys",
|
||||
"group-api-methods",
|
||||
})
|
||||
{
|
||||
Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
|
||||
}
|
||||
|
||||
// Sanity: each artifact shows its label.
|
||||
Assert.Contains("Helpers", cut.Markup);
|
||||
Assert.Contains("ERP", cut.Markup);
|
||||
Assert.Contains("Hist", cut.Markup);
|
||||
Assert.Contains("Ops", cut.Markup);
|
||||
Assert.Contains("smtp.example.com", cut.Markup);
|
||||
Assert.Contains("ext-system", cut.Markup);
|
||||
Assert.Contains("CreateOrder", cut.Markup);
|
||||
|
||||
// Next button is disabled while no selection exists.
|
||||
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
|
||||
Assert.True(next.HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 2: Step 2 shows resolved dependencies — auto-included templates pulled
|
||||
// in because a seed template composes them.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Step2_shows_resolved_dependencies_after_clicking_next()
|
||||
{
|
||||
// Seed template "Pump" composes "Motor". The user selects Pump only;
|
||||
// the resolver pulls Motor in transitively.
|
||||
var pump = new Template("Pump") { Id = 1 };
|
||||
pump.Compositions.Add(new TemplateComposition("MotorSlot")
|
||||
{
|
||||
Id = 100,
|
||||
ComposedTemplateId = 2,
|
||||
});
|
||||
var motor = new Template("Motor") { Id = 2 };
|
||||
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { pump, motor }));
|
||||
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Template?>(pump));
|
||||
_templateRepo.GetTemplateWithChildrenAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Template?>(motor));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
||||
|
||||
// The template-tree renders a checkbox per node — tick the one whose
|
||||
// sibling label is "Pump". (TemplateFolderTree uses .tv-checkbox.)
|
||||
var pumpRow = cut.FindAll("li[role='treeitem']")
|
||||
.First(li => li.TextContent.Contains("Pump"));
|
||||
var checkbox = pumpRow.QuerySelector("input.tv-checkbox");
|
||||
Assert.NotNull(checkbox);
|
||||
checkbox!.Change(true);
|
||||
|
||||
// Click "Next" to advance to Step 2; the resolver call is awaited
|
||||
// inside GoToReviewAsync — bUnit's WaitForState handles the re-render.
|
||||
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
|
||||
await next.ClickAsync(new());
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Step 2 shows the seed/auto split — Motor lands under "Auto-included".
|
||||
var autoGroup = cut.Find("[data-testid='auto-group']");
|
||||
Assert.Contains("Motor", autoGroup.TextContent);
|
||||
});
|
||||
|
||||
var seedGroup = cut.Find("[data-testid='seed-group']");
|
||||
Assert.Contains("Pump", seedGroup.TextContent);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync
|
||||
// is invoked with the user-selected ids and the authenticated identity.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
|
||||
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Template?>(template));
|
||||
|
||||
// Exporter returns a tiny in-memory bundle stream.
|
||||
_exporter
|
||||
.ExportAsync(
|
||||
Arg.Any<ExportSelection>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump"));
|
||||
|
||||
// Tick Pump.
|
||||
var pumpCheckbox = cut.FindAll("li[role='treeitem']")
|
||||
.First(li => li.TextContent.Contains("Pump"))
|
||||
.QuerySelector("input.tv-checkbox");
|
||||
Assert.NotNull(pumpCheckbox);
|
||||
pumpCheckbox!.Change(true);
|
||||
|
||||
// Advance Step 1 → 2.
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
|
||||
|
||||
// Advance Step 2 → 3.
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
|
||||
|
||||
// Fill matching passphrases. The inputs are wired with @bind:event="oninput",
|
||||
// so use Input() rather than Change() to fire the right event.
|
||||
var passphraseInput = cut.Find("#passphrase");
|
||||
passphraseInput.Input("hunter2hunter2");
|
||||
var confirmInput = cut.Find("#passphrase-confirm");
|
||||
confirmInput.Input("hunter2hunter2");
|
||||
|
||||
// Click "Export" — the only enabled button labeled "Export" at this step.
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
|
||||
|
||||
// Step 4 renders the download summary once ExportAsync resolves.
|
||||
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
|
||||
|
||||
await _exporter.Received(1).ExportAsync(
|
||||
Arg.Is<ExportSelection>(s =>
|
||||
s.TemplateIds.Contains(1)
|
||||
&& s.IncludeDependencies),
|
||||
"alice",
|
||||
"test-cluster",
|
||||
"hunter2hunter2",
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 4: A user without the Design role fails the RequireDesign policy.
|
||||
// The router enforces [Authorize(Policy=...)] at request time — bUnit
|
||||
// doesn't model routing, so we verify the policy itself denies the
|
||||
// principal (the same gate the router consults).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Page_returns_unauthorized_for_user_without_Design_role()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddScadaBridgeAuthorization();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
// Audit-only user — has a role but it isn't Design.
|
||||
var principal = BuildPrincipal("bob", "Audit");
|
||||
var result = await authService.AuthorizeAsync(
|
||||
principal, null, AuthorizationPolicies.RequireDesign);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Static helpers — exercised directly so the file-naming + secret-count
|
||||
// contract is unit-pinned independently of the rendering surface.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void BuildFilename_produces_pattern_and_sanitises_source_environment()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2026, 5, 24, 13, 45, 22, TimeSpan.Zero);
|
||||
var filename = TransportExportPage.BuildFilename("dev/cluster a", fixedTime);
|
||||
Assert.Equal("scadabundle-dev-cluster-a-2026-05-24-134522.scadabundle", filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
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.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;
|
||||
|
||||
/// <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>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>();
|
||||
|
||||
public TransportImportPageTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
Services.AddSingleton(_importer);
|
||||
Services.AddSingleton(_auditService);
|
||||
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", "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",
|
||||
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, 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.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", "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 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.
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user