merge: small fixes (register rows 28, 35, 57) — confirm dialogs name their own action (9 mislabeled 'Delete' buttons fixed incl. secured-write Approve/Reject), DeploymentRecords composite (DeployedAt DESC, Id DESC) index, audit-config query CLI table projection

This commit is contained in:
Joseph Doherty
2026-08-15 17:56:09 -04:00
30 changed files with 2857 additions and 45 deletions
@@ -0,0 +1,182 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Tests for the compact <c>audit-config query</c> table projection (deferred-work
/// register row 57). The command passed no <c>tableProjector</c>, so the generic
/// renderer saw an <see cref="AuditLogQueryResult"/> OBJECT, took its Property/Value
/// branch, and printed the entire <c>entries</c> array as raw JSON inside one cell.
/// The projection unwraps the envelope to a row array, drops the per-entry
/// <c>afterStateJson</c> snapshot, and keeps <c>totalCount</c> visible as a trailing note.
/// </summary>
public class AuditConfigTableProjectionTests
{
/// <summary>
/// The real server shape, serialised from the REAL <see cref="AuditLogQueryResult"/>
/// / <see cref="AuditLogEntry"/> types rather than hand-written JSON, so renaming one
/// of their properties fails this test instead of silently blanking a column.
/// </summary>
private static string RealPayload(int totalCount) =>
JsonSerializer.Serialize(
new AuditLogQueryResult(
new[]
{
new AuditLogEntry("multi-role", "Created", "Template", "7", "PumpTemplate")
{
Id = 412,
AfterStateJson = "{\"id\":7,\"name\":\"PumpTemplate\",\"attributes\":[]}",
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero),
},
new AuditLogEntry("designer", "Deleted", "ExternalSystem", "3", "MES")
{
Id = 413,
AfterStateJson = null,
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 5, 0, TimeSpan.Zero),
},
},
totalCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
[Fact]
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
// An ARRAY is the whole point — the object envelope is what made the renderer
// print one Property/Value row with the entries blob in a single cell.
Assert.Equal(JsonValueKind.Array, root.ValueKind);
Assert.Equal(2, root.GetArrayLength());
var first = root[0];
Assert.Equal(412, first.GetProperty("id").GetInt32());
Assert.Equal("multi-role", first.GetProperty("user").GetString());
Assert.Equal("Created", first.GetProperty("action").GetString());
Assert.Equal("Template", first.GetProperty("entityType").GetString());
Assert.Equal("PumpTemplate", first.GetProperty("entityName").GetString());
Assert.Equal("7", first.GetProperty("entityId").GetString());
Assert.Contains("2026-08-15", first.GetProperty("timestamp").GetString());
var second = root[1];
Assert.Equal("Deleted", second.GetProperty("action").GetString());
Assert.Equal("MES", second.GetProperty("entityName").GetString());
}
[Fact]
public void ProjectEntries_DropsAfterStateJson()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
// The fat field must NOT survive — one entity snapshot per row fills the terminal,
// which is the audit analogue of template's child arrays.
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 137));
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
// Two entries plus the note row.
Assert.Equal(3, root.GetArrayLength());
var note = root[2].GetProperty("note").GetString();
Assert.Contains("2 of 137", note);
// The note must not masquerade as an entry.
Assert.False(root[2].TryGetProperty("id", out _));
}
[Fact]
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
{
// When the page IS the result set the count adds nothing the operator cannot
// already see, so the column must not appear and widen every small query.
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
Assert.Equal(2, doc.RootElement.GetArrayLength());
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
}
[Fact]
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
{
var json = JsonSerializer.Serialize(
new AuditLogQueryResult(Array.Empty<AuditLogEntry>(), 0),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var compact = AuditConfigTableProjection.ProjectEntries(json);
using var doc = JsonDocument.Parse(compact);
// The renderer prints "(no results)" for an empty array; an empty OBJECT would
// have printed an empty Property/Value table instead.
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public void ProjectEntries_BareArray_IsStillProjected()
{
// Tolerated so the projection does not regress if the payload is ever unwrapped
// upstream, or when the deprecated `audit-log` alias hits an older server.
const string bare = """
[ { "id": 1, "user": "u", "action": "Updated", "entityType": "Site",
"entityName": "SiteA", "entityId": "2", "timestamp": "2026-08-15T12:00:00+00:00",
"afterStateJson": "{\"big\":true}" } ]
""";
var compact = AuditConfigTableProjection.ProjectEntries(bare);
using var doc = JsonDocument.Parse(compact);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("SiteA", doc.RootElement[0].GetProperty("entityName").GetString());
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
}
[Fact]
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
{
// e.g. an error envelope — leave it to the renderer's own fallbacks rather than
// projecting it into an empty table that hides the message.
const string other = """{ "error": "boom" }""";
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
}
[Fact]
public void ProjectEntries_NonJson_ReturnedVerbatim()
{
const string notJson = "<html>proxy error</html>";
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
}
[Fact]
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
{
// The reported symptom was the whole entries array in one cell. A page of rows
// carrying real afterStateJson snapshots must collapse hard.
var entries = Enumerable.Range(0, 50).Select(i =>
new AuditLogEntry("designer", "Updated", "Template", i.ToString(), $"Template{i}")
{
Id = i,
AfterStateJson = new string('x', 2000),
Timestamp = DateTimeOffset.UnixEpoch.AddMinutes(i),
}).ToArray();
var json = JsonSerializer.Serialize(new AuditLogQueryResult(entries, entries.Length),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var compact = AuditConfigTableProjection.ProjectEntries(json);
Assert.True(compact.Length * 4 < json.Length,
$"Expected compact ({compact.Length}) to be far smaller than raw ({json.Length}).");
}
}
@@ -45,7 +45,7 @@ public class SecuredWritesTests : BunitContext
Services.AddSingleton(_browse);
// Confirm dialogs resolve to "yes" without a DialogHost in the render scope.
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>())
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>())
.Returns(Task.FromResult(true));
// Empty list by default; individual tests seed the queue/history.
@@ -303,7 +303,8 @@ public class SecuredWritesTests : BunitContext
_dialog.Received(1).ConfirmAsync(
Arg.Any<string>(),
Arg.Is<string>(m => Regex.IsMatch(m, "Submitted .* ago")),
Arg.Any<bool>());
Arg.Any<bool>(),
Arg.Any<string?>());
}
[Fact]
@@ -210,7 +210,7 @@ public class SchemaLibraryPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so the delete path runs end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
@@ -0,0 +1,138 @@
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.Health;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Pins the verb on the "Trigger failover" confirmation button (deferred-work
/// register row 28). The dialog copy was always right, but the red confirm button
/// read <em>"Delete"</em>: <c>DialogHost</c> assumed every <c>danger: true</c>
/// confirm was a delete, and no caller could say otherwise. These tests run the
/// real <see cref="DialogService"/> behind a real <c>DialogHost</c> — not a
/// substitute — so they cover both halves of the fix: the call site passing
/// <c>confirmLabel</c>, and the host preferring it over its own default. A
/// substitute-based test would pin the argument but not what the user reads.
///
/// bUnit cannot run real JS interop, so the host's <c>sbDialog.*</c> focus-trap
/// calls are exercised in <see cref="JSRuntimeMode.Loose"/> mode as no-ops.
/// </summary>
public class FailoverConfirmLabelTests : BunitContext
{
private readonly IManualFailoverService _failover = Substitute.For<IManualFailoverService>();
private readonly DialogService _dialog = new();
public FailoverConfirmLabelTests()
{
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Administrator")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
// BunitContext pre-registers a placeholder IAuthorizationService that throws when
// AuthorizeView evaluates a policy; force the real one so the admin gate is real.
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
Services.AddSingleton(_failover);
// The concrete service registered as the interface, so the control and the
// host share one instance (DialogHost injects IDialogService and casts).
Services.AddSingleton<IDialogService>(_dialog);
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>
/// Renders the failover control and a live <c>DialogHost</c> side by side under
/// the cascading auth state <c>AuthorizeView</c> needs, mirroring how
/// <c>MainLayout</c> hosts the single shared dialog.
/// </summary>
private IRenderedComponent<CascadingAuthenticationState> RenderControlWithHost(string? siteId)
{
return Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<CentralFailoverControl>(0);
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), 2);
if (siteId is not null)
{
builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId);
}
builder.CloseComponent();
builder.OpenComponent<DialogHost>(3);
builder.CloseComponent();
})));
}
/// <summary>
/// Opens the failover dialog and returns the footer buttons.
/// </summary>
/// <remarks>
/// The click is deliberately NOT awaited. With a real <see cref="DialogService"/>
/// the control's handler parks on <c>await ConfirmAsync(...)</c> until someone
/// answers the dialog, so awaiting the click would deadlock the test. The dialog
/// state is published synchronously before <c>ConfirmAsync</c> returns its task, so
/// the modal is rendered by the time the click dispatch returns.
/// </remarks>
private IReadOnlyList<AngleSharp.Dom.IElement> OpenFailoverDialog(string? siteId)
{
var host = RenderControlWithHost(siteId);
var trigger = host.FindAll("button")
.First(b => b.TextContent.Contains("failover", StringComparison.OrdinalIgnoreCase));
_ = trigger.ClickAsync(new());
host.WaitForState(() => host.FindAll("div.modal-footer button").Count > 0);
return host.FindAll("div.modal-footer button").ToList();
}
[Theory]
[InlineData(null)] // central pair
[InlineData("SiteA")] // site pair
public void Failover_confirm_button_says_fail_over_not_delete(string? siteId)
{
var footerButtons = OpenFailoverDialog(siteId).Select(b => b.TextContent.Trim()).ToList();
Assert.Contains("Fail over", footerButtons);
// The whole point of the row: the destructive verb must not be "Delete".
Assert.DoesNotContain("Delete", footerButtons);
// Cancel is still the way out — the fix must not have eaten the footer.
Assert.Contains("Cancel", footerButtons);
}
[Fact]
public void Failover_confirm_button_keeps_destructive_styling()
{
// The label and the styling are separate concerns now (danger = red,
// confirmLabel = the verb). Relabelling must not have downgraded the
// button to a benign primary one.
var confirm = OpenFailoverDialog(siteId: null).First(b => b.TextContent.Trim() == "Fail over");
Assert.Contains("btn-danger", confirm.ClassList);
}
[Fact]
public async Task An_unlabelled_danger_confirm_still_falls_back_to_delete()
{
// The fallback is retained deliberately: ~20 genuine delete call sites rely
// on it (and the Playwright suites click the literal "Delete"). This pins that
// the row-28 fix is additive and did not change the default.
var cut = Render<DialogHost>();
await cut.InvokeAsync(() => { _ = _dialog.ConfirmAsync("Delete thing", "Sure?", danger: true); });
cut.Render();
var footerButtons = cut.FindAll("div.modal-footer button").Select(b => b.TextContent.Trim()).ToList();
Assert.Contains("Delete", footerButtons);
}
}
@@ -99,7 +99,7 @@ public class HealthFailoverButtonTests : BunitContext
public async Task Confirming_triggers_the_failover_exactly_once()
{
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_failover.FailOverCentralAsync(Arg.Any<string>())
.Returns(Task.FromResult<string?>("akka.tcp://scadabridge@central-a:8081"));
@@ -113,7 +113,7 @@ public class HealthFailoverButtonTests : BunitContext
public async Task Cancelling_the_confirmation_does_not_trigger_a_failover()
{
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(false);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(false);
var cut = Render(onlineNodes: 2);
await cut.Find("button").ClickAsync(new());
@@ -128,7 +128,7 @@ public class HealthFailoverButtonTests : BunitContext
// the admin's own circuit drops. If the dialog does not say so, a working failover
// looks like a crash they caused.
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
var cut = Render(onlineNodes: 2);
await cut.Find("button").ClickAsync(new());
@@ -136,7 +136,8 @@ public class HealthFailoverButtonTests : BunitContext
await _dialog.Received(1).ConfirmAsync(
Arg.Any<string>(),
Arg.Is<string>(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
Arg.Any<bool>());
Arg.Any<bool>(),
Arg.Any<string?>());
}
[Fact]
@@ -145,7 +146,7 @@ public class HealthFailoverButtonTests : BunitContext
// The service returns null when the peer guard refuses. The UI must not report a
// failover that never happened.
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_failover.FailOverCentralAsync(Arg.Any<string>()).Returns(Task.FromResult<string?>(null));
var cut = Render(onlineNodes: 2);
@@ -177,7 +178,7 @@ public class HealthFailoverButtonTests : BunitContext
public async Task Site_card_confirming_triggers_the_site_failover_path()
{
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "akka.tcp://scadabridge@site-a-a:8082", null)));
@@ -194,7 +195,7 @@ public class HealthFailoverButtonTests : BunitContext
// Central failover drops the admin's circuit; a SITE failover does not, and saying
// otherwise would train operators to distrust the warning that does matter.
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null)));
@@ -204,14 +205,15 @@ public class HealthFailoverButtonTests : BunitContext
await _dialog.Received(1).ConfirmAsync(
Arg.Any<string>(),
Arg.Is<string>(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
Arg.Any<bool>());
Arg.Any<bool>(),
Arg.Any<string?>());
}
[Fact]
public async Task Site_card_surfaces_a_refusal_from_the_site()
{
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.FromResult(new SiteFailoverOutcome(
false, null, "No standby available — failing over a lone node would be an outage.")));
@@ -228,7 +230,7 @@ public class HealthFailoverButtonTests : BunitContext
// A timeout is not a "no" from the site — the operator must be able to tell the
// difference, because the failover may or may not have taken effect.
Arrange("Administrator");
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.FromResult(new SiteFailoverOutcome(
false, null, "Site did not respond: Ask timed out")));
@@ -191,7 +191,7 @@ public class NotificationListsPageTests : BunitContext
/// <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)
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
@@ -336,7 +336,7 @@ public class NotificationReportDetailModalTests : BunitContext
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
@@ -271,7 +271,7 @@ public class NotificationReportPageTests : BunitContext
/// <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)
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
@@ -211,7 +211,7 @@ public sealed class QueryStringDrillInTests
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
@@ -679,7 +679,7 @@ public class SiteCallsReportPageTests : BunitContext
/// <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)
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(