Phase 2 PR 4 — close 4 open MXAccess findings (push frames + reconnect + write-await + read-cancel) #3

Merged
dohertj2 merged 14 commits from phase-2-pr4-findings into v2 2026-04-18 06:57:22 -04:00
18 changed files with 916 additions and 27 deletions
Showing only changes of commit 18f93d72bb - Show all commits

View File

@@ -1,7 +1,12 @@
@page "/clusters/{ClusterId}"
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@implements IAsyncDisposable
@rendermode RenderMode.InteractiveServer
@inject ClusterService ClusterSvc
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
@@ -12,6 +17,13 @@
}
else
{
@if (_liveBanner is not null)
{
<div class="alert alert-info py-2 small">
<strong>Live update:</strong> @_liveBanner
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
</div>
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">@_cluster.Name</h1>
@@ -97,10 +109,16 @@ else
private ConfigGeneration? _currentPublished;
private string _tab = "overview";
private bool _busy;
private HubConnection? _hub;
private string? _liveBanner;
private string Tab(string key) => _tab == key ? "active" : string.Empty;
protected override async Task OnInitializedAsync() => await LoadAsync();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
await ConnectHubAsync();
}
private async Task LoadAsync()
{
@@ -110,6 +128,25 @@ else
_currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published);
}
private async Task ConnectHubAsync()
{
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
.WithAutomaticReconnect()
.Build();
_hub.On<NodeStateChangedMessage>("NodeStateChanged", async msg =>
{
if (msg.ClusterId != ClusterId) return;
_liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}";
await LoadAsync();
await InvokeAsync(StateHasChanged);
});
await _hub.StartAsync();
await _hub.SendAsync("SubscribeCluster", ClusterId);
}
private async Task CreateDraftAsync()
{
_busy = true;
@@ -120,4 +157,9 @@ else
}
finally { _busy = false; }
}
public async ValueTask DisposeAsync()
{
if (_hub is not null) await _hub.DisposeAsync();
}
}

View File

@@ -2,9 +2,10 @@
@using System.Security.Claims
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Admin.Security
@inject IHttpContextAccessor Http
@inject ILdapAuthService LdapAuth
@inject NavigationManager Nav
<div class="row justify-content-center mt-5">
<div class="col-md-5">
@@ -15,24 +16,24 @@
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
<div class="mb-3">
<label class="form-label">Username</label>
<InputText @bind-Value="_input.Username" class="form-control"/>
<InputText @bind-Value="_input.Username" class="form-control" autocomplete="username"/>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<InputText type="password" @bind-Value="_input.Password" class="form-control"/>
<InputText type="password" @bind-Value="_input.Password" class="form-control" autocomplete="current-password"/>
</div>
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
<button class="btn btn-primary w-100" type="submit">Sign in</button>
<button class="btn btn-primary w-100" type="submit" disabled="@_busy">
@(_busy ? "Signing in…" : "Sign in")
</button>
</EditForm>
<hr/>
<small class="text-muted">
<strong>Phase 1 note:</strong> real LDAP bind is deferred. This scaffold accepts
any non-empty credentials and issues a <code>FleetAdmin</code> cookie. Replace the
<code>LdapAuthService</code> stub with the ScadaLink-parity implementation before
production deployment.
LDAP bind against the configured directory. Dev defaults to GLAuth on
<code>localhost:3893</code>.
</small>
</div>
</div>
@@ -48,27 +49,52 @@
private Input _input = new();
private string? _error;
private bool _busy;
private async Task SignInAsync()
{
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
_error = null;
_busy = true;
try
{
_error = "Username and password are required";
return;
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
{
_error = "Username and password are required";
return;
}
var result = await LdapAuth.AuthenticateAsync(_input.Username, _input.Password, CancellationToken.None);
if (!result.Success)
{
_error = result.Error ?? "Sign-in failed";
return;
}
if (result.Roles.Count == 0)
{
_error = "Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.";
return;
}
var ctx = Http.HttpContext
?? throw new InvalidOperationException("HttpContext unavailable at sign-in");
var claims = new List<Claim>
{
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? _input.Username),
new(ClaimTypes.NameIdentifier, _input.Username),
};
foreach (var role in result.Roles)
claims.Add(new Claim(ClaimTypes.Role, role));
foreach (var group in result.Groups)
claims.Add(new Claim("ldap_group", group));
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
ctx.Response.Redirect("/");
}
var ctx = Http.HttpContext
?? throw new InvalidOperationException("HttpContext unavailable for sign-in");
var claims = new List<Claim>
{
new(ClaimTypes.Name, _input.Username),
new(ClaimTypes.Role, AdminRoles.FleetAdmin),
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
ctx.Response.Redirect("/");
finally { _busy = false; }
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
/// <summary>
/// Pushes sticky alerts (crash-loop circuit trips, failed applies, reservation-release
/// anomalies) to subscribed admin clients. Alerts don't auto-clear — the operator acks them
/// from the UI via <see cref="AcknowledgeAsync"/>.
/// </summary>
public sealed class AlertHub : Hub
{
public const string AllAlertsGroup = "__alerts__";
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlertsGroup);
await base.OnConnectedAsync();
}
/// <summary>Client-initiated ack. The server side of ack persistence is deferred — v2.1.</summary>
public Task AcknowledgeAsync(string alertId) => Task.CompletedTask;
}
public sealed record AlertMessage(
string AlertId,
string Severity,
string Title,
string Detail,
DateTime RaisedAtUtc,
string? ClusterId,
string? NodeId);

