6 Commits

Author SHA1 Message Date
Joseph Doherty bb353c4d43 docs(plans): mark F1, F2, F6, F18, F19 follow-ups complete
Post-compaction batch of 5 follow-ups landed:
- F19 (09d6676): WithOtOpcUaRuntimeActors extension for driver-role spawn
- F1  (463512d): AuthEndpoints integration tests via TestServer
- F6  (dfc143c): RedundancyStateActor broadcast override + un-skip 2 tests
- F18 (b266f63): User.Identity.Name into Deployments createdBy
- F2  (45a8c79): JwtBearer validation via IPostConfigureOptions
2026-05-26 06:18:31 -04:00
Joseph Doherty 45a8c79ffe refactor(security): JwtBearer validation via IPostConfigureOptions (F2)
Eliminates the services.BuildServiceProvider() captive-provider antipattern
(ASP0000) inside AddJwtBearer. The new ConfigureJwtBearerFromTokenService
resolves JwtTokenService from the real DI container at runtime and stays
in lock-step with JwtTokenService.BuildValidationParameters.

All 27 Security.Tests stay green, including the F1 integration tests that
exercise /auth/token through the real bearer pipeline.
2026-05-26 06:18:00 -04:00
Joseph Doherty b266f63cd7 feat(adminui): thread User.Identity.Name into Deployments createdBy (F18)
Injects AuthenticationStateProvider and reads the current user's identity
name on Deploy click, replacing the "(current user)" placeholder.
Anonymous case falls back to "(anonymous)" — should never hit in practice
since the page requires FleetAdmin/ConfigEditor.
2026-05-26 06:17:53 -04:00
Joseph Doherty dfc143cdeb feat(controlplane): RedundancyStateActor broadcast override + un-skip tests (F6)
Mirrors the publisher-injection pattern from FleetStatusBroadcaster and
PeerOpcUaProbeActor: Props accepts an optional Action<object> override so
tests can use a TestProbe sink instead of bootstrapping DistributedPubSub
(unreliable single-node in TestKit).

Un-skips the two RedundancyStateActor tests deferred under F6.
2026-05-26 06:16:32 -04:00
Joseph Doherty 463512d1d8 test(security): AuthEndpoints integration tests via TestServer (F1)
7 tests exercise AddOtOpcUaAuth + MapOtOpcUaAuth end-to-end against an
in-memory ConfigDb + stub ILdapAuthService. Covers /auth/login (204/401/503),
/auth/ping (401/200), /auth/token (200+JWT shape), /auth/logout (204+clear-cookie).

Scope is the auth contract — not the fused Host bootstrap (cluster + role
gating belongs in the Task 58 multi-node harness). HostBuilder + TestServer
is used directly instead of WebApplicationFactory<Program> because the
test project has no Program entry point and Host needs Akka cluster up.
2026-05-26 06:15:07 -04:00
Joseph Doherty 09d6676e1f feat(runtime): WithOtOpcUaRuntimeActors extension for driver-role node startup (F19)
Mirrors WithOtOpcUaControlPlaneSingletons for the driver role. Spawns
DriverHostActor + DbHealthProbeActor on the host's ActorSystem and
registers both under marker keys. Host's Program.cs now calls it when
the node carries the driver role, so driver-only and admin+driver
deployments both auto-bootstrap the per-node actors.

