From 1a7e7351493646dead85e6ae2f10990bc8cfdfaa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 03:34:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(centralui):=20Secured=20Writes=20page=20?= =?UTF-8?q?=E2=80=94=20operator=20submit=20+=20verifier=20queue=20+=20hist?= =?UTF-8?q?ory=20(T14b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Layout/NavMenu.razor | 24 ++ .../Pages/Operations/SecuredWrites.razor | 382 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 8 + .../Services/ISecuredWriteService.cs | 104 +++++ .../Services/SecuredWriteService.cs | 184 +++++++++ .../ZB.MOM.WW.ScadaBridge.CentralUI.csproj | 4 + .../Components/SecuredWritesTests.cs | 266 ++++++++++++ 7 files changed, 972 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor index 4bd723a4..d6f1ab33 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor @@ -83,6 +83,30 @@ + @* Operations — Secured Writes (M7 T14b). Two-person MxGateway write workflow: + Operator submits, a different Verifier approves/rejects. The section must show + for Operator OR Verifier. There is no combined policy, so the OR is expressed + as: Operator → render; otherwise (NotAuthorized) fall through to a Verifier + check. This renders the section EXACTLY ONCE for a user holding either (or both) + roles — important in dev where DisableLogin grants one identity every role — + and hides the heading entirely from a user with neither. *@ + + + + + + + + + + + + + + + + + @* Monitoring — Health Dashboard is all-roles; Event Logs and Parked Messages are Deployer-role only (Component-CentralUI). The section is ungated because Health Dashboard is always diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor new file mode 100644 index 00000000..64af8b5c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor @@ -0,0 +1,382 @@ +@page "/operations/secured-writes" +@attribute [Authorize] +@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.Management +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums +@using ZB.MOM.WW.ScadaBridge.Security +@inject ISecuredWriteService SecuredWriteSvc +@inject ISiteRepository SiteRepository +@inject AuthenticationStateProvider AuthStateProvider +@inject IDialogService Dialog +@inject ILogger Logger + +@* Secured Writes (M7 OPC UA / MxGateway UX, Task C5 / T14b). Two-person workflow: + an Operator submits a write against an MxGateway connection; a DIFFERENT Verifier + approves (which relays the write to the device) or rejects. This page only SUBMITS + commands — the server (ManagementActor) enforces roles, the no-self-approval guard, + the device relay, and the audit trail. Dev caveat: with DisableLogin on, one identity + holds every role, so both role regions render; approve/reject on a row this user + submitted is disabled (the server rejects it too, but the UI surfaces it up front). *@ + +
+ + +
+

Secured Writes

+ Two-person MxGateway writes — operator submits, a different verifier approves. +
+ + @* ── Operator region: submit form ───────────────────────────────────── *@ + + +
+
Submit a secured write (Operator)
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+ + @* ── Verifier region: pending queue ─────────────────────────────────── *@ + + +
+
Pending verification (Verifier)
+
+ @if (_pending.Count == 0) + { +

No pending secured writes.

+ } + else + { + + + + + + + + + + + + + + + @foreach (var row in _pending) + { + var isOwn = string.Equals(row.OperatorUser, _currentUsername, StringComparison.OrdinalIgnoreCase); + + + + + + + + + + + } + +
SiteConnectionTagValueTypeOperatorSubmittedActions
@row.SiteId@row.ConnectionName@row.TagPath@row.ValueJson@row.ValueType@row.OperatorUser@row.SubmittedAtUtc.ToString("u") + @if (isOwn) + { + your submission + } + + +
+ } +
+
+
+
+ + @* ── History region: terminal rows (any authenticated user) ─────────── *@ +
+
History (decided / executed)
+
+ @if (_history.Count == 0) + { +

No completed secured writes.

+ } + else + { + + + + + + + + + + + + + + + + + + @foreach (var row in _history) + { + + + + + + + + + + + + + + } + +
SiteConnectionTagValueStatusOperatorVerifierSubmittedDecidedExecutedError
@row.SiteId@row.ConnectionName@row.TagPath@row.ValueJson@row.Status@row.OperatorUser@(row.VerifierUser ?? "—")@row.SubmittedAtUtc.ToString("u")@(row.DecidedAtUtc?.ToString("u") ?? "—")@(row.ExecutedAtUtc?.ToString("u") ?? "—")@(row.ExecutionError ?? "")
+ } +
+
+
+ +@code { + private ToastNotification _toast = default!; + + private IReadOnlyList _sites = Array.Empty(); + private readonly Dictionary> _connectionsBySite = new(StringComparer.Ordinal); + private List _pending = new(); + private List _history = new(); + private string _currentUsername = ClaimsPrincipalExtensions.UnknownUser; + + // Submit-form state. + private string _formSiteIdentifier = ""; + private string _formConnectionName = ""; + private string _formTagPath = ""; + private string _formValue = ""; + private string _formValueType = nameof(DataType.Boolean); + private string _formComment = ""; + + private bool _submitting; + private bool _actionInProgress; + + /// Terminal statuses surfaced in the History table. + private static readonly string[] TerminalStatuses = ["Executed", "Failed", "Rejected", "Expired"]; + + private IReadOnlyList MxConnectionsForForm => + string.IsNullOrEmpty(_formSiteIdentifier) || !_connectionsBySite.TryGetValue(_formSiteIdentifier, out var conns) + ? Array.Empty() + : conns.Where(c => string.Equals(c.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase)).ToList(); + + private bool CanSubmit => + !string.IsNullOrWhiteSpace(_formSiteIdentifier) + && !string.IsNullOrWhiteSpace(_formConnectionName) + && !string.IsNullOrWhiteSpace(_formTagPath) + && !string.IsNullOrWhiteSpace(_formValue); + + protected override async Task OnInitializedAsync() + { + _currentUsername = await AuthStateProvider.GetCurrentUsernameAsync(); + await LoadSitesAsync(); + await RefreshListsAsync(); + } + + private async Task LoadSitesAsync() + { + try + { + _sites = await SiteRepository.GetAllSitesAsync(); + foreach (var site in _sites) + { + _connectionsBySite[site.SiteIdentifier] = + await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load sites/connections for the Secured Writes form."); + _toast.ShowError("Failed to load sites."); + } + } + + private async Task RefreshListsAsync() + { + var all = await SecuredWriteSvc.ListAsync(status: null, siteId: null); + _pending = all.Where(w => string.Equals(w.Status, "Pending", StringComparison.OrdinalIgnoreCase)).ToList(); + _history = all.Where(w => TerminalStatuses.Contains(w.Status, StringComparer.OrdinalIgnoreCase)).ToList(); + } + + private async Task Submit() + { + if (!CanSubmit) return; + _submitting = true; + try + { + var result = await SecuredWriteSvc.SubmitAsync( + _formSiteIdentifier, _formConnectionName, _formTagPath, + _formValue, _formValueType, + string.IsNullOrWhiteSpace(_formComment) ? null : _formComment); + + if (result.Success) + { + _toast.ShowSuccess("Secured write submitted for verification."); + _formTagPath = ""; + _formValue = ""; + _formComment = ""; + await RefreshListsAsync(); + } + else + { + _toast.ShowError(result.Error ?? "Submit failed."); + } + } + finally + { + _submitting = false; + } + } + + private async Task Approve(SecuredWriteDto row) + { + var confirmed = await Dialog.ConfirmAsync( + "Approve secured write", + $"Approving will write to the device.\n\n" + + $"Site: {row.SiteId}\nConnection: {row.ConnectionName}\nTag: {row.TagPath}\n" + + $"Value: {row.ValueJson} ({row.ValueType})\nOperator: {row.OperatorUser}", + danger: true); + if (!confirmed) return; + + _actionInProgress = true; + try + { + var result = await SecuredWriteSvc.ApproveAsync(row.Id, comment: null); + if (result.Success) + { + var status = result.Dto?.Status ?? "decided"; + if (string.Equals(status, "Executed", StringComparison.OrdinalIgnoreCase)) + { + _toast.ShowSuccess("Secured write approved and executed."); + } + else + { + _toast.ShowWarning($"Secured write approved but not executed (status: {status})."); + } + await RefreshListsAsync(); + } + else + { + _toast.ShowError(result.Error ?? "Approve failed."); + } + } + finally + { + _actionInProgress = false; + } + } + + private async Task Reject(SecuredWriteDto row) + { + var confirmed = await Dialog.ConfirmAsync( + "Reject secured write", + $"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?", + danger: true); + if (!confirmed) return; + + _actionInProgress = true; + try + { + var result = await SecuredWriteSvc.RejectAsync(row.Id, comment: null); + if (result.Success) + { + _toast.ShowSuccess("Secured write rejected."); + await RefreshListsAsync(); + } + else + { + _toast.ShowError(result.Error ?? "Reject failed."); + } + } + finally + { + _actionInProgress = false; + } + } + + private static string StatusBadgeClass(string status) => status switch + { + "Executed" => "text-bg-success", + "Failed" => "text-bg-danger", + "Rejected" => "text-bg-secondary", + "Expired" => "text-bg-secondary", + _ => "text-bg-light" + }; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 58a02dcc..2b32d780 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -93,6 +93,14 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // Secured Writes (M7 T14b): dispatches the two-person secured-write commands + // (submit / approve / reject / list) to the central ManagementActor through the + // in-process ManagementActorHolder seam — the same Ask path the HTTP /management + // endpoint uses. The server stays the single enforcer of role gating, + // separation-of-duties (no self-approval), the MxGateway device relay on approve, + // and the append-only audit trail; the page only SUBMITS commands. + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs new file mode 100644 index 00000000..df1747f0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs @@ -0,0 +1,104 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Outcome of a single secured-write command dispatch. Wraps either the resulting +/// (on success) or a human-readable error, so the page +/// can render an inline result/toast rather than reasoning about transport exceptions +/// or the three management response shapes. +/// +/// Whether the command succeeded. +/// The resulting secured-write row when is true; otherwise null. +/// A human-readable error message when is false; otherwise null. +public record SecuredWriteActionResult(bool Success, SecuredWriteDto? Dto, string? Error) +{ + /// Creates a successful result wrapping . + /// The secured-write row the command produced. + /// A successful . + public static SecuredWriteActionResult Ok(SecuredWriteDto dto) => new(true, dto, null); + + /// Creates a failed result carrying . + /// The human-readable failure message. + /// A failed . + public static SecuredWriteActionResult Fail(string error) => new(false, null, error); +} + +/// +/// CentralUI facade over the two-person ("secured") write management commands +/// (M7 OPC UA / MxGateway UX, Task T14b — page C5). It dispatches the strongly-typed +/// SubmitSecuredWriteCommand / ApproveSecuredWriteCommand / +/// RejectSecuredWriteCommand / ListSecuredWritesCommand to the central +/// ManagementActor through the in-process ManagementActorHolder seam — +/// the SAME path the HTTP /management endpoint uses — so the server remains the +/// single enforcer of role gating, separation-of-duties (no self-approval), the actual +/// MxGateway device relay on approve, and the append-only audit trail. +/// +/// +/// The page itself performs NO device I/O and re-implements NO security logic: every +/// mutation is a command submitted to the server. The current user's claims are mapped +/// to an so the actor's role check and no-self-approval +/// guard run against the real principal. ManagementUnauthorized / +/// ManagementError / transport failures collapse into a typed +/// so the page renders an inline outcome rather +/// than throwing. +/// +public interface ISecuredWriteService +{ + /// + /// Submits a new pending secured write (Operator action). The target connection + /// must exist within and use the MxGateway protocol — + /// both validated server-side. + /// + /// Site identifier the write targets. + /// MxGateway data connection name within the site. + /// Fully-qualified tag path the value is written to. + /// JSON-serialised value to write. + /// Target data type name (e.g. Boolean, Double). + /// Optional free-text comment supplied by the operator. + /// Cancellation token. + /// A task resolving to the submit outcome. + Task SubmitAsync( + string siteId, + string connectionName, + string tagPath, + string valueJson, + string valueType, + string? comment, + CancellationToken cancellationToken = default); + + /// + /// Approves (and triggers execution of) a pending secured write (Verifier action). + /// The server enforces that the approver differs from the submitter and runs the + /// device relay. + /// + /// Identity of the pending secured write. + /// Optional free-text comment supplied by the verifier. + /// Cancellation token. + /// A task resolving to the approve outcome. + Task ApproveAsync( + long id, string? comment, CancellationToken cancellationToken = default); + + /// + /// Rejects a pending secured write (Verifier action). The server enforces that the + /// rejecter differs from the submitter. + /// + /// Identity of the pending secured write. + /// Optional free-text comment supplied by the verifier. + /// Cancellation token. + /// A task resolving to the reject outcome. + Task RejectAsync( + long id, string? comment, CancellationToken cancellationToken = default); + + /// + /// Lists secured writes, optionally filtered by and + /// (read-only — any authenticated user). On any failure + /// an empty list is returned so the page renders gracefully. + /// + /// Status filter; null matches every status. + /// Site id filter; null matches every site. + /// Cancellation token. + /// A task resolving to the matching rows, newest submission first. + Task> ListAsync( + string? status, string? siteId, CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs new file mode 100644 index 00000000..b794d209 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs @@ -0,0 +1,184 @@ +using System.Security.Claims; +using System.Text.Json; +using Akka.Actor; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.ManagementService; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default implementation — a thin facade that +/// dispatches the secured-write management commands to the central +/// ManagementActor through the in-process +/// (the same Ask seam the HTTP /management endpoint uses). The actor authorizes +/// the command against the supplied , enforces +/// separation-of-duties, runs the MxGateway device relay on approve, and writes the +/// audit row — none of that is re-implemented here. +/// +/// +/// The current Blazor principal is projected to an so +/// the server's role gate and no-self-approval guard run against the real identity. +/// The three management response shapes plus any transport fault collapse into a typed +/// (or an empty list for queries) so the page can +/// render inline outcomes without try/catch noise. +/// +public sealed class SecuredWriteService : ISecuredWriteService +{ + /// + /// camelCase + ignore-cycles, matching ManagementActor.SerializeResult's + /// options. is produced with those settings, + /// so the deserializer must mirror them to bind every property. + /// + private static readonly JsonSerializerOptions ResultDeserializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(30); + + private readonly ManagementActorHolder _holder; + private readonly AuthenticationStateProvider _auth; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the . + /// + /// Holder for the central ManagementActor reference. + /// Authentication state provider used to project the current principal. + /// Logger instance. + public SecuredWriteService( + ManagementActorHolder holder, + AuthenticationStateProvider auth, + ILogger logger) + { + _holder = holder ?? throw new ArgumentNullException(nameof(holder)); + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public Task SubmitAsync( + string siteId, string connectionName, string tagPath, string valueJson, + string valueType, string? comment, CancellationToken cancellationToken = default) + => DispatchAsync( + new SubmitSecuredWriteCommand(siteId, connectionName, tagPath, valueJson, valueType, comment), + cancellationToken); + + /// + public Task ApproveAsync( + long id, string? comment, CancellationToken cancellationToken = default) + => DispatchAsync(new ApproveSecuredWriteCommand(id, comment), cancellationToken); + + /// + public Task RejectAsync( + long id, string? comment, CancellationToken cancellationToken = default) + => DispatchAsync(new RejectSecuredWriteCommand(id, comment), cancellationToken); + + /// + public async Task> ListAsync( + string? status, string? siteId, CancellationToken cancellationToken = default) + { + var response = await SendAsync(new ListSecuredWritesCommand(status, siteId), cancellationToken); + if (response is ManagementSuccess success) + { + var result = JsonSerializer.Deserialize( + success.JsonData, ResultDeserializerOptions); + return result?.Items ?? Array.Empty(); + } + + // Read path: log + return empty so the queue/history tables render gracefully. + _logger.LogWarning( + "ListSecuredWrites failed: {Response}", DescribeFailure(response)); + return Array.Empty(); + } + + /// + /// Dispatches a single mutating command and maps the response (or any fault) to a + /// typed . + /// + private async Task DispatchAsync( + object command, CancellationToken cancellationToken) + { + var response = await SendAsync(command, cancellationToken); + switch (response) + { + case ManagementSuccess success: + var dto = JsonSerializer.Deserialize( + success.JsonData, ResultDeserializerOptions); + return dto is null + ? SecuredWriteActionResult.Fail("The server returned an unreadable result.") + : SecuredWriteActionResult.Ok(dto); + case ManagementUnauthorized unauthorized: + return SecuredWriteActionResult.Fail(unauthorized.Message); + case ManagementError error: + return SecuredWriteActionResult.Fail(error.Error); + default: + return SecuredWriteActionResult.Fail(DescribeFailure(response)); + } + } + + /// + /// Wraps in a for the + /// current principal and Asks the ManagementActor. Transport faults (timeout, + /// actor not yet started, cancellation→propagated) become a synthetic + /// so callers handle one response shape. + /// + private async Task SendAsync(object command, CancellationToken cancellationToken) + { + var actor = _holder.ActorRef; + if (actor is null) + { + return new ManagementError( + string.Empty, "Management service is not ready.", "SERVICE_UNAVAILABLE"); + } + + var user = await BuildAuthenticatedUserAsync(); + var envelope = new ManagementEnvelope(user, command, Guid.NewGuid().ToString("N")); + + try + { + return await actor.Ask(envelope, AskTimeout, cancellationToken); + } + catch (OperationCanceledException) + { + // Caller-initiated cancel (e.g. circuit teardown) — propagate cleanly. + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "ManagementActor Ask failed for {Command}", command.GetType().Name); + return new ManagementError(string.Empty, ex.Message, "TRANSPORT_ERROR"); + } + } + + /// + /// Projects the current Blazor to the + /// the actor authorizes against — username, + /// display name, role claims, and the permitted-site scope claims (mirrors the + /// claim set the HTTP endpoint constructs). + /// + private async Task BuildAuthenticatedUserAsync() + { + var state = await _auth.GetAuthenticationStateAsync(); + var principal = state.User; + + var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown"; + var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username; + var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToArray(); + var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray(); + + return new AuthenticatedUser(username, displayName, roles, permittedSiteIds); + } + + /// Renders a fallback description for an unexpected/failure response. + private static string DescribeFailure(object response) => response switch + { + ManagementUnauthorized unauthorized => unauthorized.Message, + ManagementError error => error.Error, + _ => "Unexpected response from the management service." + }; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj index 58521198..814465de 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj @@ -31,6 +31,10 @@ + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs new file mode 100644 index 00000000..268597c8 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs @@ -0,0 +1,266 @@ +using System.Security.Claims; +using Bunit; +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.Components.Shared; +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.Management; +using ZB.MOM.WW.ScadaBridge.Security; +using SecuredWritesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Operations.SecuredWrites; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; + +/// +/// bUnit rendering + interaction tests for the Secured Writes page (M7 T14b / C5). +/// The page is the UI for the two-person MxGateway write workflow: an Operator submits, +/// a different Verifier approves/rejects, and everyone sees the history. The page only +/// SUBMITS commands through — the server enforces +/// roles, the no-self-approval guard, the device relay, and the audit trail — so these +/// tests substitute the service and assert the page wires the inputs/actions correctly. +/// The real policies are registered so the per-region AuthorizeView Policy=... +/// blocks are genuinely evaluated against the test principal's role claims. +/// +public class SecuredWritesTests : BunitContext +{ + private readonly ISecuredWriteService _service = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly IDialogService _dialog = Substitute.For(); + + public SecuredWritesTests() + { + Services.AddSingleton(_service); + Services.AddSingleton(_siteRepo); + Services.AddSingleton(_dialog); + + // Confirm dialogs resolve to "yes" without a DialogHost in the render scope. + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(true)); + + // Empty list by default; individual tests seed the queue/history. + _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + } + + /// + /// Registers the real authorization stack + a principal with the given username and + /// roles so the page's AuthorizeView Policy=... regions evaluate genuinely. + /// + private void AddAuth(string username, params string[] roles) + { + var claims = new List { new(JwtTokenService.UsernameClaimType, username) }; + claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaBridgeAuthorization(Services); + // BunitContext pre-registers a placeholder IAuthorizationService that throws on + // policy evaluation; force the real one so the regions are actually gated. + Services.AddSingleton(); + } + + private void SeedSites(params Site[] sites) + { + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(sites.ToList())); + foreach (var site in sites) + { + _siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + } + } + + private void SeedConnections(int siteId, params DataConnection[] connections) + => _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any()) + .Returns(Task.FromResult>(connections.ToList())); + + /// + /// Renders the page inside a so its + /// AuthorizeView regions have the cascading Task<AuthenticationState> + /// they require (mirrors the NavMenu tests). + /// + private IRenderedComponent RenderPage() + { + var host = Render(parameters => parameters + .Add(p => p.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }))); + return host.FindComponent(); + } + + private static SecuredWriteDto Dto( + long id, string status, string operatorUser, + string? verifier = null, string? executionError = null, + string siteId = "plant-a", string connection = "GW-1", string tag = "Pump.Setpoint", + string valueJson = "true", string valueType = "Boolean") => + new(id, siteId, connection, tag, valueJson, valueType, status, operatorUser, + OperatorComment: null, SubmittedAtUtc: DateTime.UtcNow, + VerifierUser: verifier, VerifierComment: null, + DecidedAtUtc: status == "Pending" ? null : DateTime.UtcNow, + ExecutedAtUtc: status is "Executed" or "Failed" ? DateTime.UtcNow : null, + ExecutionError: executionError); + + // ── (a) Operator: submit form present + Submit calls SubmitAsync with the values ── + + [Fact] + public void Operator_SeesSubmitForm() + { + AddAuth("op-user", Roles.Operator); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + + var cut = RenderPage(); + + cut.WaitForAssertion(() => + { + Assert.NotNull(cut.Find("[data-test='secured-write-submit-region']")); + Assert.NotNull(cut.Find("[data-test='secured-write-submit']")); + }); + } + + [Fact] + public void Operator_Submit_CallsServiceWithEnteredValues() + { + AddAuth("op-user", Roles.Operator); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + SeedConnections(1, new DataConnection("GW-1", "MxGateway", 1) { Id = 10 }); + + _service.SubmitAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(1, "Pending", "op-user")))); + + var cut = RenderPage(); + + cut.Find("[data-test='secured-write-site']").Change("plant-a"); + cut.Find("[data-test='secured-write-connection']").Change("GW-1"); + cut.Find("[data-test='secured-write-tagpath']").Change("Pump.Setpoint"); + cut.Find("[data-test='secured-write-value']").Change("true"); + cut.Find("[data-test='secured-write-datatype']").Change("Boolean"); + cut.Find("[data-test='secured-write-comment']").Change("routine setpoint"); + + cut.Find("[data-test='secured-write-submit']").Click(); + + _service.Received(1).SubmitAsync( + "plant-a", "GW-1", "Pump.Setpoint", "true", "Boolean", "routine setpoint", + Arg.Any()); + } + + // ── (b) Verifier: pending row renders with Approve/Reject; own-row actions disabled ── + + [Fact] + public void Verifier_PendingRow_HasEnabledActions_ForOtherOperatorsRow() + { + AddAuth("verifier-user", Roles.Verifier); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>( + new[] { Dto(1, "Pending", "someone-else") })); + + var cut = RenderPage(); + + cut.WaitForAssertion(() => + { + Assert.NotNull(cut.Find("[data-test='secured-write-pending-region']")); + var approve = cut.Find("[data-test='secured-write-approve']"); + var reject = cut.Find("[data-test='secured-write-reject']"); + Assert.False(approve.HasAttribute("disabled")); + Assert.False(reject.HasAttribute("disabled")); + }); + } + + [Fact] + public void Verifier_OwnSubmission_HasApproveAndRejectDisabled() + { + // DisableLogin dev caveat: one identity holds both roles; the page must still + // disable approve/reject on a row the current user submitted (server enforces + // this too, but the UI surfaces it up front). + AddAuth("self", Roles.Operator, Roles.Verifier); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>( + new[] { Dto(1, "Pending", "self") })); + + var cut = RenderPage(); + + cut.WaitForAssertion(() => + { + var approve = cut.Find("[data-test='secured-write-approve']"); + var reject = cut.Find("[data-test='secured-write-reject']"); + Assert.True(approve.HasAttribute("disabled")); + Assert.True(reject.HasAttribute("disabled")); + Assert.NotNull(cut.Find("[data-test='secured-write-own-note']")); + }); + } + + [Fact] + public void Verifier_Approve_CallsApproveAsync() + { + AddAuth("verifier-user", Roles.Verifier); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>( + new[] { Dto(7, "Pending", "someone-else") })); + _service.ApproveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(7, "Executed", "someone-else", "verifier-user")))); + + var cut = RenderPage(); + cut.Find("[data-test='secured-write-approve']").Click(); + + _service.Received(1).ApproveAsync(7, Arg.Any(), Arg.Any()); + } + + // ── (c) History: terminal rows render ── + + [Fact] + public void History_RendersTerminalRows() + { + AddAuth("viewer", Roles.Viewer); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new[] + { + Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"), + Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"), + Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"), + Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"), + })); + + var cut = RenderPage(); + + cut.WaitForAssertion(() => + { + Assert.NotNull(cut.Find("[data-test='secured-write-history-region']")); + var rows = cut.FindAll("[data-test='secured-write-history-row']"); + // Three terminal rows (Executed/Rejected/Failed); the Pending row is excluded. + Assert.Equal(3, rows.Count); + Assert.Contains("Executed", cut.Markup); + Assert.Contains("Rejected", cut.Markup); + Assert.Contains("device timeout", cut.Markup); + Assert.DoesNotContain("ShouldNotAppear", cut.Markup); + }); + } + + [Fact] + public void Viewer_DoesNotSeeOperatorOrVerifierRegions() + { + AddAuth("viewer", Roles.Viewer); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + + var cut = RenderPage(); + + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll("[data-test='secured-write-submit-region']")); + Assert.Empty(cut.FindAll("[data-test='secured-write-pending-region']")); + // History is visible to any authenticated user. + Assert.NotNull(cut.Find("[data-test='secured-write-history-region']")); + }); + } +}