View File

@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
/// <summary>
/// Pushes per-node generation-apply state changes (<c>ClusterNodeGenerationState</c>) to
/// subscribed browser clients. Clients call <c>SubscribeCluster(clusterId)</c> on connect to
/// scope notifications; the server sends <c>NodeStateChanged</c> messages whenever the poller
/// observes a delta.
/// </summary>
public sealed class FleetStatusHub : Hub
{
public Task SubscribeCluster(string clusterId)
{
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(clusterId));
}
public Task UnsubscribeCluster(string clusterId)
{
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(clusterId));
}
/// <summary>Clients call this once to also receive fleet-wide status — used by the dashboard.</summary>
public Task SubscribeFleet() => Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
public const string FleetGroup = "__fleet__";
public static string GroupName(string clusterId) => $"cluster:{clusterId}";
}
public sealed record NodeStateChangedMessage(
string NodeId,
string ClusterId,
long? CurrentGenerationId,
string? LastAppliedStatus,
string? LastAppliedError,
DateTime? LastAppliedAt,
DateTime? LastSeenAt);

View File

@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
/// <summary>
/// Polls <c>ClusterNodeGenerationState</c> every <see cref="PollInterval"/> and publishes
/// per-node deltas to <see cref="FleetStatusHub"/>. Also raises sticky
/// <see cref="AlertMessage"/>s on transitions into <c>Failed</c>.
/// </summary>
public sealed class FleetStatusPoller(
IServiceScopeFactory scopeFactory,
IHubContext<FleetStatusHub> fleetHub,
IHubContext<AlertHub> alertHub,
ILogger<FleetStatusPoller> logger) : BackgroundService
{
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
private readonly Dictionary<string, NodeStateSnapshot> _last = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("FleetStatusPoller starting — interval {Interval}s", PollInterval.TotalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
try { await PollOnceAsync(stoppingToken); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "FleetStatusPoller tick failed");
}
try { await Task.Delay(PollInterval, stoppingToken); }
catch (OperationCanceledException) { break; }
}
}
internal async Task PollOnceAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n.ClusterId })
.ToListAsync(ct);
foreach (var r in rows)
{
var snapshot = new NodeStateSnapshot(
r.s.NodeId, r.ClusterId, r.s.CurrentGenerationId,
r.s.LastAppliedStatus?.ToString(), r.s.LastAppliedError,
r.s.LastAppliedAt, r.s.LastSeenAt);
var hadPrior = _last.TryGetValue(r.s.NodeId, out var prior);
if (!hadPrior || prior != snapshot)
{
_last[r.s.NodeId] = snapshot;
var msg = new NodeStateChangedMessage(
snapshot.NodeId, snapshot.ClusterId, snapshot.GenerationId,
snapshot.Status, snapshot.Error, snapshot.AppliedAt, snapshot.SeenAt);
await fleetHub.Clients.Group(FleetStatusHub.GroupName(snapshot.ClusterId))
.SendAsync("NodeStateChanged", msg, ct);
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
.SendAsync("NodeStateChanged", msg, ct);
if (snapshot.Status == "Failed" && (!hadPrior || prior.Status != "Failed"))
{
var alert = new AlertMessage(
AlertId: $"{snapshot.NodeId}:apply-failed",
Severity: "error",
Title: $"Apply failed on {snapshot.NodeId}",
Detail: snapshot.Error ?? "(no detail)",
RaisedAtUtc: DateTime.UtcNow,
ClusterId: snapshot.ClusterId,
NodeId: snapshot.NodeId);
await alertHub.Clients.Group(AlertHub.AllAlertsGroup)
.SendAsync("AlertRaised", alert, ct);
}
}
}
}
/// <summary>Exposed for tests — forces a snapshot reset so stub data re-seeds.</summary>
internal void ResetCache() => _last.Clear();
private readonly record struct NodeStateSnapshot(
string NodeId, string ClusterId, long? GenerationId,
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
}

