Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FleetStatusPollerTests.cs
Joseph Doherty 18f93d72bb Phase 1 LDAP auth + SignalR real-time — closes the last two open Admin UI TODOs. LDAP: Admin/Security/ gets SecurityOptions (bound from appsettings.json Authentication:Ldap), LdapAuthResult record, ILdapAuthService + LdapAuthService ported from scadalink-design's LdapAuthService (TLS guard, search-then-bind when a service account is configured, direct-bind fallback, service-account re-bind after user bind so attribute lookup uses the service principal's read rights, LdapException-to-friendly-message translation, OperationCanceledException pass-through), RoleMapper (pure function: case-insensitive group-name match against LdapOptions.GroupToRole, returns the distinct set of mapped Admin roles). EscapeLdapFilter escapes the five LDAP filter control chars (\, *, (, ), \0); ExtractFirstRdnValue pulls the value portion of a DN's leading RDN for memberOf parsing; ExtractOuSegment added as a GLAuth-specific fallback when the directory doesn't populate memberOf but does embed ou=PrimaryGroup into user DNs (actual GLAuth config in C:\publish\glauth\glauth.cfg uses nameformat=cn, groupformat=ou — direct bind is enough). Login page rewritten: EditForm → ILdapAuthService.AuthenticateAsync → cookie sign-in with claims (Name = displayName, NameIdentifier = username, Role for each mapped role, ldap_group for each raw group); failed bind shows the service's error; empty-role-map returns an explicit "no Admin role mapped" message rather than silently succeeding. appsettings.json gains an Authentication:Ldap section with dev-GLAuth defaults (localhost:3893, UseTls=false, AllowInsecureLdap=true for dev, GroupToRole maps GLAuth's ReadOnly/WriteOperate/AlarmAck → ConfigViewer/ConfigEditor/FleetAdmin). SignalR: two hubs + a BackgroundService poller. FleetStatusHub routes per-cluster NodeStateChanged pushes (SubscribeCluster/UnsubscribeCluster on connection; FleetGroup for dashboard-wide) with a typed NodeStateChangedMessage payload. AlertHub auto-subscribes every connection to the AllAlertsGroup and exposes AcknowledgeAsync (ack persistence deferred to v2.1). FleetStatusPoller (IHostedService, 5s default cadence) scans ClusterNodeGenerationState joined with ClusterNode, caches the prior snapshot per NodeId, pushes NodeStateChanged on any delta, raises AlertMessage("apply-failed") on transition INTO Failed (sticky — the hub client acks later). Program.cs registers HttpContextAccessor (sign-in needs it), SignalR, LdapOptions + ILdapAuthService, the poller as hosted service, and maps /hubs/fleet + /hubs/alerts endpoints. ClusterDetail adds @rendermode RenderMode.InteractiveServer, @implements IAsyncDisposable, and a HubConnectionBuilder subscription that calls LoadAsync() on each NodeStateChanged for its cluster so the "current published" card refreshes without a page reload; a dismissable "Live update" info banner surfaces the most recent event. Microsoft.AspNetCore.SignalR.Client 10.0.0 + Novell.Directory.Ldap.NETStandard 3.6.0 added. Tests: 13 new — RoleMapperTests (single group, case-insensitive match, multi-group distinct-roles, unknown-group ignored, empty-map); LdapAuthServiceTests (EscapeLdapFilter with 4 inputs, ExtractFirstRdnValue with 4 inputs — all via reflection against internals); LdapLiveBindTests (skip when localhost:3893 unreachable; valid-credentials-bind-succeeds; wrong-password-fails-with-recognizable-error; empty-username-rejected-before-hitting-directory); FleetStatusPollerTests (throwaway DB, seeds cluster+node+generation+apply-state, runs PollOnceAsync, asserts NodeStateChanged hit the recorder; second test seeds a Failed state and asserts AlertRaised fired) — backed by RecordingHubContext/RecordingHubClients/RecordingClientProxy that capture SendCoreAsync invocations while throwing NotImplementedException for the IHubClients methods the poller doesn't call (fail-fast if evolution adds new dependencies). InternalsVisibleTo added so the test project can call FleetStatusPoller.PollOnceAsync directly. Full solution 946 pass / 1 pre-existing Phase 0 baseline failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:28:49 -04:00

156 lines
6.5 KiB
C#

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");
}
}