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 ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Security;
using ScadaLink.Transport;
using ScadaLink.Transport.Export;
using TransportExportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportExport;
namespace ScadaLink.CentralUI.Tests.Pages.Design;
///
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
///
///
/// Covers the four contract points the design plan calls out:
///
///
/// - Step 1 renders the template tree plus every flat artifact group.
/// - Step 2 surfaces the dependency-resolved closure (seed vs auto-included).
/// - Step 4 invokes with the user's
/// selected ids and authenticated identity.
/// - The page-level RequireDesign policy denies a user lacking the
/// Design role (router enforcement; the component code-behind never sees
/// the request).
///
///
///
/// 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
/// scadalinkTransport.downloadBundle call returns void — loose mode is
/// the lighter wiring than re-stubbing it in every export-path test.
///
///
public class TransportExportPageTests : BunitContext
{
private readonly ITemplateEngineRepository _templateRepo = Substitute.For();
private readonly IExternalSystemRepository _externalRepo = Substitute.For();
private readonly INotificationRepository _notificationRepo = Substitute.For();
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For();
private readonly IBundleExporter _exporter = Substitute.For();
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())
.Returns(Task.FromResult>(new List()));
_templateRepo.GetAllFoldersAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
_templateRepo.GetAllSharedScriptsAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
_externalRepo.GetAllExternalSystemsAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
_notificationRepo.GetAllNotificationListsAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any())
.Returns(Task.FromResult>(new List()));
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();
Services.AddSingleton>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
SourceEnvironment = "test-cluster",
}));
var principal = BuildPrincipal("alice", "Design");
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"));
}
// ─────────────────────────────────────────────────────────────────────
// 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())
.Returns(Task.FromResult>(new List { template }));
_templateRepo.GetAllSharedScriptsAsync(Arg.Any())
.Returns(Task.FromResult>(new List { script }));
_externalRepo.GetAllExternalSystemsAsync(Arg.Any())
.Returns(Task.FromResult>(
new List { externalSystem }));
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any())
.Returns(Task.FromResult>(
new List { db }));
_notificationRepo.GetAllNotificationListsAsync(Arg.Any())
.Returns(Task.FromResult>(new List { notifList }));
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any())
.Returns(Task.FromResult>(new List { smtp }));
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any())
.Returns(Task.FromResult>(new List { apiKey }));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any())
.Returns(Task.FromResult>(new List { apiMethod }));
var cut = Render();
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())
.Returns(Task.FromResult>(new List { pump, motor }));
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any())
.Returns(Task.FromResult(pump));
_templateRepo.GetTemplateWithChildrenAsync(2, Arg.Any())
.Returns(Task.FromResult(motor));
var cut = Render();
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())
.Returns(Task.FromResult>(new List { template }));
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any())
.Returns(Task.FromResult(template));
// Exporter returns a tiny in-memory bundle stream.
_exporter
.ExportAsync(
Arg.Any(),
Arg.Any(),
Arg.Any(),
Arg.Any(),
Arg.Any())
.Returns(_ => Task.FromResult(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
var cut = Render();
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(s =>
s.TemplateIds.Contains(1)
&& s.IncludeDependencies),
"alice",
"test-cluster",
"hunter2hunter2",
Arg.Any());
}
// ─────────────────────────────────────────────────────────────────────
// 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.AddScadaLinkAuthorization();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService();
// 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);
}
}