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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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);
}
}