Compare commits
6 Commits
698709a578
...
bb353c4d43
| Author | SHA1 | Date | |
|---|---|---|---|
| bb353c4d43 | |||
| 45a8c79ffe | |||
| b266f63cd7 | |||
| dfc143cdeb | |||
| 463512d1d8 | |||
| 09d6676e1f |
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<object></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>
|
||||
|
||||
Reference in New Issue
Block a user