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,123 @@
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (#23 M7-T14 /
|
||||
/// Bundle F). Builds the <c>?...</c> querystring the Export-CSV link points
|
||||
/// at; the same conversion is round-tripped on the server side by
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Audit.AuditExportEndpoints.ParseFilter"/>.
|
||||
/// These tests pin the no-filter base path + the round-trip back through
|
||||
/// <see cref="QueryHelpers.ParseQuery"/> so the link contract stays stable.
|
||||
/// </summary>
|
||||
public class AuditLogPageExportUrlTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildExportUrl_NullFilter_ReturnsBasePath()
|
||||
{
|
||||
var url = AuditLogPage.BuildExportUrl(null);
|
||||
Assert.Equal("/api/centralui/audit/export", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_EmptyFilter_ReturnsBasePath()
|
||||
{
|
||||
// Defensive: a filter where every column is null should still render
|
||||
// as the bare path — no trailing "?" so the URL stays clean.
|
||||
var url = AuditLogPage.BuildExportUrl(new AuditLogQueryFilter());
|
||||
Assert.Equal("/api/centralui/audit/export", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_AllFiltersSet_RoundTrips()
|
||||
{
|
||||
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
var filter = new AuditLogQueryFilter(
|
||||
Channels: new[] { AuditChannel.ApiOutbound },
|
||||
Kinds: new[] { AuditKind.ApiCall },
|
||||
Statuses: new[] { AuditStatus.Failed },
|
||||
SourceSiteIds: new[] { "plant-a" },
|
||||
Target: "PaymentApi",
|
||||
Actor: "apikey-1",
|
||||
CorrelationId: corr,
|
||||
FromUtc: new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc),
|
||||
ToUtc: new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc));
|
||||
|
||||
var url = AuditLogPage.BuildExportUrl(filter);
|
||||
|
||||
Assert.StartsWith("/api/centralui/audit/export?", url);
|
||||
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||
|
||||
Assert.Equal("ApiOutbound", query["channel"]);
|
||||
Assert.Equal("ApiCall", query["kind"]);
|
||||
Assert.Equal("Failed", query["status"]);
|
||||
Assert.Equal("plant-a", query["site"]);
|
||||
Assert.Equal("PaymentApi", query["target"]);
|
||||
Assert.Equal("apikey-1", query["actor"]);
|
||||
Assert.Equal(corr.ToString(), query["correlationId"]);
|
||||
Assert.Equal("2026-05-20T00:00:00.0000000Z", query["from"]);
|
||||
Assert.Equal("2026-05-20T23:59:59.0000000Z", query["to"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
|
||||
{
|
||||
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.Notification });
|
||||
|
||||
var url = AuditLogPage.BuildExportUrl(filter);
|
||||
|
||||
Assert.StartsWith("/api/centralui/audit/export?", url);
|
||||
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||
Assert.Single(query);
|
||||
Assert.Equal("Notification", query["channel"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
|
||||
{
|
||||
var exec = Guid.Parse("12121212-3434-5656-7878-909090909090");
|
||||
var filter = new AuditLogQueryFilter(ExecutionId: exec);
|
||||
|
||||
var url = AuditLogPage.BuildExportUrl(filter);
|
||||
|
||||
Assert.StartsWith("/api/centralui/audit/export?", url);
|
||||
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||
Assert.Single(query);
|
||||
Assert.Equal(exec.ToString(), query["executionId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam()
|
||||
{
|
||||
var parent = Guid.Parse("34343434-5656-7878-9090-121212121212");
|
||||
var filter = new AuditLogQueryFilter(ParentExecutionId: parent);
|
||||
|
||||
var url = AuditLogPage.BuildExportUrl(filter);
|
||||
|
||||
Assert.StartsWith("/api/centralui/audit/export?", url);
|
||||
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||
Assert.Single(query);
|
||||
Assert.Equal(parent.ToString(), query["parentExecutionId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
|
||||
{
|
||||
// Task 9: each multi-value dimension emits one repeated query-string key
|
||||
// per selected value so the export endpoint's ParseFilter sees them all.
|
||||
var filter = new AuditLogQueryFilter(
|
||||
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound },
|
||||
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
|
||||
SourceSiteIds: new[] { "plant-a", "plant-b" });
|
||||
|
||||
var url = AuditLogPage.BuildExportUrl(filter);
|
||||
|
||||
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, query["channel"].ToArray());
|
||||
Assert.Equal(new[] { "Failed", "Parked" }, query["status"].ToArray());
|
||||
Assert.Equal(new[] { "plant-a", "plant-b" }, query["site"].ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G).
|
||||
///
|
||||
/// <para>
|
||||
/// Bundle G introduces two new policies:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OperationalAudit</c> — read access to the Audit Log page +
|
||||
/// Configuration Audit Log page + nav group.</item>
|
||||
/// <item><c>AuditExport</c> — additional gate on the Export-CSV button and
|
||||
/// the streaming export endpoint.</item>
|
||||
/// </list>
|
||||
/// Both policies are satisfied by the <c>Audit</c> role and (defence in depth)
|
||||
/// the <c>Admin</c> role — admins see everything by convention in this
|
||||
/// codebase. The tests pin both the page-level + endpoint-level enforcement,
|
||||
/// and the Export-button visibility split.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AuditLogPagePermissionTests : BunitContext
|
||||
{
|
||||
public AuditLogPagePermissionTests()
|
||||
{
|
||||
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
|
||||
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
|
||||
// an init call). Loose mode lets those unconfigured JS calls no-op so
|
||||
// the permission-gating tests need not configure browser interop.
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new("Username", "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private void WireUpPageDependencies()
|
||||
{
|
||||
// The page hosts AuditFilterBar + AuditResultsGrid which depend on
|
||||
// ISiteRepository and IAuditLogQueryService — provide stand-ins so
|
||||
// a permitted render is exercised end-to-end.
|
||||
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
||||
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
|
||||
}
|
||||
|
||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
WireUpPageDependencies();
|
||||
|
||||
// Page-level [Authorize(Policy=...)] is enforced by the router in a
|
||||
// live app. bUnit renders the component directly, so we wrap the
|
||||
// page in a CascadingAuthenticationState so the in-page
|
||||
// AuthorizeView for the Export button can read the principal.
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<AuditLogPage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<AuditLogPage>();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 1: WithoutOperationalAudit_PageReturns403_OrHidden
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Page-level enforcement is the [Authorize(Policy = "OperationalAudit")]
|
||||
// attribute on the .razor page. We can't easily smoke-test routing here,
|
||||
// so we verify the attribute is present + the policy denies a principal
|
||||
// that holds none of the permitting roles.
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutOperationalAudit_PolicyDenies()
|
||||
{
|
||||
// A Design-only user (no Audit, no Admin) must NOT satisfy the
|
||||
// OperationalAudit policy.
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddScadaBridgeAuthorization();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var principal = BuildPrincipal("Design");
|
||||
var result = await authService.AuthorizeAsync(
|
||||
principal, null, AuthorizationPolicies.OperationalAudit);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogPage_HasOperationalAuditAuthorizeAttribute()
|
||||
{
|
||||
// Sanity-pin the attribute so the page-level gate can't regress to
|
||||
// [Authorize] (any-authenticated) by accident.
|
||||
var attributes = typeof(AuditLogPage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.ToList();
|
||||
|
||||
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute()
|
||||
{
|
||||
// ConfigurationAuditLog mirrors the gate — both Audit-group pages
|
||||
// share the OperationalAudit permission so the nav-group policy
|
||||
// remains coherent with the per-page gates.
|
||||
var configType = typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.ConfigurationAuditLog);
|
||||
var attributes = configType
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.ToList();
|
||||
|
||||
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 2 + 3: Export button visibility split.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
|
||||
{
|
||||
// The "Audit" role grants OperationalAudit + AuditExport in the
|
||||
// default mapping, so we test the split by handing the user ONLY
|
||||
// an extra-narrow role that we map ONLY to OperationalAudit: a
|
||||
// fresh "AuditReadOnly" role (see AuthorizationPolicies).
|
||||
var cut = RenderAuditLogPage("AuditReadOnly");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The page rendered (heading + container present) but the
|
||||
// Export-CSV anchor is gone because AuditExport is denied.
|
||||
Assert.Contains("Audit Log", cut.Markup);
|
||||
Assert.DoesNotContain("Export CSV", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
|
||||
{
|
||||
var cut = RenderAuditLogPage("Audit");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Audit Log", cut.Markup);
|
||||
Assert.Contains("Export CSV", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminUser_SeesPage_AndExportButton()
|
||||
{
|
||||
// Admin holds every permission by convention — both policies must
|
||||
// succeed for a plain Admin user.
|
||||
var cut = RenderAuditLogPage("Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Audit Log", cut.Markup);
|
||||
Assert.Contains("Export CSV", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 4 + 5: Endpoint-level enforcement.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AuditExportEndpoint_WithoutAuditExport_Returns403()
|
||||
{
|
||||
// A user holding only Design must NOT be able to call the export
|
||||
// endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport.
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditExportEndpoint_WithAuditExport_Returns200()
|
||||
{
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditExportEndpoint_AdminAlone_Returns200()
|
||||
{
|
||||
// Admin alone (no Audit role) must still pass — defence in depth.
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditExportEndpoint_AuditReadOnly_Returns403()
|
||||
{
|
||||
// AuditReadOnly grants OperationalAudit but NOT AuditExport, so the
|
||||
// endpoint must refuse — the page is readable but the bulk export
|
||||
// path is gated separately.
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Helper: tiny in-process host with the real AuthorizationPolicies.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildEndpointHostAsync(
|
||||
string[] roles)
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddAuthentication(FakeAuthHandler.SchemeName)
|
||||
.AddScheme<FakeAuthHandlerOptions, FakeAuthHandler>(
|
||||
FakeAuthHandler.SchemeName, opts => opts.Roles = roles);
|
||||
// Real policies — the whole point of these tests is to
|
||||
// exercise the production AddScadaBridgeAuthorization wiring.
|
||||
services.AddScadaBridgeAuthorization();
|
||||
services.AddSingleton(repo);
|
||||
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
|
||||
});
|
||||
web.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapAuditExportEndpoints();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var host = await hostBuilder.StartAsync();
|
||||
var client = host.GetTestClient();
|
||||
return (client, repo, host);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only authentication handler that signs every request in with
|
||||
/// the configured set of roles.
|
||||
/// </summary>
|
||||
private sealed class FakeAuthHandler : AuthenticationHandler<FakeAuthHandlerOptions>
|
||||
{
|
||||
public const string SchemeName = "FakeAuth";
|
||||
|
||||
public FakeAuthHandler(
|
||||
IOptionsMonitor<FakeAuthHandlerOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
|
||||
foreach (var role in Options.Roles)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
||||
}
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
public string[] Roles { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
|
||||
using NavMenu = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.NavMenu;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for the new Audit Log page (#23 M7-T1) and the Audit
|
||||
/// nav group that hosts both it and the renamed Configuration Audit Log
|
||||
/// (#23 M7 Bundle A).
|
||||
///
|
||||
/// These are render-only smoke tests — the filter bar and results grid
|
||||
/// are intentional placeholders that Bundle B fills in. The tests pin
|
||||
/// the page route, page heading, nav group label, and the two child
|
||||
/// links so later bundles cannot regress the scaffolding.
|
||||
/// </summary>
|
||||
public class AuditLogPageScaffoldTests : BunitContext
|
||||
{
|
||||
public AuditLogPageScaffoldTests()
|
||||
{
|
||||
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
|
||||
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
|
||||
// an init call). Loose mode lets those unconfigured JS calls no-op so
|
||||
// the page scaffold smoke tests need not configure browser interop.
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new("Username", "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
||||
{
|
||||
return RenderAuditLogPageWithQuery(query: null, roles: roles);
|
||||
}
|
||||
|
||||
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPageWithQuery(string? query, params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
// The page now hosts AuditFilterBar + AuditResultsGrid which depend on
|
||||
// ISiteRepository and IAuditLogQueryService respectively (Bundle B).
|
||||
// Provide stand-ins so the scaffold smoke tests still render the page.
|
||||
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>());
|
||||
Services.AddSingleton(_queryService);
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/audit/log?{query}");
|
||||
}
|
||||
|
||||
// Bundle G (#23 M7-T15): the page now hosts an in-component
|
||||
// AuthorizeView around the Export-CSV button, so the page MUST
|
||||
// render inside a CascadingAuthenticationState. The router supplies
|
||||
// this in production; bUnit hosts the page directly so we wrap it
|
||||
// here.
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<AuditLogPage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<AuditLogPage>();
|
||||
}
|
||||
|
||||
private IRenderedComponent<NavMenu> RenderNavMenu(params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<NavMenu>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<NavMenu>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clicks the collapsible section header whose title matches, expanding it.
|
||||
/// Nav sections are collapsed by default, so a section's items are only in
|
||||
/// the DOM once expanded.
|
||||
/// </summary>
|
||||
private static void ExpandNavSection(IRenderedComponent<NavMenu> cut, string title)
|
||||
{
|
||||
var toggle = cut.FindAll("button.nav-section-toggle")
|
||||
.Single(b => b.TextContent.Contains(title, StringComparison.Ordinal));
|
||||
toggle.Click();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogPage_Renders_PageHeading()
|
||||
{
|
||||
var cut = RenderAuditLogPage("Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The H1 is the only positive scaffold assertion — the filter
|
||||
// bar and grid are still placeholders the Bundle B work fills.
|
||||
Assert.Contains("<h1", cut.Markup);
|
||||
Assert.Contains("Audit Log", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
|
||||
{
|
||||
var cut = RenderNavMenu("Admin", "Design", "Deployment");
|
||||
ExpandNavSection(cut, "Audit");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains(">Audit<", cut.Markup);
|
||||
Assert.Contains("/audit/log", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup()
|
||||
{
|
||||
var cut = RenderNavMenu("Admin", "Design", "Deployment");
|
||||
ExpandNavSection(cut, "Audit");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Both audit pages must appear after the Audit section header
|
||||
// in the rendered nav. We check both links + that the header
|
||||
// comes before either link in the markup, so they are in the
|
||||
// Audit group rather than orphaned under Monitoring.
|
||||
Assert.Contains("/audit/configuration", cut.Markup);
|
||||
Assert.Contains("/audit/log", cut.Markup);
|
||||
var headerIdx = cut.Markup.IndexOf(">Audit<", StringComparison.Ordinal);
|
||||
var configIdx = cut.Markup.IndexOf("/audit/configuration", StringComparison.Ordinal);
|
||||
var logIdx = cut.Markup.IndexOf("/audit/log", StringComparison.Ordinal);
|
||||
Assert.True(headerIdx >= 0 && headerIdx < configIdx,
|
||||
"Audit section header must precede the Configuration Audit Log link.");
|
||||
Assert.True(headerIdx >= 0 && headerIdx < logIdx,
|
||||
"Audit section header must precede the Audit Log link.");
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Bundle D — query-string drill-in parsing (#23 M7-T10..T12)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithCorrelationId_AppliesFilter_AndAutoLoads()
|
||||
{
|
||||
var corr = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Auto-load fires because correlationId is a real filter dimension.
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.CorrelationId == corr),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
|
||||
{
|
||||
// The "View this execution" drill-in lands on /audit/log?executionId={id}.
|
||||
// The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId
|
||||
// set, and auto-loads the grid.
|
||||
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == executionId),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin");
|
||||
|
||||
// An unparseable executionId leaves ExecutionId null. With no other filter
|
||||
// params present the page renders but does NOT call the query service.
|
||||
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads()
|
||||
{
|
||||
// The "View parent execution" drill-in (and operator-crafted URLs) land on
|
||||
// /audit/log?parentExecutionId={id}. The page parses the Guid, builds an
|
||||
// AuditLogQueryFilter with ParentExecutionId set, and auto-loads the grid.
|
||||
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == parentExecutionId),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin");
|
||||
|
||||
// An unparseable parentExecutionId leaves ParentExecutionId null. With no
|
||||
// other filter params present the page renders but does NOT call the query
|
||||
// service.
|
||||
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithTargetParam_AppliesTargetFilter()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.Target == "ExternalSystem-Alpha"),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithSiteParam_AppliesSiteFilter()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f =>
|
||||
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithStatusParam_AppliesStatusFilter()
|
||||
{
|
||||
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills
|
||||
// in with ?status=Failed. The page parses the enum (case-insensitive),
|
||||
// builds an AuditLogQueryFilter with Status set, and auto-loads.
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f =>
|
||||
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin");
|
||||
|
||||
// An unparseable status value leaves Status null. With no other filter
|
||||
// params present the page renders but does NOT call the query service
|
||||
// (matching the existing "no params" contract).
|
||||
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPage("Admin");
|
||||
|
||||
// The grid is in "no filter" state — the page heading renders, but the
|
||||
// query service must NOT be hit because nothing told us to load.
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Audit Log", cut.Markup);
|
||||
});
|
||||
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ExecutionTreePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.ExecutionTreePage;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). The page is reached via the "View execution chain"
|
||||
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
|
||||
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
|
||||
/// and hands the flat node list to the <c>ExecutionTree</c> component.
|
||||
/// </summary>
|
||||
public class ExecutionTreePageTests : BunitContext
|
||||
{
|
||||
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new("Username", "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private IRenderedComponent<ExecutionTreePage> RenderPage(string? query, params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
Services.AddSingleton(_queryService);
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/audit/execution-tree?{query}");
|
||||
}
|
||||
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<ExecutionTreePage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<ExecutionTreePage>();
|
||||
}
|
||||
|
||||
private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2)
|
||||
=> new(
|
||||
id, parent, rowCount,
|
||||
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
|
||||
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
|
||||
rowCount == 0 ? null : "plant-a",
|
||||
rowCount == 0 ? null : "boiler-3",
|
||||
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithExecutionId_CallsService_AndRendersTree()
|
||||
{
|
||||
var root = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var child = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
}));
|
||||
|
||||
var cut = RenderPage($"executionId={child}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().GetExecutionTreeAsync(child, Arg.Any<CancellationToken>());
|
||||
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
|
||||
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderPage(query: null, "Admin");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderPage("executionId=not-a-guid", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClickTreeNode_OpensExecutionDetailModal()
|
||||
{
|
||||
var root = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
var child = Guid.Parse("44444444-4444-4444-4444-444444444444");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
}));
|
||||
// The modal loads the double-clicked execution's audit rows on open.
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
// AuditEventDetail (reachable from the modal) owns a clipboard interop call.
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var cut = RenderPage($"executionId={child}", "Admin");
|
||||
|
||||
// The modal is absent until a node is activated.
|
||||
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"));
|
||||
|
||||
var body = cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body");
|
||||
body.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == child),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClosingExecutionDetailModal_HidesIt()
|
||||
{
|
||||
var root = Guid.Parse("55555555-5555-5555-5555-555555555555");
|
||||
var child = Guid.Parse("66666666-6666-6666-6666-666666666666");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
}));
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var cut = RenderPage($"executionId={child}", "Admin");
|
||||
|
||||
cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick();
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
|
||||
|
||||
cut.Find("[data-test=\"execution-detail-close\"]").Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
|
||||
{
|
||||
var attributes = typeof(ExecutionTreePage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.ToList();
|
||||
|
||||
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.Health;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Health Monitoring dashboard (Task 24).
|
||||
///
|
||||
/// Scope: the Notification Outbox KPI tile row added to the Health dashboard.
|
||||
/// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but
|
||||
/// <see cref="CommunicationService"/> is a concrete class whose outbox calls
|
||||
/// route through an injected notification-outbox <see cref="IActorRef"/>; the
|
||||
/// tests reuse the scripted-actor seam established by the Notification Report
|
||||
/// page tests (see <c>NotificationReportPageTests</c>).
|
||||
/// </summary>
|
||||
public class HealthPageTests : BunitContext
|
||||
{
|
||||
private readonly ActorSystem _system = ActorSystem.Create("health-page-tests");
|
||||
private readonly CommunicationService _comms;
|
||||
|
||||
// Mutable scripted reply — individual tests can override before rendering.
|
||||
private NotificationKpiResponse _kpiReply =
|
||||
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
|
||||
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
|
||||
|
||||
// Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests
|
||||
// that target the Site Call tiles override this before rendering.
|
||||
private SiteCallKpiResponse _siteCallKpiReply =
|
||||
new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1,
|
||||
DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3),
|
||||
StuckCount: 5);
|
||||
|
||||
public HealthPageTests()
|
||||
{
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
||||
_comms.SetNotificationOutbox(outbox);
|
||||
|
||||
var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
|
||||
_comms.SetSiteCallAudit(siteCallAudit);
|
||||
Services.AddSingleton(_comms);
|
||||
|
||||
var aggregator = Substitute.For<ICentralHealthAggregator>();
|
||||
aggregator.GetAllSiteStates()
|
||||
.Returns(new Dictionary<string, SiteHealthState>());
|
||||
Services.AddSingleton(aggregator);
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
|
||||
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
|
||||
// the existing assertions (Notification Outbox tiles, Online/Offline
|
||||
// counts) keep passing; tests that target the Audit tiles set their
|
||||
// own substitute.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Admin"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_OutboxKpiTiles_WithValues()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
// KPI data arrives via an async actor Ask after first render.
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Notification Outbox", cut.Markup);
|
||||
Assert.Contains("Queue Depth", cut.Markup);
|
||||
Assert.Contains("Stuck", cut.Markup);
|
||||
Assert.Contains("Parked", cut.Markup);
|
||||
// KPI numeric values surface in the tiles.
|
||||
Assert.Contains(">12<", cut.Markup); // QueueDepth
|
||||
Assert.Contains(">4<", cut.Markup); // StuckCount
|
||||
Assert.Contains(">3<", cut.Markup); // ParkedCount
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersLinkToTheNotificationKpisPage()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
var link = cut.Find("a[href='/notifications/kpis']");
|
||||
Assert.Contains("View details", link.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_AuditKpiTiles_WithValues()
|
||||
{
|
||||
// Override the default empty snapshot — this test wants concrete values
|
||||
// to land in the three Audit tiles.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: 250,
|
||||
ErrorEventsLastHour: 5,
|
||||
BacklogTotal: 17,
|
||||
AsOfUtc: DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The three audit tiles render at the documented data-test selectors.
|
||||
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
|
||||
// Volume shows the formatted thousand-separator value.
|
||||
Assert.Contains("250", cut.Markup);
|
||||
// Backlog renders 17.
|
||||
Assert.Contains("17", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_SiteCallKpiTiles_WithValues()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
// KPI data arrives via an async actor Ask after first render.
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Site Calls", cut.Markup);
|
||||
// The three Site Call tiles render at the documented data-test selectors.
|
||||
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
|
||||
// KPI numeric values surface in the tiles.
|
||||
Assert.Contains(">9<", cut.Markup); // BufferedCount
|
||||
Assert.Contains(">5<", cut.Markup); // StuckCount
|
||||
Assert.Contains(">2<", cut.Markup); // ParkedCount
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersLinkToTheSiteCallsReportPage()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
var link = cut.Find("a[href='/site-calls/report']");
|
||||
Assert.Contains("View details", link.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallKpiFailure_ShowsGracefulFallback()
|
||||
{
|
||||
_siteCallKpiReply = new SiteCallKpiResponse(
|
||||
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Failure must not crash the page; tiles fall back to a dash and the
|
||||
// inline error message surfaces.
|
||||
Assert.Contains("Site Calls", cut.Markup);
|
||||
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
|
||||
Assert.Contains("site call repository unavailable", cut.Markup);
|
||||
Assert.Contains(">—<", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboxKpiFailure_ShowsGracefulFallback()
|
||||
{
|
||||
_kpiReply = new NotificationKpiResponse(
|
||||
"k", false, "outbox repository unavailable", 0, 0, 0, 0, null);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Failure must not crash the page; tiles fall back to a dash.
|
||||
Assert.Contains("Notification Outbox", cut.Markup);
|
||||
Assert.Contains("Queue Depth", cut.Markup);
|
||||
Assert.Contains(">—<", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the notification-outbox actor. Replies to the KPI request
|
||||
/// with the test's currently-scripted response.
|
||||
/// </summary>
|
||||
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||
{
|
||||
public ScriptedOutboxActor(HealthPageTests test)
|
||||
{
|
||||
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
|
||||
/// the test's currently-scripted response.
|
||||
/// </summary>
|
||||
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
public ScriptedSiteCallAuditActor(HealthPageTests test)
|
||||
{
|
||||
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using NotificationKpisPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationKpis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Notification KPIs page.
|
||||
///
|
||||
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
||||
/// non-virtual methods, so NSubstitute cannot intercept it. Both the global and
|
||||
/// per-site KPI calls route through an injected <see cref="IActorRef"/> (the
|
||||
/// notification-outbox proxy), so the tests wire a real, lightweight
|
||||
/// <see cref="ActorSystem"/> with a scripted <see cref="ReceiveActor"/> that
|
||||
/// answers both <see cref="NotificationKpiRequest"/> and
|
||||
/// <see cref="PerSiteNotificationKpiRequest"/> — the same seam
|
||||
/// <c>SetNotificationOutbox</c> exists for.
|
||||
/// </summary>
|
||||
public class NotificationKpisPageTests : BunitContext
|
||||
{
|
||||
private readonly ActorSystem _system = ActorSystem.Create("notif-kpis-tests");
|
||||
private readonly CommunicationService _comms;
|
||||
|
||||
// Mutable scripted replies — individual tests can override before rendering.
|
||||
private NotificationKpiResponse _kpiReply =
|
||||
new("k", true, null, QueueDepth: 7, StuckCount: 2, ParkedCount: 1,
|
||||
DeliveredLastInterval: 42, OldestPendingAge: TimeSpan.FromMinutes(9));
|
||||
|
||||
private PerSiteNotificationKpiResponse _perSiteReply =
|
||||
new("p", true, null, new List<SiteNotificationKpiSnapshot>
|
||||
{
|
||||
new("plant-a", QueueDepth: 4, StuckCount: 1, ParkedCount: 0,
|
||||
DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)),
|
||||
});
|
||||
|
||||
public NotificationKpisPageTests()
|
||||
{
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
||||
_comms.SetNotificationOutbox(outbox);
|
||||
|
||||
Services.AddSingleton(_comms);
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Plant A", "plant-a") { Id = 1 },
|
||||
new("Plant B", "plant-b") { Id = 2 },
|
||||
}));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Deployment"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_RequiresDeploymentPolicy()
|
||||
{
|
||||
var attr = typeof(NotificationKpisPage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(attr);
|
||||
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersGlobalTilesAndPerSiteRows()
|
||||
{
|
||||
var cut = Render<NotificationKpisPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Queue Depth", cut.Markup);
|
||||
Assert.Contains("7", cut.Markup); // global tile value
|
||||
// Per-site row — site identifier "plant-a" resolves to its friendly name.
|
||||
Assert.Contains("Plant A", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowsKpiError_WhenGlobalKpiQueryFails()
|
||||
{
|
||||
_kpiReply = new NotificationKpiResponse(
|
||||
"k", false, "kpi down", 0, 0, 0, 0, null);
|
||||
|
||||
var cut = Render<NotificationKpisPage>();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowsPerSiteError_WhenPerSiteKpiQueryFails()
|
||||
{
|
||||
// Only the per-site path errors — the global KPI reply stays successful.
|
||||
_perSiteReply = new PerSiteNotificationKpiResponse(
|
||||
"p", false, "per-site down", new List<SiteNotificationKpiSnapshot>());
|
||||
|
||||
var cut = Render<NotificationKpisPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Per-site KPIs unavailable: per-site down", cut.Markup);
|
||||
// The two error paths are isolated — the global KPI alert (whose markup
|
||||
// opens ">KPIs unavailable:", without the "Per-site " prefix) must not appear.
|
||||
Assert.DoesNotContain(">KPIs unavailable:", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowsPerSiteEmptyState_WhenNoSites()
|
||||
{
|
||||
_perSiteReply = new PerSiteNotificationKpiResponse(
|
||||
"p", true, null, new List<SiteNotificationKpiSnapshot>());
|
||||
|
||||
var cut = Render<NotificationKpisPage>();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the notification-outbox actor. Replies to each KPI message
|
||||
/// type with the test's currently-scripted response.
|
||||
/// </summary>
|
||||
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||
{
|
||||
public ScriptedOutboxActor(NotificationKpisPageTests test)
|
||||
{
|
||||
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
||||
Receive<PerSiteNotificationKpiRequest>(_ => Sender.Tell(test._perSiteReply));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using NotificationListsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationLists;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the standalone Notification Lists page (Task 7).
|
||||
/// </summary>
|
||||
public class NotificationListsPageTests : BunitContext
|
||||
{
|
||||
private void WireAuthAndDialog()
|
||||
{
|
||||
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Design"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersNotificationListRows()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
repo.GetAllNotificationListsAsync()
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(
|
||||
new List<NotificationList> { new("Ops On-Call") { Id = 1 } }));
|
||||
repo.GetRecipientsByListIdAsync(1)
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(
|
||||
new List<NotificationRecipient> { new("Jane", "jane@example.com") }));
|
||||
Services.AddSingleton(repo);
|
||||
WireAuthAndDialog();
|
||||
|
||||
var cut = Render<NotificationListsPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Ops On-Call", cut.Markup);
|
||||
Assert.Contains("jane@example.com", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowsEmptyState_WhenNoLists()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
repo.GetAllNotificationListsAsync()
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(
|
||||
new List<NotificationList>()));
|
||||
Services.AddSingleton(repo);
|
||||
WireAuthAndDialog();
|
||||
|
||||
var cut = Render<NotificationListsPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Contains("No notification lists", cut.Markup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteList_ConfirmsThenDeletesAndReloads()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
repo.GetAllNotificationListsAsync()
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(
|
||||
new List<NotificationList> { new("Ops On-Call") { Id = 1 } }));
|
||||
repo.GetRecipientsByListIdAsync(1)
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(
|
||||
new List<NotificationRecipient>()));
|
||||
Services.AddSingleton(repo);
|
||||
WireAuthAndDialog();
|
||||
|
||||
var cut = Render<NotificationListsPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("Ops On-Call"));
|
||||
|
||||
var deleteButton = cut.FindAll("tbody tr button")
|
||||
.First(b => b.TextContent.Contains("Delete"));
|
||||
deleteButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
repo.Received().DeleteNotificationListAsync(1);
|
||||
repo.Received().SaveChangesAsync();
|
||||
// Reload re-invokes the list query (once on init, once after delete).
|
||||
repo.Received(2).GetAllNotificationListsAsync();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using NotificationReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for the Notification Report row-detail modal — double-clicking a
|
||||
/// notification row opens a Bootstrap modal showing that notification's full,
|
||||
/// untruncated details.
|
||||
///
|
||||
/// Mirrors <see cref="NotificationReportPageTests"/>'s seam: the report's
|
||||
/// <see cref="CommunicationService"/> calls route through an injected scripted
|
||||
/// actor (the notification-outbox proxy).
|
||||
/// </summary>
|
||||
public class NotificationReportDetailModalTests : BunitContext
|
||||
{
|
||||
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
|
||||
private readonly CommunicationService _comms;
|
||||
|
||||
private NotificationDetailResponse _detailReply =
|
||||
new("d", true, null, new NotificationDetail(
|
||||
NotificationId: "notif-aaaaaaaa-1111-full-id",
|
||||
Type: "Email",
|
||||
ListName: "Ops On-Call",
|
||||
Subject: "Pump fault at Plant-A",
|
||||
Body: "Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
||||
Status: "Parked",
|
||||
RetryCount: 3,
|
||||
LastError: "SMTP timeout connecting to mail relay",
|
||||
ResolvedTargets: "[\"ops@example.com\",\"oncall@example.com\"]",
|
||||
TypeData: null,
|
||||
SourceSiteId: "plant-a",
|
||||
SourceInstanceId: "Pump-001",
|
||||
SourceScript: "PumpFault.csx",
|
||||
SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31),
|
||||
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
LastAttemptAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
NextAttemptAt: null,
|
||||
DeliveredAt: null));
|
||||
|
||||
private NotificationOutboxQueryResponse _queryReply =
|
||||
new("q", true, null, new List<NotificationSummary>
|
||||
{
|
||||
new("notif-aaaaaaaa-1111-full-id", "Email", "Ops On-Call", "Pump fault at Plant-A",
|
||||
"Parked", RetryCount: 3, LastError: "SMTP timeout connecting to mail relay",
|
||||
SourceSiteId: "plant-a", SourceInstanceId: "Pump-001",
|
||||
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
DeliveredAt: null, IsStuck: true),
|
||||
new("notif-bbbbbbbb-2222-full-id", "Email", "Maintenance", "Daily summary",
|
||||
"Delivered", RetryCount: 0, LastError: null, SourceSiteId: "plant-b",
|
||||
SourceInstanceId: null, CreatedAt: DateTimeOffset.UtcNow.AddHours(-2),
|
||||
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
|
||||
}, TotalCount: 2);
|
||||
|
||||
public NotificationReportDetailModalTests()
|
||||
{
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
||||
_comms.SetNotificationOutbox(outbox);
|
||||
|
||||
Services.AddSingleton(_comms);
|
||||
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Plant A", "plant-a") { Id = 1 },
|
||||
new("Plant B", "plant-b") { Id = 2 },
|
||||
}));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Deployment"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClickRow_OpensDetailModal()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
// No modal initially.
|
||||
Assert.Empty(cut.FindAll(".modal.show"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var modal = cut.Find(".modal.show");
|
||||
Assert.Contains("Pump fault at Plant-A", modal.TextContent);
|
||||
Assert.Contains("Ops On-Call", modal.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modal_ShowsFullNotificationId_NotTruncated()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var modal = cut.Find(".modal.show");
|
||||
// The grid renders ShortId(...) (first 12 chars); the modal must show
|
||||
// the complete identifier.
|
||||
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_DismissesModal()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForState(() => cut.FindAll(".modal.show").Count > 0);
|
||||
|
||||
var closeButton = cut.Find(".modal.show .modal-footer button");
|
||||
closeButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modal_ShowsLastError_WhenPresent()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var modal = cut.Find(".modal.show");
|
||||
Assert.Contains("SMTP timeout connecting to mail relay", modal.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modal_FetchesAndShowsBody()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var modal = cut.Find(".modal.show");
|
||||
Assert.Contains(
|
||||
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
||||
modal.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modal_ShowsRecipients_FromResolvedTargets()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var modal = cut.Find(".modal.show");
|
||||
Assert.Contains("ops@example.com", modal.TextContent);
|
||||
Assert.Contains("oncall@example.com", modal.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
|
||||
{
|
||||
_detailReply = _detailReply with
|
||||
{
|
||||
Detail = _detailReply.Detail! with { ResolvedTargets = null },
|
||||
};
|
||||
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var modal = cut.Find(".modal.show");
|
||||
Assert.Contains("Not yet resolved", modal.TextContent);
|
||||
Assert.Contains("Ops On-Call", modal.TextContent);
|
||||
Assert.Contains("at delivery time", modal.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modal_ShowsError_WhenDetailFetchFails()
|
||||
{
|
||||
_detailReply = new NotificationDetailResponse("d", false, "detail store unavailable", null);
|
||||
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var modal = cut.Find(".modal.show");
|
||||
// The error surfaces in the body/recipient sections...
|
||||
Assert.Contains("detail store unavailable", modal.TextContent);
|
||||
// ...but the summary fields (from the grid row) still render.
|
||||
Assert.Contains("Ops On-Call", modal.TextContent);
|
||||
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||
{
|
||||
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
|
||||
{
|
||||
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
|
||||
Receive<NotificationDetailRequest>(r => Sender.Tell(test._detailReply with
|
||||
{
|
||||
CorrelationId = r.CorrelationId,
|
||||
}));
|
||||
Receive<RetryNotificationRequest>(r =>
|
||||
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
|
||||
Receive<DiscardNotificationRequest>(r =>
|
||||
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using NotificationReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Notification Report page.
|
||||
///
|
||||
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
||||
/// non-virtual methods, so NSubstitute cannot intercept it. The report calls all
|
||||
/// route through an injected <see cref="IActorRef"/> (the notification-outbox
|
||||
/// proxy), so the tests wire a real, lightweight <see cref="ActorSystem"/> with a
|
||||
/// scripted <see cref="ReceiveActor"/> that replies with fixed responses — the
|
||||
/// same seam <c>SetNotificationOutbox</c> exists for.
|
||||
/// </summary>
|
||||
public class NotificationReportPageTests : BunitContext
|
||||
{
|
||||
private readonly ActorSystem _system = ActorSystem.Create("notif-report-tests");
|
||||
private readonly CommunicationService _comms;
|
||||
|
||||
// Mutable scripted reply — individual tests can override before rendering.
|
||||
private NotificationOutboxQueryResponse _queryReply =
|
||||
new("q", true, null, new List<NotificationSummary>
|
||||
{
|
||||
new("notif-aaaaaaaa-1111", "Email", "Ops On-Call", "Pump fault at Plant-A",
|
||||
"Parked", RetryCount: 3, LastError: "SMTP timeout", SourceSiteId: "plant-a",
|
||||
SourceInstanceId: "Pump-001", CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
DeliveredAt: null, IsStuck: true),
|
||||
new("notif-bbbbbbbb-2222", "Email", "Maintenance", "Daily summary",
|
||||
"Delivered", RetryCount: 0, LastError: null, SourceSiteId: "plant-b",
|
||||
SourceInstanceId: null, CreatedAt: DateTimeOffset.UtcNow.AddHours(-2),
|
||||
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
|
||||
}, TotalCount: 2);
|
||||
|
||||
// Records the most recent retry/discard requests the actor received.
|
||||
private readonly List<RetryNotificationRequest> _retryRequests = new();
|
||||
private readonly List<DiscardNotificationRequest> _discardRequests = new();
|
||||
|
||||
public NotificationReportPageTests()
|
||||
{
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
||||
_comms.SetNotificationOutbox(outbox);
|
||||
|
||||
Services.AddSingleton(_comms);
|
||||
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Plant A", "plant-a") { Id = 1 },
|
||||
new("Plant B", "plant-b") { Id = 2 },
|
||||
}));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Deployment"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
// CentralUI-028: the page now injects SiteScopeService — the test user
|
||||
// has no SiteId claims, so this resolves to system-wide and the
|
||||
// pre-existing test expectations hold.
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_RequiresDeploymentPolicy()
|
||||
{
|
||||
var attr = typeof(NotificationReportPage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(attr);
|
||||
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_NotificationRows()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Pump fault at Plant-A", cut.Markup);
|
||||
Assert.Contains("Daily summary", cut.Markup);
|
||||
Assert.Contains("Ops On-Call", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StuckRow_IsBadged()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var stuckRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
|
||||
// The stuck row carries a visible "Stuck" badge.
|
||||
Assert.Contains("badge", stuckRow.InnerHtml);
|
||||
Assert.Contains("Stuck", stuckRow.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickRetry_OnParkedRow_CallsRetryNotification()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
var retryButton = parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Retry"));
|
||||
|
||||
retryButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(_retryRequests);
|
||||
Assert.Equal("notif-aaaaaaaa-1111", _retryRequests[0].NotificationId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickDiscard_OnParkedRow_CallsDiscardNotification()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
var discardButton = parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Discard"));
|
||||
|
||||
discardButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(_discardRequests);
|
||||
Assert.Equal("notif-aaaaaaaa-1111", _discardRequests[0].NotificationId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryFailure_ShowsErrorMessage()
|
||||
{
|
||||
_queryReply = new NotificationOutboxQueryResponse(
|
||||
"q", false, "outbox query backend unavailable",
|
||||
new List<NotificationSummary>(), TotalCount: 0);
|
||||
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Contains("outbox query backend unavailable", cut.Markup));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Bundle D drill-in (#23 M7-T10) — every notification row carries a
|
||||
// "View audit history" link to /audit/log?correlationId={NotificationId}.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NotificationRow_ViewAuditHistory_Link_HasCorrectHref()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Both rows (Parked + Delivered) must surface the link — the drill-in
|
||||
// is row-scope, not status-scope. We pin the parked row's href to the
|
||||
// canonical correlationId-deep-link shape.
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(link);
|
||||
Assert.Equal(
|
||||
"/audit/log?correlationId=notif-aaaaaaaa-1111",
|
||||
link!.GetAttribute("href"));
|
||||
Assert.Contains("View audit history", link.TextContent);
|
||||
|
||||
var deliveredRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Daily summary"));
|
||||
var deliveredLink = deliveredRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(deliveredLink);
|
||||
Assert.Equal(
|
||||
"/audit/log?correlationId=notif-bbbbbbbb-2222",
|
||||
deliveredLink!.GetAttribute("href"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_NavigatesTo_AuditLog_WithCorrelationId()
|
||||
{
|
||||
// The drill-in is a plain <a href> — browser-native navigation, not a
|
||||
// Blazor onclick handler — so this test verifies the rendered anchor's
|
||||
// attributes are exactly what a browser would follow: href, role, and
|
||||
// human-visible text. (Triggering bUnit's .Click() on a bare anchor
|
||||
// raises MissingEventHandlerException because there is no onclick
|
||||
// handler to invoke; the navigation contract lives in the <a> markup.)
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]")!;
|
||||
|
||||
Assert.Equal("a", link.TagName, ignoreCase: true);
|
||||
Assert.Equal("/audit/log?correlationId=notif-aaaaaaaa-1111", link.GetAttribute("href"));
|
||||
Assert.Contains("View audit history", link.TextContent);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the notification-outbox actor. Replies to each outbox message
|
||||
/// type with the test's currently-scripted response.
|
||||
/// </summary>
|
||||
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||
{
|
||||
public ScriptedOutboxActor(NotificationReportPageTests test)
|
||||
{
|
||||
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
|
||||
Receive<RetryNotificationRequest>(r =>
|
||||
{
|
||||
test._retryRequests.Add(r);
|
||||
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null));
|
||||
});
|
||||
Receive<DiscardNotificationRequest>(r =>
|
||||
{
|
||||
test._discardRequests.Add(r);
|
||||
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
|
||||
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI-033: tests for the drill-in / query-string code paths on the two
|
||||
/// newest pages (TransportImport + SiteCallsReport). The base happy-path cases
|
||||
/// (Parked, stuck=true, no params) live next to the rest of the page's tests in
|
||||
/// <c>SiteCallsReportPageTests</c> / <c>TransportImportPageTests</c>; this file
|
||||
/// fills the remaining gaps the finding called out — unrecognised values, case
|
||||
/// handling, and the no-query-string default for the Transport wizard.
|
||||
/// </summary>
|
||||
public sealed class QueryStringDrillInTests
|
||||
{
|
||||
// STM: CentralUI-033-QueryStringDrillIn marker — used by grep verification.
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// SiteCallsReport — ?status=
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SiteCallsReport_StatusParam_CaseInsensitiveMatch_NormalisesToCanonicalCasing()
|
||||
{
|
||||
// The dropdown options use canonical casing ("Parked"). The KPI tiles
|
||||
// emit canonical, but a hand-crafted ?status=parked URL must still seed
|
||||
// the filter — the parser is case-insensitive and the seeded value uses
|
||||
// the canonical casing so the <select> can bind it.
|
||||
using var ctx = new SiteCallsReportFixture();
|
||||
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/site-calls/report?status=parked");
|
||||
|
||||
var cut = ctx.Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(ctx.QueryRequests);
|
||||
// Normalised to canonical casing (the dropdown's option text), not
|
||||
// the URL's raw "parked".
|
||||
Assert.Equal("Parked", ctx.QueryRequests[0].StatusFilter);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallsReport_StatusParam_Unrecognised_IsSilentlyDropped()
|
||||
{
|
||||
// Lax parsing: an unrecognised status value is ignored, leaving the
|
||||
// filter empty so the page loads unfiltered. Mirrors AuditLogPage's
|
||||
// drill-in convention — a hand-crafted bad URL must not break the page.
|
||||
using var ctx = new SiteCallsReportFixture();
|
||||
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/site-calls/report?status=NotARealStatus");
|
||||
|
||||
var cut = ctx.Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(ctx.QueryRequests);
|
||||
Assert.Null(ctx.QueryRequests[0].StatusFilter);
|
||||
Assert.False(ctx.QueryRequests[0].StuckOnly);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallsReport_StuckParam_NonBoolean_IsSilentlyDropped()
|
||||
{
|
||||
// bool.TryParse fails for "yes"/"1" — the parser drops the value and
|
||||
// leaves StuckOnly = false, mirroring the unrecognised-status path.
|
||||
using var ctx = new SiteCallsReportFixture();
|
||||
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/site-calls/report?stuck=yes");
|
||||
|
||||
var cut = ctx.Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(ctx.QueryRequests);
|
||||
Assert.False(ctx.QueryRequests[0].StuckOnly);
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// TransportImport — no query-string parameters on this route
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI-033: TransportImport.razor declares no <c>[Parameter]</c> /
|
||||
/// <c>SupplyParameterFromQuery</c> bindings — the wizard's initial state is
|
||||
/// purely <c>ImportWizardStep.Upload</c> regardless of the query-string. This
|
||||
/// test pins that contract: navigating with an unrecognised query-string
|
||||
/// param does not throw and does not change the initial step.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TransportImport_UnrecognisedQueryStringParam_DoesNotChangeInitialStep()
|
||||
{
|
||||
using var ctx = new TransportImportFixture();
|
||||
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/design/transport/import?bundleImportId=11111111-1111-1111-1111-111111111111&foo=bar");
|
||||
|
||||
var cut = ctx.Render<TransportImportPage>();
|
||||
|
||||
// The wizard starts at Upload regardless of any drill-in query string —
|
||||
// the page has no [Parameter]-bound properties so unknown keys are
|
||||
// silently ignored by Blazor's parameter binding.
|
||||
var step = (TransportImportPage.ImportWizardStep)typeof(TransportImportPage)
|
||||
.GetField("_step",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
||||
.GetValue(cut.Instance)!;
|
||||
Assert.Equal(TransportImportPage.ImportWizardStep.Upload, step);
|
||||
|
||||
// And the Step-1 InputFile control is rendered — the page came up clean.
|
||||
Assert.NotNull(cut.Find("input[type='file']"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test-scoped fixtures — kept inside this file to bound the diff.
|
||||
// The existing page-level test files have their own larger fixtures;
|
||||
// these copies are intentionally minimal (only what the drill-in
|
||||
// tests need).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private sealed class SiteCallsReportFixture : BunitContext
|
||||
{
|
||||
private readonly ActorSystem _system = ActorSystem.Create("qs-drillin-tests");
|
||||
|
||||
public readonly CommunicationService Comms;
|
||||
public readonly List<SiteCallQueryRequest> QueryRequests = new();
|
||||
|
||||
public SiteCallsReportFixture()
|
||||
{
|
||||
Comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
|
||||
Comms.SetSiteCallAudit(auditProxy);
|
||||
|
||||
Services.AddSingleton(Comms);
|
||||
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Plant A", "plant-a") { Id = 1 },
|
||||
}));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Deployment"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
public ScriptedSiteCallAuditActor(SiteCallsReportFixture fixture)
|
||||
{
|
||||
Receive<SiteCallQueryRequest>(req =>
|
||||
{
|
||||
fixture.QueryRequests.Add(req);
|
||||
Sender.Tell(new SiteCallQueryResponse(
|
||||
req.CorrelationId, true, null,
|
||||
new List<SiteCallSummary>(), null, null));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
=> Task.FromResult(true);
|
||||
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
private sealed class TransportImportFixture : BunitContext
|
||||
{
|
||||
public TransportImportFixture()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var importer = Substitute.For<IBundleImporter>();
|
||||
Services.AddSingleton(importer);
|
||||
Services.AddSingleton(Substitute.For<IAuditService>());
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
MaxBundleSizeMb = 10,
|
||||
MaxUnlockAttemptsPerSession = 3,
|
||||
}));
|
||||
|
||||
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 claims = new List<Claim>
|
||||
{
|
||||
new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.UsernameClaimType, "alice"),
|
||||
new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Admin"),
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
|
||||
///
|
||||
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
||||
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
|
||||
/// route through an injected <see cref="IActorRef"/> (the Site Call Audit proxy),
|
||||
/// so the tests wire a real, lightweight <see cref="ActorSystem"/> with a scripted
|
||||
/// <see cref="ReceiveActor"/> that replies with fixed responses — the same seam
|
||||
/// <c>SetSiteCallAudit</c> exists for. Mirrors <see cref="NotificationReportPageTests"/>.
|
||||
/// </summary>
|
||||
public class SiteCallsReportPageTests : BunitContext
|
||||
{
|
||||
private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests");
|
||||
private readonly CommunicationService _comms;
|
||||
|
||||
private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
// Mutable scripted reply — individual tests can override before rendering.
|
||||
private SiteCallQueryResponse _queryReply = new(
|
||||
"q", true, null,
|
||||
new List<SiteCallSummary>
|
||||
{
|
||||
new(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked",
|
||||
RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503,
|
||||
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
TerminalAtUtc: null, IsStuck: true),
|
||||
new(FailedId, "plant-b", "DbOutbound", "Historian.Write", "Failed",
|
||||
RetryCount: 1, LastError: "constraint violation", HttpStatus: null,
|
||||
CreatedAtUtc: DateTime.UtcNow.AddHours(-2), UpdatedAtUtc: DateTime.UtcNow.AddHours(-2),
|
||||
TerminalAtUtc: DateTime.UtcNow.AddHours(-2), IsStuck: false),
|
||||
},
|
||||
NextAfterCreatedAtUtc: null,
|
||||
NextAfterId: null);
|
||||
|
||||
// Records the most recent retry/discard requests the actor received.
|
||||
private readonly List<SiteCallQueryRequest> _queryRequests = new();
|
||||
private readonly List<RetrySiteCallRequest> _retryRequests = new();
|
||||
private readonly List<DiscardSiteCallRequest> _discardRequests = new();
|
||||
|
||||
// Scripted relay responses — overridable per test.
|
||||
private RetrySiteCallResponse _retryReply =
|
||||
new("q", SiteCallRelayOutcome.Applied, true, true, null);
|
||||
private DiscardSiteCallResponse _discardReply =
|
||||
new("q", SiteCallRelayOutcome.Applied, true, true, null);
|
||||
|
||||
public SiteCallsReportPageTests()
|
||||
{
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
|
||||
_comms.SetSiteCallAudit(auditProxy);
|
||||
|
||||
Services.AddSingleton(_comms);
|
||||
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Plant A", "plant-a") { Id = 1 },
|
||||
new("Plant B", "plant-b") { Id = 2 },
|
||||
}));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Deployment"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_RequiresDeploymentPolicy()
|
||||
{
|
||||
var attr = typeof(SiteCallsReportPage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(attr);
|
||||
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_SiteCallRows()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("ERP.GetOrder", cut.Markup);
|
||||
Assert.Contains("Historian.Write", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StuckRow_IsBadged()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var stuckRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
Assert.Contains("badge", stuckRow.InnerHtml);
|
||||
Assert.Contains("Stuck", stuckRow.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryDiscardButtons_ShownOnlyOnParkedRows()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var failedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Historian.Write"));
|
||||
|
||||
// The Parked row carries Retry + Discard buttons.
|
||||
Assert.Contains(parkedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Retry"));
|
||||
Assert.Contains(parkedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Discard"));
|
||||
|
||||
// The Failed row carries neither — Retry/Discard are Parked-only.
|
||||
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Retry"));
|
||||
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Discard"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var retryButton = parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Retry"));
|
||||
|
||||
retryButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(_retryRequests);
|
||||
Assert.Equal(ParkedId, _retryRequests[0].TrackedOperationId);
|
||||
// The relay carries the owning site so central can route it.
|
||||
Assert.Equal("plant-a", _retryRequests[0].SourceSite);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var discardButton = parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Discard"));
|
||||
|
||||
discardButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(_discardRequests);
|
||||
Assert.Equal(ParkedId, _discardRequests[0].TrackedOperationId);
|
||||
Assert.Equal("plant-a", _discardRequests[0].SourceSite);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryRelay_SiteUnreachable_ShowsDistinctMessage()
|
||||
{
|
||||
// The relay never reached the owning site — a transient transport
|
||||
// condition, surfaced distinctly from a generic failure.
|
||||
_retryReply = new RetrySiteCallResponse(
|
||||
"q", SiteCallRelayOutcome.SiteUnreachable, Success: false, SiteReachable: false,
|
||||
ErrorMessage: "Site plant-a is offline — relay not delivered.");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Retry"))
|
||||
.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Contains("offline", cut.Markup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryFailure_ShowsErrorMessage()
|
||||
{
|
||||
_queryReply = new SiteCallQueryResponse(
|
||||
"q", false, "site call query backend unavailable",
|
||||
new List<SiteCallSummary>(), null, null);
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Contains("site call query backend unavailable", cut.Markup));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Drill-in — every row carries a "View audit history" link to
|
||||
// /audit/log?correlationId={TrackedOperationId}.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Both rows (Parked + Failed) surface the link — the drill-in is
|
||||
// row-scope, not status-scope.
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(link);
|
||||
Assert.Equal(
|
||||
$"/audit/log?correlationId={ParkedId}",
|
||||
link!.GetAttribute("href"));
|
||||
Assert.Contains("View audit history", link.TextContent);
|
||||
|
||||
var failedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Historian.Write"));
|
||||
var failedLink = failedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(failedLink);
|
||||
Assert.Equal(
|
||||
$"/audit/log?correlationId={FailedId}",
|
||||
failedLink!.GetAttribute("href"));
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Keyset paging — Next is driven by the response's NextAfter* cursor, not by
|
||||
// page numbers; the request echoes the cursor back to the actor.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Paging_NextButton_HiddenWhenNoFurtherPage()
|
||||
{
|
||||
// The default reply returns 2 rows and no NextAfter* cursor — there is no
|
||||
// further page, so Next is disabled.
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var next = cut.Find("[data-test='site-calls-next']");
|
||||
Assert.True(next.HasAttribute("disabled"));
|
||||
var prev = cut.Find("[data-test='site-calls-prev']");
|
||||
Assert.True(prev.HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paging_NextButton_AdvancesUsingKeysetCursor()
|
||||
{
|
||||
// A full page (PageSize=50 rows) plus a NextAfter* cursor: Next is live
|
||||
// and, when clicked, the follow-up query carries that cursor.
|
||||
var firstPage = new List<SiteCallSummary>();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
firstPage.Add(new SiteCallSummary(
|
||||
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
|
||||
RetryCount: 0, LastError: null, HttpStatus: 200,
|
||||
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
|
||||
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
|
||||
}
|
||||
|
||||
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
|
||||
_queryReply = new SiteCallQueryResponse(
|
||||
"q", true, null, firstPage,
|
||||
NextAfterCreatedAtUtc: cursorCreated,
|
||||
NextAfterId: cursorId);
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
|
||||
|
||||
var next = cut.Find("[data-test='site-calls-next']");
|
||||
Assert.False(next.HasAttribute("disabled"));
|
||||
|
||||
next.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Two queries fired: the initial load and the Next click. The second
|
||||
// carries the keyset cursor echoed by the first response.
|
||||
Assert.Equal(2, _queryRequests.Count);
|
||||
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
|
||||
Assert.Equal(cursorId, _queryRequests[1].AfterId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor()
|
||||
{
|
||||
// The keyset back-stack is the trickiest paging path: Next pushes the
|
||||
// current cursor, Prev pops it and refetches that prior page. Page 1 is
|
||||
// opened with the empty (null, null) cursor, so after Next→Previous the
|
||||
// follow-up query must carry (null, null) again.
|
||||
var firstPage = new List<SiteCallSummary>();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
firstPage.Add(new SiteCallSummary(
|
||||
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
|
||||
RetryCount: 0, LastError: null, HttpStatus: 200,
|
||||
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
|
||||
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
|
||||
}
|
||||
|
||||
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
|
||||
_queryReply = new SiteCallQueryResponse(
|
||||
"q", true, null, firstPage,
|
||||
NextAfterCreatedAtUtc: cursorCreated,
|
||||
NextAfterId: cursorId);
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
|
||||
|
||||
// Step forward — query 2 carries the keyset cursor.
|
||||
var next = cut.Find("[data-test='site-calls-next']");
|
||||
next.Click();
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Equal(2, _queryRequests.Count);
|
||||
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
|
||||
});
|
||||
|
||||
// Previous is now live (the back-stack has one entry); click it.
|
||||
var prev = cut.Find("[data-test='site-calls-prev']");
|
||||
Assert.False(prev.HasAttribute("disabled"));
|
||||
prev.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Query 3 is the Previous refetch — the back-stack popped the page-1
|
||||
// cursor, which is the empty (null, null) first-page cursor.
|
||||
Assert.Equal(3, _queryRequests.Count);
|
||||
Assert.Null(_queryRequests[2].AfterCreatedAtUtc);
|
||||
Assert.Null(_queryRequests[2].AfterId);
|
||||
// Back on page 1, the back-stack is empty again so Previous re-disables.
|
||||
Assert.True(cut.Find("[data-test='site-calls-prev']").HasAttribute("disabled"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast()
|
||||
{
|
||||
// NotParked is a definitive answer from the site (nothing to do), not a
|
||||
// failure — it surfaces as a single info toast, never an error. This
|
||||
// also guards the single-toast contract: a non-Applied outcome must
|
||||
// produce exactly one toast.
|
||||
_retryReply = new RetrySiteCallResponse(
|
||||
"q", SiteCallRelayOutcome.NotParked, Success: false, SiteReachable: true,
|
||||
ErrorMessage: "The cached call is no longer parked.");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Retry"))
|
||||
.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("no longer parked", cut.Markup);
|
||||
// Exactly one toast — the ShowRelayOutcome switch owns the single
|
||||
// toast; no second (error) toast piggybacks on the same response.
|
||||
Assert.Single(cut.FindAll(".toast"));
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
|
||||
// here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
|
||||
// params must seed the filter BEFORE the first query so the initial grid load
|
||||
// is already filtered, and the filter card controls must reflect the values.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
|
||||
{
|
||||
// The Parked KPI tile emits ?status=Parked — set the URI before render.
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/site-calls/report?status=Parked");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The first (and only) query the page issues carries the Parked
|
||||
// status filter — the grid load is pre-filtered, not unfiltered.
|
||||
Assert.Single(_queryRequests);
|
||||
Assert.Equal("Parked", _queryRequests[0].StatusFilter);
|
||||
|
||||
// The Status <select> control reflects the seeded value so the
|
||||
// operator sees the filter and can Clear it.
|
||||
var statusSelect = cut.Find("#sc-status");
|
||||
Assert.Equal("Parked", statusSelect.GetAttribute("value"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
|
||||
{
|
||||
// The Stuck KPI tile emits ?stuck=true.
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/site-calls/report?stuck=true");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The first query carries StuckOnly = true.
|
||||
Assert.Single(_queryRequests);
|
||||
Assert.True(_queryRequests[0].StuckOnly);
|
||||
|
||||
// The "Stuck only" checkbox is checked.
|
||||
var stuckCheckbox = cut.Find("#sc-stuck-only");
|
||||
Assert.True(stuckCheckbox.HasAttribute("checked"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
|
||||
{
|
||||
// No drill-in params — the page loads exactly as before: an unfiltered
|
||||
// query and no status/stuck filter set on the controls.
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(_queryRequests);
|
||||
Assert.Null(_queryRequests[0].StatusFilter);
|
||||
Assert.False(_queryRequests[0].StuckOnly);
|
||||
|
||||
var statusSelect = cut.Find("#sc-status");
|
||||
Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
|
||||
var stuckCheckbox = cut.Find("#sc-stuck-only");
|
||||
Assert.False(stuckCheckbox.HasAttribute("checked"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
|
||||
{
|
||||
// CentralUI-028: a Deployment user scoped to Plant A only must not see
|
||||
// Plant B rows in the grid, even though the query response carried both.
|
||||
// Last AuthenticationStateProvider registration wins on resolution.
|
||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("Username", "scoped"),
|
||||
new Claim(ClaimTypes.Role, "Deployment"),
|
||||
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
|
||||
}, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.FindAll("table tbody tr").Count > 0,
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
var rows = cut.FindAll("table tbody tr");
|
||||
Assert.Single(rows);
|
||||
// Plant A row only; Plant B (FailedId) row must be filtered out.
|
||||
Assert.Contains(ParkedId.ToString("N")[..12], rows[0].TextContent);
|
||||
Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the Site Call Audit actor. Replies to each message type with
|
||||
/// the test's currently-scripted response.
|
||||
/// </summary>
|
||||
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
|
||||
{
|
||||
Receive<SiteCallQueryRequest>(r =>
|
||||
{
|
||||
test._queryRequests.Add(r);
|
||||
Sender.Tell(test._queryReply);
|
||||
});
|
||||
Receive<RetrySiteCallRequest>(r =>
|
||||
{
|
||||
test._retryRequests.Add(r);
|
||||
Sender.Tell(test._retryReply);
|
||||
});
|
||||
Receive<DiscardSiteCallRequest>(r =>
|
||||
{
|
||||
test._discardRequests.Add(r);
|
||||
Sender.Tell(test._discardReply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using SmtpConfigurationPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.SmtpConfiguration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the SMTP Configuration page — specifically the TlsMode
|
||||
/// field added so the UI exposes all five user-relevant SmtpConfiguration fields.
|
||||
/// </summary>
|
||||
public class SmtpConfigurationPageTests : BunitContext
|
||||
{
|
||||
private void WireAuth()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Admin"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
private static SmtpConfiguration Sample() =>
|
||||
new("smtp.example.com", "Basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1,
|
||||
Port = 587,
|
||||
TlsMode = "StartTLS",
|
||||
Credentials = "user:pass",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void EditForm_RendersTlsModeSelectWithAllThreeModes()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
repo.GetAllSmtpConfigurationsAsync()
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
|
||||
new List<SmtpConfiguration> { Sample() }));
|
||||
Services.AddSingleton(repo);
|
||||
WireAuth();
|
||||
|
||||
var cut = Render<SmtpConfigurationPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("smtp.example.com"));
|
||||
|
||||
cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var selects = cut.FindAll("select");
|
||||
var tlsSelect = selects.Single(s => s.QuerySelectorAll("option")
|
||||
.Any(o => o.TextContent == "StartTLS"));
|
||||
var modes = tlsSelect.QuerySelectorAll("option").Select(o => o.TextContent).ToList();
|
||||
Assert.Equal(new[] { "None", "StartTLS", "SSL" }, modes);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOnlyView_ShowsTlsMode()
|
||||
{
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
repo.GetAllSmtpConfigurationsAsync()
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
|
||||
new List<SmtpConfiguration> { Sample() }));
|
||||
Services.AddSingleton(repo);
|
||||
WireAuth();
|
||||
|
||||
var cut = Render<SmtpConfigurationPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("TLS Mode", cut.Markup);
|
||||
Assert.Contains("StartTLS", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavingEdit_PersistsChosenTlsMode()
|
||||
{
|
||||
var config = Sample();
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
repo.GetAllSmtpConfigurationsAsync()
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
|
||||
new List<SmtpConfiguration> { config }));
|
||||
Services.AddSingleton(repo);
|
||||
WireAuth();
|
||||
|
||||
var cut = Render<SmtpConfigurationPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("smtp.example.com"));
|
||||
|
||||
cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click();
|
||||
|
||||
var tlsSelect = cut.FindAll("select")
|
||||
.Single(s => s.QuerySelectorAll("option").Any(o => o.TextContent == "StartTLS"));
|
||||
tlsSelect.Change("SSL");
|
||||
|
||||
cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
repo.Received().UpdateSmtpConfigurationAsync(
|
||||
Arg.Is<SmtpConfiguration>(c => c.TlsMode == "SSL"));
|
||||
repo.Received().SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user