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>
This commit is contained in:
Joseph Doherty
2026-04-17 22:28:49 -04:00
parent 7a5b535cd6
commit 18f93d72bb
18 changed files with 916 additions and 27 deletions

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