feat(m9/T32c): schema-library CRUD commands + handlers + Central UI page + read-accessor

This commit is contained in:
Joseph Doherty
2026-06-18 12:32:31 -04:00
parent 71d5722692
commit 71a2bca4df
13 changed files with 1363 additions and 0 deletions
@@ -31,6 +31,7 @@
<NavRailSection Title="Design" Key="design"> <NavRailSection Title="Design" Key="design">
<NavRailItem Href="/design/templates" Text="Templates" /> <NavRailItem Href="/design/templates" Text="Templates" />
<NavRailItem Href="/design/shared-scripts" Text="Shared Scripts" /> <NavRailItem Href="/design/shared-scripts" Text="Shared Scripts" />
<NavRailItem Href="/design/schema-library" Text="Schema Library" />
<NavRailItem Href="/design/connections" Text="Connections" /> <NavRailItem Href="/design/connections" Text="Connections" />
<NavRailItem Href="/design/external-systems" Text="External Systems" /> <NavRailItem Href="/design/external-systems" Text="External Systems" />
<NavRailItem Href="/design/transport/export" Text="Export Bundle" /> <NavRailItem Href="/design/transport/export" Text="Export Bundle" />
@@ -0,0 +1,228 @@
@page "/design/schema-library"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISchemaLibraryService SchemaLibraryService
@inject IDialogService Dialog
@* Schema Library (M9-T32c): list + create/edit (via SchemaBuilder) + delete the
reusable named JSON-Schema library entries that the {"$ref":"lib:Name"} resolver
(T32b) resolves against. Every mutation is dispatched through ISchemaLibraryService
— the guard-running ManagementActor path — never a direct repo write. *@
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Schema Library</h4>
@if (!_editing)
{
<button class="btn btn-primary btn-sm" @onclick="BeginCreate">New Schema</button>
}
</div>
<ToastNotification @ref="_toast" />
@if (_editing)
{
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-3">@(_editId.HasValue ? $"Edit Schema: {_editOriginalName}" : "New Schema")</h5>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm"
placeholder="e.g. Address" @bind="_formName" />
<div class="form-text">Referenced as <code>lib:@(string.IsNullOrWhiteSpace(_formName) ? "Name" : _formName.Trim())</code>.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Scope <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control form-control-sm"
placeholder="(global)" @bind="_formScope" />
</div>
</div>
<label class="form-label small">Schema</label>
<SchemaBuilder Mode="value"
Value="@_formSchemaJson"
ValueChanged="@(v => _formSchemaJson = v)" />
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSchema" disabled="@_busy">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEdit" disabled="@_busy">Cancel</button>
</div>
</div>
</div>
}
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_schemas.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No library schemas defined.</p>
@if (!_editing)
{
<button class="btn btn-primary btn-sm" @onclick="BeginCreate">Create your first schema</button>
}
</div>
}
else
{
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Scope</th>
<th>Reference</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var s in _schemas)
{
<tr @key="s.Id">
<td class="fw-semibold">@s.Name</td>
<td>@(string.IsNullOrWhiteSpace(s.Scope) ? "—" : s.Scope)</td>
<td><code>lib:@s.Name</code></td>
<td class="text-end">
<button class="btn btn-outline-primary btn-sm me-1"
@onclick="() => BeginEdit(s)">Edit</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteSchema(s)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
@code {
private List<SharedSchema> _schemas = new();
private bool _loading = true;
private bool _busy;
private bool _editing;
private int? _editId;
private string _editOriginalName = string.Empty;
private string _formName = string.Empty;
private string? _formScope;
private string? _formSchemaJson;
private string? _formError;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
try
{
_schemas = (await SchemaLibraryService.ListAsync()).ToList();
}
finally
{
_loading = false;
}
}
private void BeginCreate()
{
_editing = true;
_editId = null;
_editOriginalName = string.Empty;
_formName = string.Empty;
_formScope = null;
// Seed an object-shaped schema so the builder opens with a useful default.
_formSchemaJson = """{"type":"object","properties":{}}""";
_formError = null;
}
private void BeginEdit(SharedSchema schema)
{
_editing = true;
_editId = schema.Id;
_editOriginalName = schema.Name;
_formName = schema.Name;
_formScope = schema.Scope;
_formSchemaJson = schema.SchemaJson;
_formError = null;
}
private void CancelEdit()
{
_editing = false;
_formError = null;
}
private async Task SaveSchema()
{
_formError = null;
var name = _formName?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(name))
{
_formError = "Schema name is required.";
return;
}
var scope = string.IsNullOrWhiteSpace(_formScope) ? null : _formScope.Trim();
var schemaJson = _formSchemaJson ?? string.Empty;
_busy = true;
try
{
var result = _editId.HasValue
? await SchemaLibraryService.UpdateAsync(_editId.Value, name, scope, schemaJson)
: await SchemaLibraryService.CreateAsync(name, scope, schemaJson);
if (result.Success)
{
_toast.ShowSuccess(_editId.HasValue
? $"Schema '{name}' updated."
: $"Schema '{name}' created.");
_editing = false;
await LoadAsync();
}
else
{
_formError = result.Error;
}
}
finally
{
_busy = false;
}
}
private async Task DeleteSchema(SharedSchema schema)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Schema",
$"Delete library schema '{schema.Name}'? References to lib:{schema.Name} will no longer resolve.",
danger: true);
if (!confirmed) return;
var result = await SchemaLibraryService.DeleteAsync(schema.Id);
if (result.Success)
{
_toast.ShowSuccess($"Schema '{schema.Name}' deleted.");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error ?? "Delete failed.");
}
}
}
@@ -124,6 +124,23 @@ public static class ServiceCollectionExtensions
// the move dialog only SUBMITS the command and renders the returned outcome. // the move dialog only SUBMITS the command and renders the returned outcome.
services.AddScoped<IDataConnectionMoveService, DataConnectionMoveService>(); services.AddScoped<IDataConnectionMoveService, DataConnectionMoveService>();
// Schema Library (M9-T32c): authoring + read accessors for the reusable named
// JSON-Schema library (SharedSchema + ISharedSchemaRepository, T32a).
//
// ISchemaLibraryService dispatches the CRUD commands to the central
// ManagementActor through the in-process ManagementActorHolder seam — the same
// Ask path the HTTP /management endpoint uses — so the server stays the single
// enforcer of the Designer gate, the unique-name guard, and JSON-Schema
// validation; the page only SUBMITS commands.
services.AddScoped<ISchemaLibraryService, SchemaLibraryService>();
// ISchemaLibraryQueryService is the READ counterpart that T30's schema-driven
// value-entry forms reuse to resolve {"$ref":"lib:Name"} pointers — a name →
// schema-JSON map backed by ISharedSchemaRepository over a fresh DI scope per
// query (mirroring the AuditLog / KPI query services, off the circuit-scoped
// DbContext). Read-only; no mutation goes through it.
services.AddScoped<ISchemaLibraryQueryService, SchemaLibraryQueryService>();
// Roslyn-backed C# analysis for the Monaco script editor. // Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service. // Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200); services.AddMemoryCache(o => o.SizeLimit = 200);
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// CentralUI read-accessor over the named JSON-Schema library (M9-T32c) for surfaces
/// that need to RESOLVE <c>{"$ref":"lib:Name"}</c> pointers — chiefly the schema-driven
/// value-entry forms (T30). It exposes the library as a name → schema-JSON map (and the
/// equivalent single-name lookup) so the value form can plug the resolved schema text
/// into <c>InboundApiSchema.Parse(json, name =&gt; query.Resolve(name))</c> exactly the
/// way the deploy-time / runtime resolvers do.
/// </summary>
/// <remarks>
/// This is the READ counterpart of <see cref="ISchemaLibraryService"/> (the authoring /
/// mutation facade). Reads do not need the guard-running ManagementActor path, so this
/// accessor is backed by <c>ISharedSchemaRepository</c> over a fresh DI scope (mirroring
/// the AuditLog / KPI query services), keeping it off the circuit-scoped DbContext.
/// </remarks>
public interface ISchemaLibraryQueryService
{
/// <summary>
/// Returns the whole library as a name → schema-JSON map (name-keyed, ordinal
/// comparison — the same key the <c>lib:Name</c> resolver matches on).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the name → schema-JSON map (empty when the library is empty).</returns>
Task<IReadOnlyDictionary<string, string>> GetSchemaMapAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Returns the schema JSON for a single named library entry, or <c>null</c> when no
/// entry by that name exists. Suitable directly as the <c>resolveRef</c> seam's
/// per-name lookup.
/// </summary>
/// <param name="name">The library entry name (the part after the <c>lib:</c> scheme prefix).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the entry's schema JSON, or <c>null</c> if not found.</returns>
Task<string?> ResolveAsync(string name, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,83 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Outcome of a single schema-library mutation dispatch (create / update / delete).
/// Wraps either success or a human-readable server error (a duplicate-name guard, an
/// invalid-JSON validation failure, or a not-found) so the schema-library page can
/// render an inline result rather than reasoning about transport exceptions or the
/// three management response shapes. Mirrors <see cref="DataConnectionMoveResult"/>.
/// </summary>
/// <param name="Success">Whether the mutation succeeded.</param>
/// <param name="Error">A human-readable error message when <paramref name="Success"/> is <c>false</c>; otherwise <c>null</c>.</param>
public record SchemaLibraryActionResult(bool Success, string? Error)
{
/// <summary>Creates a successful result.</summary>
/// <returns>A successful <see cref="SchemaLibraryActionResult"/>.</returns>
public static SchemaLibraryActionResult Ok() => new(true, null);
/// <summary>Creates a failed result carrying <paramref name="error"/>.</summary>
/// <param name="error">The human-readable failure (typically a server guard / validation message).</param>
/// <returns>A failed <see cref="SchemaLibraryActionResult"/>.</returns>
public static SchemaLibraryActionResult Fail(string error) => new(false, error);
}
/// <summary>
/// CentralUI facade over the schema-library CRUD management commands (M9-T32c). Every
/// mutation (<c>CreateSharedSchemaCommand</c> / <c>UpdateSharedSchemaCommand</c> /
/// <c>DeleteSharedSchemaCommand</c>) and the list query is dispatched to the central
/// <c>ManagementActor</c> through the in-process <c>ManagementActorHolder</c> seam — the
/// SAME Ask path the HTTP <c>/management</c> endpoint uses — so the server remains the
/// single enforcer of the Designer role gate, the unique-name guard, and JSON-Schema
/// validation. The page re-implements none of that: it lists, authors via
/// <c>SchemaBuilder</c>, and SUBMITS commands; any guard / validation failure comes back
/// as a classified <see cref="SchemaLibraryActionResult"/>.
/// </summary>
/// <remarks>
/// The current Blazor principal is projected to an <c>AuthenticatedUser</c> so the
/// server's role gate runs against the real identity. Mirrors
/// <c>IDataConnectionMoveService</c> / <c>ISecuredWriteService</c>.
/// </remarks>
public interface ISchemaLibraryService
{
/// <summary>
/// Lists every library schema (name-ordered) via the read-gated
/// <c>ListSharedSchemasCommand</c>. A read failure yields an empty list (logged) so
/// the page renders gracefully.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the library entries, or an empty list on failure.</returns>
Task<IReadOnlyList<SharedSchema>> ListAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Creates a library schema via the Designer-gated <c>CreateSharedSchemaCommand</c>.
/// </summary>
/// <param name="name">The unique schema name.</param>
/// <param name="scope">An optional scope discriminator (<c>null</c> = global).</param>
/// <param name="schemaJson">The JSON Schema document text.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to a classified create outcome.</returns>
Task<SchemaLibraryActionResult> CreateAsync(
string name, string? scope, string schemaJson, CancellationToken cancellationToken = default);
/// <summary>
/// Updates a library schema via the Designer-gated <c>UpdateSharedSchemaCommand</c>.
/// </summary>
/// <param name="id">Primary key of the schema to update.</param>
/// <param name="name">The (possibly renamed) unique schema name.</param>
/// <param name="scope">An optional scope discriminator (<c>null</c> = global).</param>
/// <param name="schemaJson">The JSON Schema document text.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to a classified update outcome.</returns>
Task<SchemaLibraryActionResult> UpdateAsync(
int id, string name, string? scope, string schemaJson, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a library schema via the Designer-gated <c>DeleteSharedSchemaCommand</c>.
/// </summary>
/// <param name="id">Primary key of the schema to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to a classified delete outcome.</returns>
Task<SchemaLibraryActionResult> DeleteAsync(int id, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,58 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="ISchemaLibraryQueryService"/> implementation (M9-T32c). Reads the
/// named JSON-Schema library directly from <see cref="ISharedSchemaRepository"/> over a
/// fresh DI scope per query — mirroring <c>AuditLogQueryService</c> /
/// <c>KpiHistoryQueryService</c> so a value form's auto-load never races other reads on
/// the shared circuit-scoped DbContext. Read-only; no mutation goes through here.
/// </summary>
public sealed class SchemaLibraryQueryService : ISchemaLibraryQueryService
{
private readonly IServiceScopeFactory _scopeFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SchemaLibraryQueryService"/>.
/// </summary>
/// <param name="scopeFactory">Factory used to open a fresh DI scope (and DbContext) per query.</param>
public SchemaLibraryQueryService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
}
/// <inheritdoc/>
public async Task<IReadOnlyDictionary<string, string>> GetSchemaMapAsync(
CancellationToken cancellationToken = default)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<ISharedSchemaRepository>();
var all = await repo.ListAsync(cancellationToken);
// Ordinal-keyed to match the lib:Name resolver's exact-name lookup. Last-wins on
// the (DB-unique) name guards against a transient duplicate read.
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var schema in all)
{
map[schema.Name] = schema.SchemaJson;
}
return map;
}
/// <inheritdoc/>
public async Task<string?> ResolveAsync(string name, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(name))
{
return null;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<ISharedSchemaRepository>();
var schema = await repo.GetByNameAsync(name, cancellationToken);
return schema?.SchemaJson;
}
}
@@ -0,0 +1,165 @@
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.Entities.Schemas;
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;
/// <summary>
/// Default <see cref="ISchemaLibraryService"/> implementation — a thin facade that
/// dispatches the schema-library CRUD commands to the central <c>ManagementActor</c>
/// through the in-process <see cref="ManagementActorHolder"/> (the same Ask seam the
/// HTTP <c>/management</c> endpoint uses). The actor authorizes the command against the
/// supplied <see cref="AuthenticatedUser"/> (Designer-gated mutations), enforces the
/// unique-name guard, validates the JSON Schema, and writes the audit row — none of
/// that is re-implemented here. Mirrors <see cref="SecuredWriteService"/> /
/// <see cref="DataConnectionMoveService"/>.
/// </summary>
public sealed class SchemaLibraryService : ISchemaLibraryService
{
/// <summary>
/// camelCase + case-insensitive, matching <c>ManagementActor.SerializeResult</c>'s
/// options. <see cref="ManagementSuccess.JsonData"/> is produced with those settings,
/// so the deserializer must mirror them to bind every property.
/// </summary>
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<SchemaLibraryService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SchemaLibraryService"/>.
/// </summary>
/// <param name="holder">Holder for the central <c>ManagementActor</c> reference.</param>
/// <param name="auth">Authentication state provider used to project the current principal.</param>
/// <param name="logger">Logger instance.</param>
public SchemaLibraryService(
ManagementActorHolder holder,
AuthenticationStateProvider auth,
ILogger<SchemaLibraryService> logger)
{
_holder = holder ?? throw new ArgumentNullException(nameof(holder));
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SharedSchema>> ListAsync(CancellationToken cancellationToken = default)
{
var response = await SendAsync(new ListSharedSchemasCommand(), cancellationToken);
if (response is ManagementSuccess success)
{
var items = JsonSerializer.Deserialize<List<SharedSchema>>(
success.JsonData, ResultDeserializerOptions);
return items ?? new List<SharedSchema>();
}
// Read path: log + return empty so the list table renders gracefully.
_logger.LogWarning("ListSharedSchemas failed: {Response}", DescribeFailure(response));
return Array.Empty<SharedSchema>();
}
/// <inheritdoc/>
public Task<SchemaLibraryActionResult> CreateAsync(
string name, string? scope, string schemaJson, CancellationToken cancellationToken = default)
=> DispatchAsync(new CreateSharedSchemaCommand(name, scope, schemaJson), cancellationToken);
/// <inheritdoc/>
public Task<SchemaLibraryActionResult> UpdateAsync(
int id, string name, string? scope, string schemaJson, CancellationToken cancellationToken = default)
=> DispatchAsync(new UpdateSharedSchemaCommand(id, name, scope, schemaJson), cancellationToken);
/// <inheritdoc/>
public Task<SchemaLibraryActionResult> DeleteAsync(int id, CancellationToken cancellationToken = default)
=> DispatchAsync(new DeleteSharedSchemaCommand(id), cancellationToken);
/// <summary>
/// Dispatches a single mutating command and maps the response (or any fault) to a
/// typed <see cref="SchemaLibraryActionResult"/>.
/// </summary>
private async Task<SchemaLibraryActionResult> DispatchAsync(
object command, CancellationToken cancellationToken)
{
var response = await SendAsync(command, cancellationToken);
return response switch
{
ManagementSuccess => SchemaLibraryActionResult.Ok(),
ManagementUnauthorized unauthorized => SchemaLibraryActionResult.Fail(unauthorized.Message),
ManagementError error => SchemaLibraryActionResult.Fail(error.Error),
_ => SchemaLibraryActionResult.Fail(DescribeFailure(response)),
};
}
/// <summary>
/// Wraps <paramref name="command"/> in a <see cref="ManagementEnvelope"/> for the
/// current principal and Asks the <c>ManagementActor</c>. Transport faults (timeout,
/// actor not yet started, cancellation→propagated) become a synthetic
/// <see cref="ManagementError"/> so callers handle one response shape.
/// </summary>
private async Task<object> 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<object>(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");
}
}
/// <summary>
/// Projects the current Blazor <see cref="ClaimsPrincipal"/> to the
/// <see cref="AuthenticatedUser"/> the actor authorizes against — username,
/// display name, role claims, and the permitted-site scope claims (mirrors the
/// claim set the HTTP endpoint constructs).
/// </summary>
private async Task<AuthenticatedUser> 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);
}
/// <summary>Renders a fallback description for an unexpected/failure response.</summary>
private static string DescribeFailure(object response) => response switch
{
ManagementUnauthorized unauthorized => unauthorized.Message,
ManagementError error => error.Error,
_ => "Unexpected response from the management service.",
};
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
// M9-T32c — schema-library authoring commands. The reusable named JSON-Schema
// library (the SharedSchema entity + ISharedSchemaRepository, T32a) gains its CRUD
// surface here. These records travel the same HTTP / ClusterClient management
// boundary as every other *Command and are auto-discovered by reflection in
// ManagementCommandRegistry (no manual registry entry needed).
//
// Gating mirrors the SharedScript / DataConnection idiom: list/get are read-only
// (any authenticated user), create/update/delete are Designer-gated. Name is unique;
// SchemaJson must parse as a JSON Schema (validated server-side in the handler).
public record ListSharedSchemasCommand;
public record GetSharedSchemaCommand(int SharedSchemaId);
public record CreateSharedSchemaCommand(string Name, string? Scope, string SchemaJson);
public record UpdateSharedSchemaCommand(int SharedSchemaId, string Name, string? Scope, string SchemaJson);
public record DeleteSharedSchemaCommand(int SharedSchemaId);
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
@@ -194,6 +195,7 @@ public class ManagementActor : ReceiveActor
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or UpdateAreaCommand or UpdateAreaCommand
@@ -337,6 +339,13 @@ public class ManagementActor : ReceiveActor
UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user.Username), UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user.Username),
DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user.Username), DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user.Username),
// Schema Library (M9-T32c)
ListSharedSchemasCommand => await HandleListSharedSchemas(sp),
GetSharedSchemaCommand cmd => await HandleGetSharedSchema(sp, cmd),
CreateSharedSchemaCommand cmd => await HandleCreateSharedSchema(sp, cmd, user.Username),
UpdateSharedSchemaCommand cmd => await HandleUpdateSharedSchema(sp, cmd, user.Username),
DeleteSharedSchemaCommand cmd => await HandleDeleteSharedSchema(sp, cmd, user.Username),
// Database Connections (External System) // Database Connections (External System)
ListDatabaseConnectionsCommand => await HandleListDatabaseConnections(sp), ListDatabaseConnectionsCommand => await HandleListDatabaseConnections(sp),
GetDatabaseConnectionCommand cmd => await HandleGetDatabaseConnection(sp, cmd), GetDatabaseConnectionCommand cmd => await HandleGetDatabaseConnection(sp, cmd),
@@ -2214,6 +2223,131 @@ public class ManagementActor : ReceiveActor
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
} }
// ========================================================================
// Schema Library handlers (M9-T32c)
//
// Reusable named JSON-Schema library (SharedSchema + ISharedSchemaRepository,
// T32a). Repo-backed, mirroring the DatabaseConnection handler idiom: direct
// repository writes plus an explicit AuditAsync after each mutation. The unique
// Name is enforced with a clear duplicate-name error and the SchemaJson is
// validated as a parseable JSON Schema (reusing InboundApiSchema.Parse) so a
// malformed library entry never reaches the $ref resolver (T32b) or the
// schema-driven value-entry forms (T30).
// ========================================================================
private static async Task<object?> HandleListSharedSchemas(IServiceProvider sp)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
return await repo.ListAsync();
}
private static async Task<object?> HandleGetSharedSchema(IServiceProvider sp, GetSharedSchemaCommand cmd)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
return await repo.GetByIdAsync(cmd.SharedSchemaId);
}
private static async Task<object?> HandleCreateSharedSchema(IServiceProvider sp, CreateSharedSchemaCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
var name = ValidateSchemaName(cmd.Name);
ValidateSchemaJson(cmd.SchemaJson);
var existing = await repo.GetByNameAsync(name);
if (existing is not null)
{
throw new ManagementCommandException($"A schema named '{name}' already exists.");
}
var schema = new SharedSchema { Name = name, Scope = cmd.Scope, SchemaJson = cmd.SchemaJson };
await repo.AddAsync(schema);
await AuditAsync(sp, user, "Create", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
return schema;
}
private static async Task<object?> HandleUpdateSharedSchema(IServiceProvider sp, UpdateSharedSchemaCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
var name = ValidateSchemaName(cmd.Name);
ValidateSchemaJson(cmd.SchemaJson);
var schema = await repo.GetByIdAsync(cmd.SharedSchemaId)
?? throw new ManagementCommandException($"Schema with ID {cmd.SharedSchemaId} not found.");
// A rename may not collide with a DIFFERENT row's name. The same name on the
// same row is allowed (a schema-body-only edit keeps its name).
var byName = await repo.GetByNameAsync(name);
if (byName is not null && byName.Id != schema.Id)
{
throw new ManagementCommandException($"A schema named '{name}' already exists.");
}
schema.Name = name;
schema.Scope = cmd.Scope;
schema.SchemaJson = cmd.SchemaJson;
await repo.UpdateAsync(schema);
await AuditAsync(sp, user, "Update", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
return schema;
}
private static async Task<object?> HandleDeleteSharedSchema(IServiceProvider sp, DeleteSharedSchemaCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
await repo.DeleteAsync(cmd.SharedSchemaId);
await AuditAsync(sp, user, "Delete", "SharedSchema", cmd.SharedSchemaId.ToString(), cmd.SharedSchemaId.ToString(), null);
return true;
}
/// <summary>
/// Validates and trims a library schema name. A non-empty unique name is the
/// lookup key for the <c>lib:Name</c> <c>$ref</c> resolver, so an empty name is a
/// caller-safe validation error (surfaced verbatim via
/// <see cref="ManagementCommandException"/>).
/// </summary>
private static string ValidateSchemaName(string? rawName)
{
var name = rawName?.Trim();
if (string.IsNullOrEmpty(name))
{
throw new ManagementCommandException("Schema name is required.");
}
return name;
}
/// <summary>
/// Validates that <paramref name="schemaJson"/> parses as a JSON Schema, reusing the
/// authoritative <see cref="InboundApiSchema.ParseWithRefs"/> parser so a library
/// entry obeys the same STRUCTURAL rules as every other stored schema (object form or
/// legacy flat-array; over-depth and root-scalar inputs rejected). A structural parse
/// failure is re-thrown as a caller-safe <see cref="ManagementCommandException"/>
/// naming the fault.
/// <para>
/// The <c>ParseWithRefs</c> (collecting) variant is used deliberately so a library
/// entry that itself carries a <c>{"$ref":"lib:Other"}</c> pointer is NOT rejected at
/// save time merely because the resolver is not consulted here — a dangling/cyclic
/// ref surfaces later on the deploy-time / runtime resolution path (T32b), exactly
/// like a forward reference to a not-yet-created entry. Only malformed JSON is a
/// save-blocking error.
/// </para>
/// </summary>
private static void ValidateSchemaJson(string? schemaJson)
{
if (string.IsNullOrWhiteSpace(schemaJson))
{
throw new ManagementCommandException("Schema JSON is required.");
}
try
{
_ = InboundApiSchema.ParseWithRefs(schemaJson, resolveRef: null);
}
catch (JsonException ex)
{
throw new ManagementCommandException($"Schema JSON is not a valid JSON Schema: {ex.Message}");
}
}
// ======================================================================== // ========================================================================
// Database Connection Definition handlers // Database Connection Definition handlers
// ======================================================================== // ========================================================================
@@ -0,0 +1,200 @@
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.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Security;
using SchemaLibraryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.SchemaLibrary;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// bUnit tests for the Schema Library page (M9-T32c). The page lists the reusable named
/// JSON-Schema library entries, authors create/edit via the shared <c>SchemaBuilder</c>
/// component, and deletes with a confirm — every mutation dispatched through
/// <see cref="ISchemaLibraryService"/> (the guard-running ManagementActor path), never a
/// direct repo write. The service is substituted so the tests capture the dispatched
/// commands and simulate success / guard-error responses without a real ManagementActor.
/// </summary>
public class SchemaLibraryPageTests : BunitContext
{
private readonly ISchemaLibraryService _service = Substitute.For<ISchemaLibraryService>();
public SchemaLibraryPageTests()
{
Services.AddSingleton(_service);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
AddTestAuth();
// Default: empty library. Tests that need rows override this.
_service.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SharedSchema>>(new List<SharedSchema>()));
}
private void AddTestAuth()
{
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Designer"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
private void SeedSchemas(params SharedSchema[] schemas) =>
_service.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SharedSchema>>(schemas.ToList()));
[Fact]
public void Renders_EmptyState_WhenNoSchemas()
{
var cut = Render<SchemaLibraryPage>();
cut.WaitForAssertion(() =>
Assert.Contains("No library schemas defined", cut.Markup));
}
[Fact]
public void Lists_ExistingSchemas_WithLibReference()
{
SeedSchemas(
new SharedSchema { Id = 1, Name = "Address", Scope = "global", SchemaJson = "{\"type\":\"object\"}" },
new SharedSchema { Id = 2, Name = "GeoPoint", SchemaJson = "{\"type\":\"object\"}" });
var cut = Render<SchemaLibraryPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("Address", cut.Markup);
Assert.Contains("GeoPoint", cut.Markup);
// The list surfaces the lib:Name reference designers paste into a $ref.
Assert.Contains("lib:Address", cut.Markup);
});
}
[Fact]
public void NewSchema_OpensEditor_WithSchemaBuilder()
{
var cut = Render<SchemaLibraryPage>();
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
// The editor card mounts with a name field and the SchemaBuilder component.
Assert.Contains("New Schema", cut.Markup);
Assert.NotEmpty(cut.FindAll("input")); // name / scope inputs
Assert.NotNull(cut.FindComponent<SchemaBuilder>());
}
[Fact]
public void Create_DispatchesCreateCommand_WithNameAndSchema()
{
_service.CreateAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SchemaLibraryActionResult.Ok()));
var cut = Render<SchemaLibraryPage>();
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
// Type a name and save (the SchemaBuilder seeds a default object schema).
cut.FindAll("input").First().Change("Address");
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
cut.WaitForAssertion(() =>
_service.Received(1).CreateAsync(
"Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()));
}
[Fact]
public void Create_GuardError_IsShownInline_AndEditorStaysOpen()
{
_service.CreateAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SchemaLibraryActionResult.Fail("A schema named 'Address' already exists.")));
var cut = Render<SchemaLibraryPage>();
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
cut.FindAll("input").First().Change("Address");
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("already exists", cut.Markup);
// Editor stays open (Save button still present) on a guard error.
Assert.Contains(cut.FindAll("button"), b => b.TextContent.Trim() == "Save");
});
}
[Fact]
public void EmptyName_DoesNotDispatch()
{
var cut = Render<SchemaLibraryPage>();
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
// Leave name blank and save.
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
cut.WaitForAssertion(() => Assert.Contains("name is required", cut.Markup));
_service.DidNotReceive().CreateAsync(
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Delete_ConfirmsThenDispatchesDeleteCommand_AndReloads()
{
SeedSchemas(new SharedSchema { Id = 7, Name = "Address", SchemaJson = "{\"type\":\"object\"}" });
_service.DeleteAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SchemaLibraryActionResult.Ok()));
var cut = Render<SchemaLibraryPage>();
cut.WaitForState(() => cut.Markup.Contains("Address"));
cut.FindAll("tbody tr button").First(b => b.TextContent.Contains("Delete")).Click();
cut.WaitForAssertion(() =>
{
_service.Received(1).DeleteAsync(7, Arg.Any<CancellationToken>());
// Reload re-invokes the list query (once on init, once after delete).
_service.Received(2).ListAsync(Arg.Any<CancellationToken>());
});
}
[Fact]
public void Edit_OpensEditor_AndDispatchesUpdateCommand()
{
SeedSchemas(new SharedSchema { Id = 9, Name = "Address", Scope = "us", SchemaJson = "{\"type\":\"object\"}" });
_service.UpdateAsync(Arg.Any<int>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SchemaLibraryActionResult.Ok()));
var cut = Render<SchemaLibraryPage>();
cut.WaitForState(() => cut.Markup.Contains("Address"));
cut.FindAll("tbody tr button").First(b => b.TextContent.Contains("Edit")).Click();
Assert.Contains("Edit Schema: Address", cut.Markup);
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
cut.WaitForAssertion(() =>
_service.Received(1).UpdateAsync(
9, "Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()));
}
/// <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)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
}
@@ -0,0 +1,85 @@
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Tests for the schema-library read-accessor (M9-T32c) that T30's schema-driven
/// value-entry forms reuse to resolve <c>{"$ref":"lib:Name"}</c> pointers. It projects
/// <see cref="ISharedSchemaRepository"/> onto a name → schema-JSON map (and a single-name
/// lookup) over a fresh DI scope. The repository is substituted and registered scoped so
/// the service's <c>IServiceScopeFactory</c> resolves it exactly as it would at runtime.
/// </summary>
public class SchemaLibraryQueryServiceTests
{
private static (SchemaLibraryQueryService Service, ISharedSchemaRepository Repo) Build()
{
var repo = Substitute.For<ISharedSchemaRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var service = new SchemaLibraryQueryService(provider.GetRequiredService<IServiceScopeFactory>());
return (service, repo);
}
[Fact]
public async Task GetSchemaMapAsync_MapsNameToSchemaJson()
{
var (service, repo) = Build();
repo.ListAsync(Arg.Any<CancellationToken>()).Returns(new List<SharedSchema>
{
new() { Id = 1, Name = "Address", SchemaJson = "{\"type\":\"object\"}" },
new() { Id = 2, Name = "GeoPoint", SchemaJson = "{\"type\":\"array\"}" },
});
var map = await service.GetSchemaMapAsync();
Assert.Equal(2, map.Count);
Assert.Equal("{\"type\":\"object\"}", map["Address"]);
Assert.Equal("{\"type\":\"array\"}", map["GeoPoint"]);
}
[Fact]
public async Task GetSchemaMapAsync_EmptyLibrary_ReturnsEmptyMap()
{
var (service, repo) = Build();
repo.ListAsync(Arg.Any<CancellationToken>()).Returns(new List<SharedSchema>());
var map = await service.GetSchemaMapAsync();
Assert.Empty(map);
}
[Fact]
public async Task ResolveAsync_KnownName_ReturnsSchemaJson()
{
var (service, repo) = Build();
repo.GetByNameAsync("Address", Arg.Any<CancellationToken>())
.Returns(new SharedSchema { Id = 1, Name = "Address", SchemaJson = "{\"type\":\"object\"}" });
var json = await service.ResolveAsync("Address");
Assert.Equal("{\"type\":\"object\"}", json);
}
[Fact]
public async Task ResolveAsync_UnknownName_ReturnsNull()
{
var (service, repo) = Build();
repo.GetByNameAsync("Missing", Arg.Any<CancellationToken>()).Returns((SharedSchema?)null);
Assert.Null(await service.ResolveAsync("Missing"));
}
[Fact]
public async Task ResolveAsync_EmptyName_ReturnsNull_WithoutHittingRepo()
{
var (service, repo) = Build();
Assert.Null(await service.ResolveAsync(""));
await repo.DidNotReceive().GetByNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
@@ -92,6 +92,25 @@ public class ManagementCommandRegistryTests
ManagementCommandRegistry.Resolve("ListInstanceNativeAlarmSourceOverrides")); ManagementCommandRegistry.Resolve("ListInstanceNativeAlarmSourceOverrides"));
} }
/// <summary>
/// M9-T32c: the schema-library CRUD commands must auto-register by reflection
/// (no manual registry entry) so the HTTP / ClusterClient boundary can route them.
/// </summary>
[Fact]
public void Resolve_SchemaLibraryCommands_AllRegistered()
{
Assert.Equal(typeof(ListSharedSchemasCommand),
ManagementCommandRegistry.Resolve("ListSharedSchemas"));
Assert.Equal(typeof(GetSharedSchemaCommand),
ManagementCommandRegistry.Resolve("GetSharedSchema"));
Assert.Equal(typeof(CreateSharedSchemaCommand),
ManagementCommandRegistry.Resolve("CreateSharedSchema"));
Assert.Equal(typeof(UpdateSharedSchemaCommand),
ManagementCommandRegistry.Resolve("UpdateSharedSchema"));
Assert.Equal(typeof(DeleteSharedSchemaCommand),
ManagementCommandRegistry.Resolve("DeleteSharedSchema"));
}
/// <summary>A *Command record outside the Management namespace, for the negative test.</summary> /// <summary>A *Command record outside the Management namespace, for the negative test.</summary>
private record UnregisteredFakeCommand(int Id); private record UnregisteredFakeCommand(int Id);
} }
@@ -0,0 +1,320 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// ManagementActor handler tests for the M9-T32c schema-library CRUD commands. The
/// reusable named JSON-Schema library (<see cref="SharedSchema"/> +
/// <see cref="ISharedSchemaRepository"/>, T32a) gains its authoring surface here:
/// list/get are read-only (any authenticated user), create/update/delete are
/// Designer-gated, the schema JSON is validated as a JSON Schema, and the unique
/// Name is enforced with a clear duplicate-name error. Mirrors the repo-backed
/// DatabaseConnection handler idiom (direct repo writes + audit).
/// </summary>
public class SchemaLibraryHandlerTests : TestKit, IDisposable
{
private readonly ISharedSchemaRepository _schemaRepo;
private readonly IAuditService _auditService;
private readonly ServiceCollection _services;
public SchemaLibraryHandlerTests()
{
_schemaRepo = Substitute.For<ISharedSchemaRepository>();
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services.AddScoped(_ => _schemaRepo);
_services.AddScoped(_ => _auditService);
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
private const string ValidSchema =
"""{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}""";
// ── Gating ──────────────────────────────────────────────────────────────
[Fact]
public void CreateSharedSchema_WithDeployerRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void UpdateSharedSchema_WithNoRoles_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new UpdateSharedSchemaCommand(1, "Addr", null, ValidSchema));
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void DeleteSharedSchema_WithDeployerRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new DeleteSharedSchemaCommand(1), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
// ── List / Get (read-gated) ─────────────────────────────────────────────
[Fact]
public void ListSharedSchemas_WithNoRoles_ReturnsSuccess()
{
_schemaRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(new List<SharedSchema>
{
new() { Id = 1, Name = "Addr", SchemaJson = ValidSchema },
});
var actor = CreateActor();
var envelope = Envelope(new ListSharedSchemasCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Contains("Addr", response.JsonData);
}
[Fact]
public void GetSharedSchema_WithNoRoles_ReturnsSuccess()
{
_schemaRepo.GetByIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new SharedSchema { Id = 7, Name = "Addr", SchemaJson = ValidSchema });
var actor = CreateActor();
var envelope = Envelope(new GetSharedSchemaCommand(7));
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("Addr", response.JsonData);
}
// ── Create round-trip + validation ──────────────────────────────────────
[Fact]
public void CreateSharedSchema_WithDesignerRole_PersistsAndReturnsEntity()
{
SharedSchema? saved = null;
_schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
.Returns((SharedSchema?)null);
_schemaRepo.AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>())
.Returns(ci => { saved = ci.Arg<SharedSchema>(); saved.Id = 42; return 42; });
var actor = CreateActor();
var envelope = Envelope(
new CreateSharedSchemaCommand("Addr", "global", ValidSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(saved);
Assert.Equal("Addr", saved!.Name);
Assert.Equal("global", saved.Scope);
Assert.Equal(ValidSchema, saved.SchemaJson);
// Returned payload carries the store-generated id.
Assert.Contains("42", response.JsonData);
}
[Fact]
public void CreateSharedSchema_WithDuplicateName_ReturnsClearError()
{
_schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
.Returns(new SharedSchema { Id = 1, Name = "Addr", SchemaJson = ValidSchema });
var actor = CreateActor();
var envelope = Envelope(
new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Addr", response.Error);
Assert.Contains("already exists", response.Error);
}
[Fact]
public void CreateSharedSchema_WithEmptyName_ReturnsClearError()
{
var actor = CreateActor();
var envelope = Envelope(
new CreateSharedSchemaCommand(" ", null, ValidSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("name", response.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CreateSharedSchema_WithInvalidJson_ReturnsClearError()
{
var actor = CreateActor();
var envelope = Envelope(
new CreateSharedSchemaCommand("Addr", null, "{ not valid json"), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("schema", response.Error, StringComparison.OrdinalIgnoreCase);
// The repo must never be hit when the schema fails validation.
_schemaRepo.DidNotReceive().AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>());
}
// ── Update round-trip + validation ──────────────────────────────────────
[Fact]
public void UpdateSharedSchema_WithDesignerRole_PersistsChanges()
{
var existing = new SharedSchema { Id = 5, Name = "Addr", Scope = null, SchemaJson = ValidSchema };
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
_schemaRepo.GetByNameAsync("Address", Arg.Any<CancellationToken>())
.Returns((SharedSchema?)null);
const string updatedSchema =
"""{"type":"object","properties":{"city":{"type":"string"}}}""";
var actor = CreateActor();
var envelope = Envelope(
new UpdateSharedSchemaCommand(5, "Address", "us", updatedSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("Address", existing.Name);
Assert.Equal("us", existing.Scope);
Assert.Equal(updatedSchema, existing.SchemaJson);
}
[Fact]
public void UpdateSharedSchema_NotFound_ReturnsClearError()
{
_schemaRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((SharedSchema?)null);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSharedSchemaCommand(99, "Addr", null, ValidSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("99", response.Error);
}
[Fact]
public void UpdateSharedSchema_RenameToExistingName_ReturnsClearError()
{
var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema };
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
// A DIFFERENT row already owns "Other".
_schemaRepo.GetByNameAsync("Other", Arg.Any<CancellationToken>())
.Returns(new SharedSchema { Id = 6, Name = "Other", SchemaJson = ValidSchema });
var actor = CreateActor();
var envelope = Envelope(
new UpdateSharedSchemaCommand(5, "Other", null, ValidSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("already exists", response.Error);
}
[Fact]
public void UpdateSharedSchema_SameName_IsAllowed()
{
var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema };
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
// The unique-name check finds THIS row — must not be treated as a collision.
_schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>()).Returns(existing);
const string updatedSchema =
"""{"type":"object","properties":{"zip":{"type":"string"}}}""";
var actor = CreateActor();
var envelope = Envelope(
new UpdateSharedSchemaCommand(5, "Addr", null, updatedSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(updatedSchema, existing.SchemaJson);
}
// ── Delete round-trip ───────────────────────────────────────────────────
[Fact]
public void DeleteSharedSchema_WithDesignerRole_Deletes()
{
var actor = CreateActor();
var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
_schemaRepo.Received(1).DeleteAsync(5, Arg.Any<CancellationToken>());
}
[Fact]
public void ListSharedSchemas_WhenRepoThrows_ReturnsError()
{
_schemaRepo.ListAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("db down secret detail"));
var actor = CreateActor();
var envelope = Envelope(new ListSharedSchemasCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.DoesNotContain("db down secret detail", response.Error);
}
}