View File

@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Admin.Components;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
@@ -15,6 +17,7 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSignalR();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o =>
@@ -45,6 +48,14 @@ builder.Services.AddScoped<ReservationService>();
builder.Services.AddScoped<DraftValidationService>();
builder.Services.AddScoped<AuditLogService>();
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
builder.Services.Configure<LdapOptions>(
builder.Configuration.GetSection("Authentication:Ldap"));
builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
builder.Services.AddHostedService<FleetStatusPoller>();
var app = builder.Build();
app.UseSerilogRequestLogging();
@@ -59,6 +70,9 @@ app.MapPost("/auth/logout", async (HttpContext ctx) =>
ctx.Response.Redirect("/");
});
app.MapHub<FleetStatusHub>("/hubs/fleet");
app.MapHub<AlertHub>("/hubs/alerts");
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
await app.RunAsync();

View File

@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
public interface ILdapAuthService
{
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>Outcome of an LDAP bind attempt. <see cref="Roles"/> is the mapped-set of Admin roles.</summary>
public sealed record LdapAuthResult(
bool Success,
string? DisplayName,
string? Username,
IReadOnlyList<string> Groups,
IReadOnlyList<string> Roles,
string? Error);

View File

@@ -0,0 +1,160 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
/// </summary>
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
: ILdapAuthService
{
private readonly LdapOptions _options = options.Value;
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username))
return new(false, null, null, [], [], "Username is required");
if (string.IsNullOrWhiteSpace(password))
return new(false, null, null, [], [], "Password is required");
if (!_options.UseTls && !_options.AllowInsecureLdap)
return new(false, null, username, [], [],
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
try
{
using var conn = new LdapConnection();
if (_options.UseTls) conn.SecureSocketLayer = true;
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
var bindDn = await ResolveUserDnAsync(conn, username, ct);
await Task.Run(() => conn.Bind(bindDn, password), ct);
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
var displayName = username;
var groups = new List<string>();
try
{
var filter = $"(cn={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
typesOnly: false), ct);
while (results.HasMore())
{
try
{
var entry = results.Next();
var name = entry.GetAttribute(_options.DisplayNameAttribute);
if (name is not null) displayName = name.StringValue;
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
if (groupAttr is not null)
{
foreach (var groupDn in groupAttr.StringValueArray)
groups.Add(ExtractFirstRdnValue(groupDn));
}
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
// directory doesn't populate memberOf (or populates it differently), the
// user's primary group name is recoverable from the second RDN of the DN.
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
{
var primary = ExtractOuSegment(entry.Dn);
if (primary is not null) groups.Add(primary);
}
}
catch (LdapException) { break; } // no-more-entries signalled by exception
}
}
catch (LdapException ex)
{
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
}
conn.Disconnect();
var roles = RoleMapper.Map(groups, _options.GroupToRole);
return new(true, displayName, username, groups, roles, null);
}
catch (LdapException ex)
{
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
return new(false, null, username, [], [], "Invalid username or password");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
return new(false, null, username, [], [], "Unexpected authentication error");
}
}
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
{
if (username.Contains('=')) return username; // already a DN
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
{
await Task.Run(() =>
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
var filter = $"(uid={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
if (results.HasMore())
return results.Next().Dn;
throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry for uid={username}");
}
return string.IsNullOrWhiteSpace(_options.SearchBase)
? $"cn={username}"
: $"cn={username},{_options.SearchBase}";
}
internal static string EscapeLdapFilter(string input) =>
input.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
/// <summary>
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
/// </summary>
internal static string? ExtractOuSegment(string dn)
{
var segments = dn.Split(',');
foreach (var segment in segments)
{
var trimmed = segment.Trim();
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
return trimmed[3..];
}
return null;
}
internal static string ExtractFirstRdnValue(string dn)
{
var equalsIdx = dn.IndexOf('=');
if (equalsIdx < 0) return dn;
var valueStart = equalsIdx + 1;
var commaIdx = dn.IndexOf(',', valueStart);
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
}
}

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>
/// LDAP + role-mapping configuration for the Admin UI. Bound from <c>appsettings.json</c>
/// <c>Authentication:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
/// <c>C:\publish\glauth\auth.md</c>).
/// </summary>
public sealed class LdapOptions
{
public const string SectionName = "Authentication:Ldap";
public bool Enabled { get; set; } = true;
public string Server { get; set; } = "localhost";
public int Port { get; set; } = 3893;
public bool UseTls { get; set; }
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
public bool AllowInsecureLdap { get; set; }
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
/// Service-account DN used for search-then-bind. When empty, a direct-bind with
/// <c>cn={user},{SearchBase}</c> is attempted.
/// </summary>
public string ServiceAccountDn { get; set; } = string.Empty;
public string ServiceAccountPassword { get; set; } = string.Empty;
public string DisplayNameAttribute { get; set; } = "cn";
public string GroupAttribute { get; set; } = "memberOf";
/// <summary>
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
/// role whose source group is in their membership list. Example dev mapping:
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
/// </summary>
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>
/// Deterministic LDAP-group-to-Admin-role mapper driven by <see cref="LdapOptions.GroupToRole"/>.
/// Every returned role corresponds to a group the user actually holds; no inference.
/// </summary>
public static class RoleMapper
{
public static IReadOnlyList<string> Map(
IReadOnlyCollection<string> ldapGroups,
IReadOnlyDictionary<string, string> groupToRole)
{
if (groupToRole.Count == 0) return [];
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in ldapGroups)
{
if (groupToRole.TryGetValue(group, out var role))
roles.Add(role);
}
return [.. roles];
}
}