Integration test covers the registration round-trip via Microsoft.Extensions.Hosting
+ Akka.Hosting AddAkka.
2026-05-26 06:09:37 -04:00
10 changed files with 402 additions and 37 deletions
@@ -75,12 +75,12 @@
{"id": 63, "subject": "Task 63: Traefik config + docker-dev compose", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,64,65], "blockedBy": [53]},
{"id": 64, "subject": "Task 64: Update existing docs (Redundancy, ServiceHosting, security)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,65], "blockedBy": [57]},
{"id": 65, "subject": "Task 65: New v2 docs (Architecture-v2, Cluster, ControlPlane, Runtime)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,64], "blockedBy": [57]},
{"id": "F1", "subject": "Follow-up: AuthEndpoints integration tests against fused Host", "status": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F2"], "blockedBy": [53], "origin": "Deviation from Task 29 (commit 38ea0c5) — deferred until Task 53 wires AddOtOpcUaAuth/MapOtOpcUaAuth in Program. Add WebApplicationFactory<OtOpcUa.Host.Program> tests for /auth/login (204/401/503), /auth/ping (401/200), /auth/token (200+JWT), /auth/logout (204+cookie clear) using a stub ILdapAuthService."},
{"id": "F2", "subject": "Follow-up: Replace JwtBearer BuildServiceProvider antipattern with IPostConfigureOptions", "status": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F1"], "blockedBy": [], "origin": "Deviation from Task 26 (commit 207fc6a) — AddOtOpcUaAuth uses services.BuildServiceProvider().CreateScope() inside .AddJwtBearer lambda (ASP0000). Refactor to IPostConfigureOptions<JwtBearerOptions> so validation parameters resolve lazily from the real request provider."},
{"id": "F1", "subject": "Follow-up: AuthEndpoints integration tests against fused Host", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F2"], "blockedBy": [53], "commit": "463512d", "origin": "Deviation from Task 29 (commit 38ea0c5) — deferred until Task 53 wires AddOtOpcUaAuth/MapOtOpcUaAuth in Program. Add WebApplicationFactory<OtOpcUa.Host.Program> tests for /auth/login (204/401/503), /auth/ping (401/200), /auth/token (200+JWT), /auth/logout (204+cookie clear) using a stub ILdapAuthService.", "deviation": "Used HostBuilder + TestServer directly (Security.Tests/AuthEndpointsIntegrationTests) instead of WebApplicationFactory<Program> — Host needs Akka cluster bootstrap that's out of scope for this contract test. Cluster-mode auth coverage belongs in Task 58."},
{"id": "F2", "subject": "Follow-up: Replace JwtBearer BuildServiceProvider antipattern with IPostConfigureOptions", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F1"], "blockedBy": [], "commit": "45a8c79", "origin": "Deviation from Task 26 (commit 207fc6a) — AddOtOpcUaAuth uses services.BuildServiceProvider().CreateScope() inside .AddJwtBearer lambda (ASP0000). Refactor to IPostConfigureOptions<JwtBearerOptions> so validation parameters resolve lazily from the real request provider."},
{"id": "F3", "subject": "Follow-up: Add EventId unique column to ConfigAuditLog for cross-restart audit idempotency", "status": "pending", "classification": "small", "estMinutes": 15, "parallelizableWith": ["F4"], "blockedBy": [], "origin": "Deviation from Task 33 — AuditWriterActor only dedups in-buffer; ConfigAuditLog lacks EventId column so a duplicate AuditEvent that arrives after a flush becomes a duplicate row. Add nullable EventId Guid + filtered unique index, migration, and refactor AuditWriterActor.WrapDetails away."},
{"id": "F4", "subject": "Follow-up: Harden AuditWriterActor.WrapDetails JSON synthesis with System.Text.Json", "status": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F3"], "blockedBy": [], "origin": "Self-review of Task 33 — WrapDetails uses string concat; malformed caller DetailsJson would produce invalid JSON and trip the CK_ConfigAuditLog_DetailsJson_IsJson constraint, killing the entire flush batch. Discard this task if F3 lands first (F3 removes WrapDetails entirely)."},
{"id": "F5", "subject": "Follow-up: ConfigPublishCoordinator multi-node happy-path test", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 30 — single-ActorSystem TestKit can't simulate the plan's 'dispatch to N driver nodes, all ack, seals' happy path because DiscoverDriverNodes() needs real cluster membership. Add a multi-system test (two ActorSystems joined into one cluster, driver-role on the second)."},
{"id": "F6", "subject": "Follow-up: RedundancyStateActor publisher abstraction so tests don't need DPS bootstrap", "status": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 35 — RedundancyStateActorTests are skipped because single-node DistributedPubSub bootstrap is unreliable in TestKit. Inject an Action<object> broadcast so tests can replace it with a probe; un-skip both tests."},
{"id": "F6", "subject": "Follow-up: RedundancyStateActor publisher abstraction so tests don't need DPS bootstrap", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [], "commit": "dfc143c", "origin": "Self-review of Task 35 — RedundancyStateActorTests are skipped because single-node DistributedPubSub bootstrap is unreliable in TestKit. Inject an Action<object> broadcast so tests can replace it with a probe; un-skip both tests."},
{"id": "F7", "subject": "Follow-up: DriverInstanceActor full engine wiring (subscriptions, writes, ApplyDelta diff)", "status": "pending", "classification": "standard", "estMinutes": 45, "parallelizableWith": [], "blockedBy": [44], "origin": "Self-review of Task 41 — subscription publishing, ApplyDelta diffing, bad-quality-on-disconnect, write path, and supervisor backoff are stubbed. Wire after OpcUaPublishActor lands."},
{"id": "F8", "subject": "Follow-up: VirtualTagActor engine wiring (compile expression, subscribe deps, publish result)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 42 — VirtualTagEngine.Evaluate not called; DependencyValueChanged just buffers."},
{"id": "F9", "subject": "Follow-up: ScriptedAlarmActor engine wiring + state persistence", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 43 — AlarmConditionService not called; PreRestart persistence to ScriptedAlarmState DB not wired; HistorianAdapter rows not emitted."},
@@ -92,8 +92,8 @@
{"id": "F15", "subject": "Follow-up: Migrate 47 legacy Admin Blazor components into AdminUI library", "status": "pending", "classification": "high-risk", "estMinutes": 180, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 48 — only MapAdminUI scaffold + 1 new page (Deployments). 47 pages stay in legacy Admin (accepted-broken) until Task 56."},
{"id": "F16", "subject": "Follow-up: Bridge FleetStatusBroadcaster → SignalR hubs (FleetStatusHub / AlertHub / ScriptLogHub)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 49 — hubs are passive Hub subclasses; the bridge from FleetStatusBroadcaster.broadcast → IHubContext is not wired."},
{"id": "F17", "subject": "Follow-up: FleetDiagnosticsClient real Akka ActorSelection round-trip (GetDiagnosticsRequest)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 51 — client returns an empty snapshot stub. Add GetDiagnosticsRequest contract + DriverHostActor handler + real Ask/Reply."},
{"id": "F18", "subject": "Follow-up: Thread HttpContext.User.Identity.Name into Deployments page (createdBy)", "status": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 52 — Deployments.razor hardcodes createdBy=\"(current user)\"; needs @inject AuthenticationStateProvider."},
{"id": "F19", "subject": "Follow-up: RuntimeStartup extension for driver-role node-actor spawn", "status": "pending", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 53 — only admin-role singletons are wired via WithOtOpcUaControlPlaneSingletons. Driver-role nodes need a parallel WithOtOpcUaRuntimeActors that spawns DriverHostActor."},
{"id": "F18", "subject": "Follow-up: Thread HttpContext.User.Identity.Name into Deployments page (createdBy)", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "commit": "b266f63", "origin": "Self-review of Task 52 — Deployments.razor hardcodes createdBy=\"(current user)\"; needs @inject AuthenticationStateProvider."},
{"id": "F19", "subject": "Follow-up: RuntimeStartup extension for driver-role node-actor spawn", "status": "completed", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "commit": "09d6676", "origin": "Self-review of Task 53 — only admin-role singletons are wired via WithOtOpcUaControlPlaneSingletons. Driver-role nodes need a parallel WithOtOpcUaRuntimeActors that spawns DriverHostActor."},
{"id": "F20", "subject": "Follow-up: Wire DriverInstanceActor.ShouldStub() into DriverHostActor child spawn", "status": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F7"], "blockedBy": [], "origin": "Self-review of Task 55 — ShouldStub helper exists but nothing calls it. Folds into F7 when DriverHostActor learns to spawn DriverInstanceActor children."}
]
}
@@ -1,5 +1,6 @@
@page "/deployments"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@@ -12,6 +13,7 @@
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAdminOperationsClient AdminOps
@inject AuthenticationStateProvider AuthState
@rendermode InteractiveServer
<PageTitle>Deployments</PageTitle>
@@ -103,8 +105,10 @@
_lastMessage = null;
try
{
var auth = await AuthState.GetAuthenticationStateAsync();
var createdBy = auth.User.Identity?.Name ?? "(anonymous)";
var result = await AdminOps.StartDeploymentAsync(
createdBy: "(current user)", // F18: thread HttpContext.User.Identity.Name through
createdBy: createdBy,
ct: CancellationToken.None);
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
@@ -24,15 +24,20 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly Akka.Cluster.Cluster _cluster;
private readonly Action<object>? _broadcastOverride;
private bool _dirty;
public ITimerScheduler Timers { get; set; } = null!;
public static Props Props() => Akka.Actor.Props.Create(() => new RedundancyStateActor());
public static Props Props(Action<object>? broadcast = null) =>
Akka.Actor.Props.Create(() => new RedundancyStateActor(broadcast));
public RedundancyStateActor()
public RedundancyStateActor() : this(broadcast: null) { }
public RedundancyStateActor(Action<object>? broadcast)
{
_cluster = Akka.Cluster.Cluster.Get(Context.System);
_broadcastOverride = broadcast;
Receive<ClusterEvent.IMemberEvent>(_ => MarkDirty());
Receive<ClusterEvent.LeaderChanged>(_ => MarkDirty());
@@ -68,7 +73,8 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
var snapshot = BuildSnapshot();
var msg = new RedundancyStateChanged(snapshot, CorrelationId.NewId());
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(Topic, msg));
if (_broadcastOverride is not null) _broadcastOverride(msg);
else DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(Topic, msg));
_log.Debug("Published RedundancyStateChanged with {Count} nodes", snapshot.Count);
}
+3 -4
View File
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Host;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
@@ -43,10 +44,8 @@ builder.Services.AddAkka("otopcua", (ab, _) =>
{
if (hasAdmin)
ab.WithOtOpcUaControlPlaneSingletons();
// Driver-role startup (DriverHostActor spawn + child probes) is wired in F19 once a
// RuntimeStartup contract is added — the actor itself exists (Phase 6), the registration
// extension does not yet. Without it, driver-role nodes still join the cluster and serve
// health/redundancy traffic but won't auto-spawn DriverHostActor.
if (hasDriver)
ab.WithOtOpcUaRuntimeActors();
});
if (hasAdmin)
@@ -0,0 +1,57 @@
using Akka.Actor;
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
namespace ZB.MOM.WW.OtOpcUa.Runtime;
public static class ServiceCollectionExtensions
{
public const string DriverRole = "driver";
public const string DriverHostActorName = "driver-host";
public const string DbHealthProbeActorName = "db-health";
/// <summary>
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
/// <see cref="DriverHostActor"/> (one per node) and <see cref="DbHealthProbeActor"/>
/// (consumed by the health endpoint + redundancy calc).
///
/// Mirror of <c>WithOtOpcUaControlPlaneSingletons</c> for the driver role. Both must
/// be registered on the same <see cref="AkkaConfigurationBuilder"/> as the cluster
/// bootstrap so the actors share the host's ActorSystem.
///
/// Wire from the fused Host's Program.cs when the node carries the <c>driver</c> role:
/// <code>
/// if (hasDriver)
/// ab.WithOtOpcUaRuntimeActors();
/// </code>
/// </summary>
public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder)
{
builder.WithActors((system, registry, resolver) =>
{
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
var roleInfo = resolver.GetService<IClusterRoleInfo>();
var dbHealth = system.ActorOf(
DbHealthProbeActor.Props(dbFactory),
DbHealthProbeActorName);
registry.Register<DbHealthProbeActorKey>(dbHealth);
var driverHost = system.ActorOf(
DriverHostActor.Props(dbFactory, roleInfo.LocalNode),
DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost);
});
return builder;
}
}
/// <summary>Marker key types used by <c>Akka.Hosting</c> to resolve runtime actors from the registry.</summary>
public sealed class DriverHostActorKey { }
public sealed class DbHealthProbeActorKey { }
@@ -11,6 +11,23 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security;
/// <summary>
/// Resolves <see cref="JwtTokenService"/> from the real DI container at runtime so the bearer
/// pipeline's <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> stay in
/// lock-step with <see cref="JwtTokenService.BuildValidationParameters"/>. Replaces the prior
/// <c>services.BuildServiceProvider()</c> antipattern (ASP0000) that built a captive provider
/// from inside <c>.AddJwtBearer</c>.
/// </summary>
internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService)
: IPostConfigureOptions<JwtBearerOptions>
{
public void PostConfigure(string? name, JwtBearerOptions options)
{
if (name != JwtBearerDefaults.AuthenticationScheme) return;
options.TokenValidationParameters = tokenService.BuildValidationParameters();
}
}
public static class ServiceCollectionExtensions
{
/// <summary>
@@ -52,12 +69,9 @@ public static class ServiceCollectionExtensions
return Task.CompletedTask;
};
})
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
{
using var scope = services.BuildServiceProvider().CreateScope();
var jwt = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
o.TokenValidationParameters = jwt.BuildValidationParameters();
});
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { /* parameters set by IPostConfigureOptions below */ });
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();
services.AddAuthorization(o =>
{
@@ -1,5 +1,4 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
@@ -8,36 +7,32 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Verifies <see cref="RedundancyStateActor"/> publishes a <see cref="RedundancyStateChanged"/>
/// snapshot in response to cluster events, and that the 250ms debounce coalesces bursts.
/// The actor accepts an <c>Action&lt;object&gt;</c> broadcast override so tests can use a
/// TestProbe sink instead of bootstrapping DistributedPubSub (which is flaky single-node).
/// </summary>
public sealed class RedundancyStateActorTests : ControlPlaneActorTestBase
{
[Fact(Skip = "Single-node DistributedPubSub bootstrap is flaky in TestKit; tracked as F6.")]
public void Self_join_triggers_RedundancyStateChanged_on_pubsub_topic()
[Fact]
public void Self_join_triggers_RedundancyStateChanged_via_broadcast_override()
{
// Subscribe a probe to the redundancy-state topic.
var probe = CreateTestProbe("redundancy-listener");
var mediator = DistributedPubSub.Get(Sys).Mediator;
mediator.Tell(new Subscribe(RedundancyStateActor.Topic, probe.Ref));
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(3));
// Start the actor — its PreStart subscribes to cluster events, which immediately fires
// a CurrentClusterState replay (InitialStateAsEvents). After the 250ms debounce window,
// a RedundancyStateChanged should land on the topic.
Sys.ActorOf(RedundancyStateActor.Props(), "redundancy-actor");
Sys.ActorOf(RedundancyStateActor.Props(broadcast: msg => probe.Ref.Tell(msg)),
"redundancy-actor");
var msg = probe.ExpectMsg<RedundancyStateChanged>(TimeSpan.FromSeconds(3));
msg.Nodes.ShouldNotBeNull();
msg.CorrelationId.Value.ShouldNotBe(Guid.Empty);
}
[Fact(Skip = "Same root cause as the prior test; tracked as F6.")]
[Fact]
public void Multiple_back_to_back_events_debounce_to_single_publish()
{
var probe = CreateTestProbe("dedup-listener");
var mediator = DistributedPubSub.Get(Sys).Mediator;
mediator.Tell(new Subscribe(RedundancyStateActor.Topic, probe.Ref));
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(3));
Sys.ActorOf(RedundancyStateActor.Props(), "redundancy-debounce");
Sys.ActorOf(RedundancyStateActor.Props(broadcast: msg => probe.Ref.Tell(msg)),
"redundancy-debounce");
// First publish should arrive within the debounce window.
probe.ExpectMsg<RedundancyStateChanged>(TimeSpan.FromSeconds(3));
@@ -0,0 +1,85 @@
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests;
/// <summary>
/// Verifies <c>WithOtOpcUaRuntimeActors</c> spawns <c>DriverHostActor</c> + <c>DbHealthProbeActor</c>
/// on the host's <c>ActorSystem</c> and registers both under their marker keys. This is the
/// driver-role mirror of the admin-role <c>WithOtOpcUaControlPlaneSingletons</c> bootstrap.
/// </summary>
public sealed class ServiceCollectionExtensionsTests
{
[Fact]
public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe()
{
using var host = Host.CreateDefaultBuilder()
.ConfigureServices((_, services) =>
{
services.AddSingleton<IDbContextFactory<OtOpcUaConfigDbContext>>(
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N")));
services.AddSingleton<IClusterRoleInfo>(new FakeClusterRoleInfo());
services.AddAkka("otopcua-test", (ab, _) =>
{
ab.AddHocon(@"
akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
akka.remote.dot-netty.tcp.hostname = ""127.0.0.1""
akka.remote.dot-netty.tcp.port = 0
akka.cluster.seed-nodes = []
akka.cluster.roles = [""driver""]
", HoconAddMode.Prepend);
ab.WithOtOpcUaRuntimeActors();
});
})
.Build();
await host.StartAsync();
try
{
var driverHost = host.Services.GetRequiredService<IRequiredActor<DriverHostActorKey>>();
var dbHealth = host.Services.GetRequiredService<IRequiredActor<DbHealthProbeActorKey>>();
driverHost.ActorRef.ShouldNotBeNull();
dbHealth.ActorRef.ShouldNotBeNull();
driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName);
dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName);
}
finally
{
await host.StopAsync();
}
}
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}
private sealed class FakeClusterRoleInfo : IClusterRoleInfo
{
public NodeId LocalNode { get; } = NodeId.Parse("test-node");
public IReadOnlySet<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
public bool HasRole(string role) => LocalRoles.Contains(role);
public IReadOnlyList<NodeId> MembersWithRole(string role) => Array.Empty<NodeId>();
public NodeId? RoleLeader(string role) => null;
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged
{
add { _ = value; }
remove { _ = value; }
}
}
}
@@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// End-to-end auth contract test: exercises <c>AddOtOpcUaAuth + MapOtOpcUaAuth</c>
/// through an in-memory <c>TestServer</c>. Scope is the auth surface — not the fused
/// <c>OtOpcUa.Host</c> bootstrap (that would entail Akka cluster + role gating, which
/// belongs in the multi-node Task 58 harness). Stub <see cref="ILdapAuthService"/>
/// drives the auth outcomes; <see cref="OtOpcUaConfigDbContext"/> uses EF in-memory so
/// DataProtection can persist keys.
/// </summary>
public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
{
private IHost _host = null!;
private TestServer _server = null!;
private static CancellationToken Ct => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
var dbName = $"auth-int-tests-{Guid.NewGuid():N}";
_host = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt =>
opt.UseInMemoryDatabase(dbName));
services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
opt.UseInMemoryDatabase(dbName));
services.AddRouting();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Security:Jwt:SigningKey"] = "test-signing-key-with-at-least-32-bytes-of-utf8-content",
["Security:Jwt:Issuer"] = "otopcua-test",
["Security:Jwt:Audience"] = "otopcua-test",
}).Build();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(e => e.MapOtOpcUaAuth());
});
})
.Build();
await _host.StartAsync(Ct);
_server = _host.GetTestServer();
}
public async ValueTask DisposeAsync()
{
await _host.StopAsync(TestContext.Current.CancellationToken);
_host.Dispose();
}
private HttpClient NewClient() => _server.CreateClient();
[Fact]
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
{
var client = NewClient();
var response = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
}
[Fact]
public async Task Login_with_invalid_credentials_returns_401()
{
var client = NewClient();
var response = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "wrong-password"), Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Login_when_ldap_throws_returns_503()
{
var client = NewClient();
var response = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct);
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
}
[Fact]
public async Task Ping_anonymous_returns_401()
{
var client = NewClient();
var response = await client.GetAsync("/auth/ping", Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Ping_after_cookie_login_returns_200()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var ping = new HttpRequestMessage(HttpMethod.Get, "/auth/ping");
AttachCookies(ping, loginResponse);
var response = await client.SendAsync(ping, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Token_after_cookie_login_returns_jwt()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var response = await client.SendAsync(tokenReq, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
var token = payload.GetProperty("token").GetString();
token.ShouldNotBeNullOrEmpty();
token!.Split('.').Length.ShouldBe(3);
}
[Fact]
public async Task Logout_clears_the_cookie()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var logoutReq = new HttpRequestMessage(HttpMethod.Post, "/auth/logout");
AttachCookies(logoutReq, loginResponse);
var response = await client.SendAsync(logoutReq, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
response.Headers.GetValues("Set-Cookie")
.ShouldContain(c => c.StartsWith("OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
}
private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior)
{
if (!prior.Headers.TryGetValues("Set-Cookie", out var setCookies)) return;
var cookiePairs = setCookies
.Select(c => c.Split(';', 2)[0])
.ToArray();
request.Headers.Add("Cookie", string.Join("; ", cookiePairs));
}
private sealed class StubLdapAuthService : ILdapAuthService
{
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (username == "ldap-down")
throw new InvalidOperationException("simulated LDAP outage");
if (password == "valid-password")
return Task.FromResult(new LdapAuthResult(
Success: true,
DisplayName: "Alice User",
Username: username,
Groups: ["ReadOnly"],
Roles: ["ConfigViewer"],
Error: null));
return Task.FromResult(new LdapAuthResult(
Success: false,
DisplayName: null,
Username: username,
Groups: [],
Roles: [],
Error: "Invalid username or password"));
}
}
}
@@ -12,6 +12,7 @@
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>