View File

@@ -13,6 +13,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
</ItemGroup>
@@ -20,6 +22,10 @@
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Admin.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>

View File

@@ -2,6 +2,25 @@
"ConnectionStrings": {
"ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
},
"Authentication": {
"Ldap": {
"Enabled": true,
"Server": "localhost",
"Port": 3893,
"UseTls": false,
"AllowInsecureLdap": true,
"SearchBase": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,ou=svcaccts,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"DisplayNameAttribute": "cn",
"GroupAttribute": "memberOf",
"GroupToRole": {
"ReadOnly": "ConfigViewer",
"ReadWrite": "ConfigEditor",
"AlarmAck": "FleetAdmin"
}
}
},
"Serilog": {
"MinimumLevel": "Information"
}

View File

@@ -0,0 +1,155 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Integration")]
public sealed class FleetStatusPollerTests : IDisposable
{
private const string DefaultServer = "localhost,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
private readonly string _databaseName = $"OtOpcUaPollerTest_{Guid.NewGuid():N}";
private readonly string _connectionString;
private readonly ServiceProvider _sp;
public FleetStatusPollerTests()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
_connectionString =
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
var services = new ServiceCollection();
services.AddLogging();
services.AddSignalR();
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
_sp = services.BuildServiceProvider();
using var scope = _sp.CreateScope();
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
}
public void Dispose()
{
_sp.Dispose();
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString)
{ InitialCatalog = "master" }.ConnectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
IF DB_ID(N'{_databaseName}') IS NOT NULL
BEGIN
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [{_databaseName}];
END";
cmd.ExecuteNonQuery();
}
[Fact]
public async Task Poller_detects_new_apply_state_and_pushes_to_fleet_hub()
{
// Seed a cluster + node + credential + generation + apply state.
using (var scope = _sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "p-1", Name = "Poll test", Enterprise = "zb", Site = "dev",
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
});
db.ClusterNodes.Add(new ClusterNode
{
NodeId = "p-1-a", ClusterId = "p-1", RedundancyRole = RedundancyRole.Primary,
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
ApplicationUri = "urn:p1:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
});
var gen = new ConfigGeneration
{
ClusterId = "p-1", Status = GenerationStatus.Published, CreatedBy = "t",
PublishedBy = "t", PublishedAt = DateTime.UtcNow,
};
db.ConfigGenerations.Add(gen);
await db.SaveChangesAsync();
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
{
NodeId = "p-1-a", CurrentGenerationId = gen.GenerationId,
LastAppliedStatus = NodeApplyStatus.Applied,
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
// Recording hub contexts — capture what would be pushed to clients.
var recorder = new RecordingHubClients();
var fleetHub = new RecordingHubContext<FleetStatusHub>(recorder);
var alertHub = new RecordingHubContext<AlertHub>(new RecordingHubClients());
var poller = new FleetStatusPoller(
_sp.GetRequiredService<IServiceScopeFactory>(),
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance);
await poller.PollOnceAsync(CancellationToken.None);
var match = recorder.SentMessages.FirstOrDefault(m =>
m.Method == "NodeStateChanged" &&
m.Args.Length > 0 &&
m.Args[0] is NodeStateChangedMessage msg &&
msg.NodeId == "p-1-a");
match.ShouldNotBeNull("poller should have pushed a NodeStateChanged for p-1-a");
}
[Fact]
public async Task Poller_raises_alert_on_transition_into_Failed()
{
using (var scope = _sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "p-2", Name = "Fail test", Enterprise = "zb", Site = "dev",
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
});
db.ClusterNodes.Add(new ClusterNode
{
NodeId = "p-2-a", ClusterId = "p-2", RedundancyRole = RedundancyRole.Primary,
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
ApplicationUri = "urn:p2:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
});
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
{
NodeId = "p-2-a",
LastAppliedStatus = NodeApplyStatus.Failed,
LastAppliedError = "simulated",
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
var alerts = new RecordingHubClients();
var alertHub = new RecordingHubContext<AlertHub>(alerts);
var fleetHub = new RecordingHubContext<FleetStatusHub>(new RecordingHubClients());
var poller = new FleetStatusPoller(
_sp.GetRequiredService<IServiceScopeFactory>(),
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance);
await poller.PollOnceAsync(CancellationToken.None);
var alertMatch = alerts.SentMessages.FirstOrDefault(m =>
m.Method == "AlertRaised" &&
m.Args.Length > 0 &&
m.Args[0] is AlertMessage alert && alert.NodeId == "p-2-a" && alert.Severity == "error");
alertMatch.ShouldNotBeNull("poller should have raised AlertRaised for p-2-a");
}
}

View File

@@ -0,0 +1,45 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Deterministic unit tests for the LDAP input-sanitization and DN-parsing helpers. Live LDAP
/// bind against the GLAuth dev instance is covered by the admin-browser smoke path, not here,
/// because unit runs must not depend on a running external service.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LdapAuthServiceTests
{
private static string EscapeLdapFilter(string input) =>
(string)typeof(LdapAuthService)
.GetMethod("EscapeLdapFilter", BindingFlags.NonPublic | BindingFlags.Static)!
.Invoke(null, [input])!;
private static string ExtractFirstRdnValue(string dn) =>
(string)typeof(LdapAuthService)
.GetMethod("ExtractFirstRdnValue", BindingFlags.NonPublic | BindingFlags.Static)!
.Invoke(null, [dn])!;
[Theory]
[InlineData("alice", "alice")]
[InlineData("a(b)c", "a\\28b\\29c")]
[InlineData("wildcard*", "wildcard\\2a")]
[InlineData("back\\slash", "back\\5cslash")]
public void Escape_filter_replaces_control_chars(string input, string expected)
{
EscapeLdapFilter(input).ShouldBe(expected);
}
[Theory]
[InlineData("ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local", "ReadOnly")]
[InlineData("cn=admin,dc=corp,dc=com", "admin")]
[InlineData("ReadOnly", "ReadOnly")] // no '=' → pass through
[InlineData("ou=OnlySegment", "OnlySegment")]
public void Extract_first_RDN_strips_the_first_attribute_value(string dn, string expected)
{
ExtractFirstRdnValue(dn).ShouldBe(expected);
}
}

View File

@@ -0,0 +1,77 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped when
/// the port is unreachable so the test suite stays portable. Verifies the bind path —
/// group/role resolution is covered deterministically by <see cref="RoleMapperTests"/>,
/// <see cref="LdapAuthServiceTests"/>, and varies per directory (GLAuth, OpenLDAP, AD emit
/// <c>memberOf</c> differently; the service has a DN-based fallback for the GLAuth case).
/// </summary>
[Trait("Category", "LiveLdap")]
public sealed class LdapLiveBindTests
{
private static bool GlauthReachable()
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync("localhost", 3893);
return task.Wait(TimeSpan.FromSeconds(1));
}
catch { return false; }
}
private static LdapAuthService NewService() => new(Options.Create(new LdapOptions
{
Server = "localhost",
Port = 3893,
UseTls = false,
AllowInsecureLdap = true,
SearchBase = "dc=lmxopcua,dc=local",
ServiceAccountDn = "", // direct-bind: GLAuth's nameformat=cn + baseDN means user DN is cn={name},{baseDN}
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
["WriteOperate"] = "ConfigEditor",
["AlarmAck"] = "FleetAdmin",
},
}), NullLogger<LdapAuthService>.Instance);
[Fact]
public async Task Valid_credentials_bind_successfully()
{
if (!GlauthReachable()) return;
var result = await NewService().AuthenticateAsync("readonly", "readonly123");
result.Success.ShouldBeTrue(result.Error);
result.Username.ShouldBe("readonly");
}
[Fact]
public async Task Wrong_password_fails_bind()
{
if (!GlauthReachable()) return;
var result = await NewService().AuthenticateAsync("readonly", "wrong-pw");
result.Success.ShouldBeFalse();
result.Error.ShouldContain("Invalid");
}
[Fact]
public async Task Empty_username_is_rejected_before_hitting_the_directory()
{
// Doesn't need GLAuth — pre-flight validation in the service.
var result = await NewService().AuthenticateAsync("", "anything");
result.Success.ShouldBeFalse();
result.Error.ShouldContain("required", Case.Insensitive);
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Minimal in-memory <see cref="IHubContext{THub}"/> that captures SendAsync invocations for
/// assertion. Only the methods the <c>FleetStatusPoller</c> actually calls are implemented —
/// other interface surface throws to fail fast if the poller evolves new dependencies.
/// </summary>
public sealed class RecordingHubContext<THub> : IHubContext<THub> where THub : Hub
{
public RecordingHubContext(RecordingHubClients clients) => Clients = clients;
public IHubClients Clients { get; }
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class RecordingHubClients : IHubClients
{
public readonly List<RecordedMessage> SentMessages = [];
public IClientProxy All => NotUsed();
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NotUsed();
public IClientProxy Client(string connectionId) => NotUsed();
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NotUsed();
public IClientProxy Group(string groupName) => new RecordingClientProxy(groupName, SentMessages);
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NotUsed();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NotUsed();
public IClientProxy User(string userId) => NotUsed();
public IClientProxy Users(IReadOnlyList<string> userIds) => NotUsed();
private static IClientProxy NotUsed() => throw new NotImplementedException("not used by FleetStatusPoller");
}
public sealed class RecordingClientProxy(string target, List<RecordedMessage> sink) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
sink.Add(new RecordedMessage(target, method, args));
return Task.CompletedTask;
}
}
public sealed record RecordedMessage(string Target, string Method, object?[] Args);

View File

@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class RoleMapperTests
{
[Fact]
public void Maps_single_group_to_single_role()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
};
RoleMapper.Map(["ReadOnly"], mapping).ShouldBe(["ConfigViewer"]);
}
[Fact]
public void Group_match_is_case_insensitive()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
};
RoleMapper.Map(["readonly"], mapping).ShouldContain("ConfigViewer");
}
[Fact]
public void User_with_multiple_matching_groups_gets_all_distinct_roles()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
["ReadWrite"] = "ConfigEditor",
["AlarmAck"] = "FleetAdmin",
};
var roles = RoleMapper.Map(["ReadOnly", "ReadWrite", "AlarmAck"], mapping);
roles.ShouldContain("ConfigViewer");
roles.ShouldContain("ConfigEditor");
roles.ShouldContain("FleetAdmin");
roles.Count.ShouldBe(3);
}
[Fact]
public void Unknown_group_is_ignored()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
};
RoleMapper.Map(["UnrelatedGroup"], mapping).ShouldBeEmpty();
}
[Fact]
public void Empty_mapping_returns_empty_roles()
{
RoleMapper.Map(["ReadOnly"], new Dictionary<string, string>()).ShouldBeEmpty();
}
}