Merge branch 'master' into fix/r3-02-stale-good-family

# Conflicts:
#	src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
#	tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs
This commit is contained in:
Joseph Doherty
2026-08-20 11:03:02 -04:00
46 changed files with 5411 additions and 208 deletions
@@ -0,0 +1,241 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 (T1-T3) — an endpoint-metadata census over <c>MapOtOpcUaMeshGrpcServices</c>, driven in
/// all three role shapes a node can actually carry (admin-only, driver-only, fused).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this drives the PRODUCTION mapper, not a hand-copied shape.</b> The Critical this
/// plan fixes (archreview 03/S14) survived seven phases of live gates precisely because every
/// prior test built its own pipeline by hand — <c>MeshGrpcBoundaryTests</c>,
/// <c>ConfigServeListenerTests</c> and <c>LocalDbSyncListenerTests</c> all reproduce the
/// author's mental model of the mapping rather than the host's actual composition. This
/// census calls <see cref="MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices"/> — the SAME method
/// <c>Program.cs</c> calls — so it is structurally impossible for the guard to assert a shape
/// production no longer has.
/// </para>
/// <para>
/// <b>Filters out grpc-dotnet's own catch-all infrastructure.</b> Every <c>MapGrpcService</c>
/// call also registers a per-service "Unimplemented method for X" fallback, and the FIRST
/// such call in an app additionally registers one shared "Unimplemented service" catch-all.
/// Neither ever reaches real logic (they always answer <c>Unimplemented</c> regardless of
/// caller identity), so their authorization metadata carries no security meaning.
/// <see cref="RealMethodEndpoints"/> keeps only the five real, callable RPC methods, matched
/// against the exact known route-pattern set rather than a route-pattern-shape heuristic —
/// see that method's remarks for why a shape-based filter is NOT safe once LocalDbSync's
/// empty-prefix <c>MapGroup</c> (T3) is in play.
/// </para>
/// <para>
/// <b>Guard-of-the-guard.</b> Every assertion below pins an EXACT expected count and the
/// exact set of route patterns, never just "not empty" — a census that silently found zero
/// endpoints (a typo'd role gate, a helper that never got called) would pass a bare
/// non-empty check trivially. It cannot pass an exact-count-and-set check by accident.
/// </para>
/// </remarks>
public sealed class MeshGrpcAnonymousMetadataTests
{
private const string ArtifactFetch = "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
private const string CentralReportApply = "/mesh_transport.v1.CentralCommandService/ReportApply";
private const string TelemetrySubscribe = "/telemetry.v1.TelemetryStreamService/Subscribe";
private const string NodeExecute = "/mesh_transport.v1.NodeCommandService/Execute";
private const string LocalDbSyncSync = "/localdb_sync.v1.LocalDbSync/Sync";
/// <summary>The five known mesh gRPC method prefixes — the ONLY endpoints this map may anonymise.</summary>
private static readonly string[] AllFivePrefixes =
[LocalDbSyncSync, ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute];
[Fact]
public void AdminOnly_MapsExactlyTheConfigServeEndpoints()
{
// Admin-only: no driver role, so neither LocalDbSync nor the telemetry/node-command pair are
// mapped even though a port is configured for them below (0 here, but the shape is the point).
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
endpoints.Count.ShouldBe(2);
endpoints.Select(RoutePatternOf).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
}
[Fact]
public void DriverOnly_WithNoSyncPort_MapsExactlyTheTelemetryAndNodeCommandEndpoints()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(2);
endpoints.Select(RoutePatternOf).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(3);
endpoints.Select(RoutePatternOf).ShouldBe(
[LocalDbSyncSync, TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void Fused_WithAllThreePorts_MapsAllFiveEndpoints()
{
// The docker-dev central pair's shape (admin,driver,cluster-MAIN) once all three dedicated
// h2c ports are configured — the shape 03/S14 called out as breaking the deploy plane for
// the whole fleet, not only the four sites.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(5);
endpoints.Select(RoutePatternOf).ShouldBe(AllFivePrefixes, ignoreOrder: true);
}
[Fact]
public void NeitherRoleNorAnyPort_MapsNothing()
{
// The default-OFF shape: every dedicated port is 0. Nothing about the mesh gRPC surface
// should exist on a node that configured none of it.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 0));
endpoints.ShouldBeEmpty();
}
// ---------------------------------------------------------------------------------------
// 03/S14 — every mesh endpoint carries IAllowAnonymous, so the AdminUI cookie FallbackPolicy
// (RequireAuthenticatedUser) never reaches it; the PSK interceptors are the ONLY gate. T3
// (LocalDbSync's empty-prefix group) is asserted separately below.
// ---------------------------------------------------------------------------------------
[Fact]
public void AdminOnly_BothConfigServeEndpoints_CarryAllowAnonymous()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
AnonymousPatternsOf(endpoints).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
}
[Fact]
public void DriverOnly_BothTelemetryAndNodeCommandEndpoints_CarryAllowAnonymous()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
AnonymousPatternsOf(endpoints).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void Fused_AllFiveMeshEndpoints_CarryAllowAnonymous()
{
// Includes LocalDbSync (T3): MapZbLocalDbSync returns IEndpointRouteBuilder, not an
// IEndpointConventionBuilder, so .AllowAnonymous() cannot be chained onto it the way the
// other four are — it needs the empty-prefix MapGroup wrapper instead.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
AnonymousPatternsOf(endpoints).ShouldBe(AllFivePrefixes, ignoreOrder: true);
}
[Fact]
public void LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical()
{
// The empty-prefix MapGroup wrapper (T3) must be pure convention-carrying — it must not
// change the gRPC route pattern LocalDbSyncAuthInterceptor and the sync client both key on.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 0));
var sync = endpoints.ShouldHaveSingleItem();
sync.RoutePattern.RawText.ShouldBe(LocalDbSyncSync);
sync.Metadata.GetMetadata<IAllowAnonymous>().ShouldNotBeNull();
}
[Fact]
public void AllowAnonymous_NeverLeaksBeyondTheFiveKnownMeshEndpoints()
{
// Scope guard (theme 2): an endpoint mapped ALONGSIDE the mesh services, through no seam this
// extension touches, must never come out anonymous. If it did, some future edit widened the
// AllowAnonymous convention beyond the five known prefixes — the exact failure class the
// fallback-policy opt-out exists to bound.
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
builder.Services.AddGrpc();
var app = builder.Build();
app.MapOtOpcUaMeshGrpcServices(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056);
var sentinel = app.MapGet("/sentinel", () => "ok");
var sentinelEndpoint = ((IEndpointRouteBuilder)app).DataSources
.SelectMany(d => d.Endpoints)
.OfType<RouteEndpoint>()
.Single(e => e.RoutePattern.RawText == "/sentinel");
sentinelEndpoint.Metadata.GetMetadata<IAllowAnonymous>().ShouldBeNull();
_ = sentinel; // the builder handle itself is not the assertion target
}
// ---------------------------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------------------------
private static WebApplication BuildApp(
bool hasAdmin,
bool hasDriver,
int syncListenPort,
int configServeGrpcPort,
int telemetryListenPort)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
builder.Services.AddGrpc();
var app = builder.Build();
app.MapOtOpcUaMeshGrpcServices(
hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
return app;
}
/// <summary>
/// The real, callable RPC method endpoints — excludes grpc-dotnet's per-app "Unimplemented
/// service" catch-all and per-service "Unimplemented method for X" fallback (see class
/// remarks). Filtered against the exact set of known method route patterns rather than a
/// "starts with /" heuristic: inside the empty-prefix <c>MapGroup</c> the LocalDbSync map
/// (T3) uses, grpc-dotnet's own fallback patterns ALSO gain a leading <c>/</c>, so a
/// prefix-shaped filter would let them back in.
/// </summary>
private static List<RouteEndpoint> RealMethodEndpoints(WebApplication app) =>
((IEndpointRouteBuilder)app).DataSources
.SelectMany(d => d.Endpoints)
.OfType<RouteEndpoint>()
.Where(e => AllFivePrefixes.Contains(e.RoutePattern.RawText))
.ToList();
private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!;
/// <summary>Route patterns of the endpoints in <paramref name="endpoints"/> that carry <see cref="IAllowAnonymous"/>.</summary>
private static List<string> AnonymousPatternsOf(IEnumerable<RouteEndpoint> endpoints) =>
endpoints
.Where(e => e.Metadata.GetMetadata<IAllowAnonymous>() is not null)
.Select(RoutePatternOf)
.ToList();
}
@@ -0,0 +1,395 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using Akka.Actor;
using Akka.Hosting;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
using ZB.MOM.WW.OtOpcUa.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 T4 — the CI-shaped guard that would have caught 03/S14: mesh gRPC calls through the
/// REAL <c>AddOtOpcUaAuth</c> + <c>UseAuthentication</c>/<c>UseAuthorization</c> stack, not a
/// hand-built pipeline that never installs the middleware that broke production.
/// </summary>
/// <remarks>
/// <para>
/// <b>The load-bearing assertion is the ALLOW.</b> A blocked call and a refused call both
/// "fail"; only a correctly-keyed call SUCCEEDING through the real cookie
/// <c>FallbackPolicy</c> proves the <c>.AllowAnonymous()</c> opt-outs (T2/T3) actually work in
/// a pipeline that carries the policy at all. Every positive test here failed before T2/T3 —
/// confirmed by running this class against the T1 commit, where every one of them was
/// challenged by the cookie handler instead of reaching the interceptor.
/// </para>
/// <para>
/// <b>Negative control (anti-vacuity).</b> <see cref="LocalDbSyncService"/> is mapped directly
/// here — via a bare <c>MapGrpcService</c>, deliberately NOT through
/// <c>MapOtOpcUaMeshGrpcServices</c> — with no <c>.AllowAnonymous()</c>. A correctly-shaped
/// call to it must be refused by the cookie challenge. If it were not, the fixture's auth
/// stack would be inert and every positive assertion above would be proving nothing.
/// </para>
/// </remarks>
public sealed class MeshGrpcAuthStackTests : IAsyncLifetime
{
private const string ConfigServeKey = "test-configserve-key";
private const string NodeCommandKey = "test-node-psk";
private const string CentralCommandKey = "test-central-psk";
private const string TelemetryKey = "test-telemetry-key";
private const string ClusterId = "MAIN";
private WebApplication _app = null!;
private ActorSystem _system = null!;
private int _port;
private TaskCompletionSource<object> _centralInbox = null!;
private TaskCompletionSource<object> _nodeInbox = null!;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcAuthStackTests).Assembly.GetName().Name!;
// Production, not the WebApplicationBuilder default: forces ValidateOnBuild/ValidateScopes OFF
// (they engage only under Development), so services never resolved without a login attempt
// (ILdapAuthService's graph) don't need to be fully stood up for this fixture to build.
builder.Environment.EnvironmentName = Environments.Production;
builder.Logging.ClearProviders();
// DataProtection's PersistKeysToDbContext<OtOpcUaConfigDbContext> (inside AddOtOpcUaAuth)
// resolves a SCOPED OtOpcUaConfigDbContext via CreateScope() + GetRequiredService — it has no
// factory support. Mirrors AddOtOpcUaConfigDb's own factory+scoped-bridge shape, over InMemory.
var dbName = Guid.NewGuid().ToString("N");
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase(dbName));
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
// The whole point of this fixture: exercise the REAL cookie handler, not
// AutoLoginAuthenticationHandler. See CookieSchemeIsTheRealHandler_NotAutoLogin.
["Security:Auth:DisableLogin"] = "false",
// This fixture serves cleartext h2c; a HTTPS-only cookie policy is irrelevant to what is
// under test (the FallbackPolicy applies identically either way) and would otherwise log
// a startup Warning for no reason.
["Security:Cookie:RequireHttpsCookie"] = "false",
});
builder.Services.AddOtOpcUaAuth(builder.Configuration);
// Interceptor options bound directly (NOT via AddOtOpcUaCluster, which also wires
// AkkaClusterOptions/ConfigSourceOptions/TelemetryDialOptions validators with ValidateOnStart —
// heavy machinery this fixture has no use for and no seed-node config to satisfy).
builder.Services.Configure<ConfigServeOptions>(o => o.ApiKey = ConfigServeKey);
builder.Services.Configure<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
builder.Services.AddSingleton<IMeshPskProvider>(new FixedPskProvider(ClusterId, CentralCommandKey));
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
_system = ActorSystem.Create("mesh-auth-stack-" + Guid.NewGuid().ToString("N"));
_centralInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
var registry = new ActorRegistry();
registry.Register<CentralCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_centralInbox)));
registry.Register<NodeCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_nodeInbox)));
builder.Services.AddSingleton(registry);
// The production interceptor set — the ONLY inbound auth on the gRPC side. Deliberately does
// NOT include LocalDbSyncAuthInterceptor: LocalDbSyncService is mapped below purely as the
// negative control and is never given a matching interceptor here.
builder.Services.AddGrpc(o =>
{
o.Interceptors.Add<ConfigServeAuthInterceptor>();
o.Interceptors.Add<CentralCommandAuthInterceptor>();
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
o.Interceptors.Add<MeshCommandAuthInterceptor>();
});
_port = GetFreePort();
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
_app = builder.Build();
_app.UseAuthentication();
_app.UseAuthorization();
// The production mapper — the four chainable maps carry AllowAnonymous (T2); LocalDbSync is
// NOT mapped here (syncListenPort: 0) so it stays free for the negative control below without
// a route collision.
_app.MapOtOpcUaMeshGrpcServices(
hasAdmin: true, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 1, telemetryListenPort: 1);
// Negative control (T4): mapped directly, NOT through MapOtOpcUaMeshGrpcServices, and
// deliberately with no .AllowAnonymous().
_app.MapGrpcService<LocalDbSyncService>();
await _app.StartAsync(TestContext.Current.CancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _app.StopAsync(TimeSpan.FromSeconds(10));
await _app.DisposeAsync();
await _system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
}
[Fact]
public async Task CookieSchemeIsTheRealHandler_NotAutoLogin()
{
// If this resolves AutoLoginAuthenticationHandler, every assertion below is meaningless — the
// fallback policy would pass every request regardless of AllowAnonymous.
var provider = _app.Services.GetRequiredService<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider>();
var scheme = await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
scheme.ShouldNotBeNull();
scheme.HandlerType.ShouldBe(typeof(CookieAuthenticationHandler));
}
[Fact]
public async Task CorrectlyKeyed_ArtifactFetch_SucceedsThroughTheRealAuthStack()
{
var deploymentId = Guid.NewGuid();
var blob = "R3-01-T4-artifact-bytes"u8.ToArray();
var factory = _app.Services.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>();
await using (var db = await factory.CreateDbContextAsync(TestContext.Current.CancellationToken))
{
db.Deployments.Add(new Deployment
{
DeploymentId = deploymentId,
RevisionHash = new string('a', 64),
CreatedBy = "r3-01-t4",
ArtifactBlob = blob,
});
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {ConfigServeKey}" } };
using var call = client.Fetch(
new FetchRequest { DeploymentId = deploymentId.ToString() },
headers,
cancellationToken: TestContext.Current.CancellationToken);
using var buffer = new MemoryStream();
await foreach (var chunk in call.ResponseStream.ReadAllAsync(TestContext.Current.CancellationToken))
chunk.Data.WriteTo(buffer);
buffer.ToArray().ShouldBe(blob);
}
[Fact]
public async Task CorrectlyKeyed_CentralCommandReportApply_SucceedsThroughTheRealAuthStack()
{
var ack = new ApplyAck(
new DeploymentId(Guid.NewGuid()),
new NodeId("node-a"),
ApplyAckOutcome.Applied,
FailureReason: null,
CorrelationId.NewId());
var client = new CentralCommandService.CentralCommandServiceClient(Channel());
var headers = new Metadata
{
{ "authorization", $"Bearer {CentralCommandKey}" },
{ "x-otopcua-cluster", ClusterId },
};
await client.ReportApplyAsync(
MeshTransportDtoMapper.ToProto(ack), headers, cancellationToken: TestContext.Current.CancellationToken);
var received = await _centralInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
received.ShouldBeOfType<ApplyAck>().DeploymentId.ShouldBe(ack.DeploymentId);
}
[Fact]
public async Task CorrectlyKeyed_NodeCommandExecute_SucceedsThroughTheRealAuthStack()
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {NodeCommandKey}" } };
await client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), headers, cancellationToken: TestContext.Current.CancellationToken);
var received = await _nodeInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
received.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
}
[Fact]
public async Task FalsifiabilityControl_AnUnAnonymisedServiceMapped_RefusesEvenACorrectlyShapedCall()
{
// LocalDbSyncService carries no AllowAnonymous in this fixture (see InitializeAsync). If this
// call succeeds, the fixture's auth stack is not actually challenging unmarked endpoints and
// every "succeeds through the real auth stack" assertion above proves nothing.
var client = new ZB.MOM.WW.LocalDb.Contracts.LocalDbSync.LocalDbSyncClient(Channel());
var headers = new Metadata { { "authorization", "Bearer irrelevant-to-this-endpoint" } };
using var call = client.Sync(headers, cancellationToken: TestContext.Current.CancellationToken);
await call.RequestStream.CompleteAsync();
// Review (R3-01): asserting a bare Exception was vacuous — an anonymised endpoint ALSO throws
// here (handler deps are not wired in this fixture => RpcException Unknown). Only the
// cookie-challenge signature discriminates: the FallbackPolicy's 302 to /login is a
// non-gRPC response, surfaced by grpc-dotnet as Internal + PROTOCOL_ERROR.
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.Internal);
ex.Status.Detail.ShouldContain("PROTOCOL_ERROR");
}
// ---------------------------------------------------------------------------------------
// T5 - status fidelity: a no-PSK / wrong-PSK call reports the INTERCEPTOR's verdict, never a
// cookie challenge. This is the vacuity trap the plan calls out: ConfigServeAuthInterceptor
// throws StatusCode.Unauthenticated, and Grpc.Net.Client also maps an HTTP 401 to
// Unauthenticated - so "the call failed with Unauthenticated" is true on a broken build AND a
// fixed one. The interceptor's DETAIL string is the only thing that discriminates them.
//
// Observed cookie-challenge signature (captured red-then-green against this same fixture, see
// MeshGrpcAuthStackTests' positive tests): the challenge does not surface as a clean
// Unauthenticated/PermissionDenied at all. Grpc.Net.Client reports
// RpcException(StatusCode.Internal, "Error starting gRPC call. HttpRequestException: The HTTP/2
// server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1)..."), because the cookie
// handler's redirect/401 breaks the prior-knowledge h2c stream before any gRPC trailers are
// written. So a status-only assertion here would ALSO be vacuous in the other direction: a
// regressed build fails with StatusCode.Internal, never Unauthenticated/PermissionDenied, and a
// caller checking only "did it throw" cannot tell a blocked call from a refused one. Recorded
// here per the plan (T5 step 2) - this is the exact string the live gate (T10) greps for.
// ---------------------------------------------------------------------------------------
[Theory]
[InlineData(null)]
[InlineData("Bearer the-wrong-key")]
public async Task NoOrWrongPsk_ArtifactFetch_FailsWithTheInterceptorsDetail(string? authHeader)
{
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
var headers = new Metadata();
if (authHeader is not null)
headers.Add("authorization", authHeader);
using var call = client.Fetch(
new FetchRequest { DeploymentId = Guid.NewGuid().ToString() },
headers,
cancellationToken: TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.Unauthenticated);
ex.Status.Detail.ShouldBe("Config-serve authentication failed.");
}
[Theory]
[InlineData(null)]
[InlineData("Bearer the-wrong-key")]
public async Task NoOrWrongPsk_TelemetrySubscribe_FailsWithTheInterceptorsDetail(string? authHeader)
{
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
var headers = new Metadata();
if (authHeader is not null)
headers.Add("authorization", authHeader);
using var call = client.Subscribe(
new TelemetryStreamRequest { CorrelationId = "r3-01-t5" },
headers,
cancellationToken: TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Telemetry stream authentication failed.");
}
[Theory]
[InlineData(null)]
[InlineData("Bearer the-wrong-key")]
public async Task NoOrWrongPsk_NodeCommandExecute_FailsWithTheInterceptorsDetail(string? authHeader)
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var headers = new Metadata();
if (authHeader is not null)
headers.Add("authorization", authHeader);
var ex = await Should.ThrowAsync<RpcException>(() => client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), headers,
cancellationToken: TestContext.Current.CancellationToken).ResponseAsync);
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Node command authentication failed.");
}
// ---------------------------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------------------------
private GrpcChannel Channel() => GrpcChannel.ForAddress($"http://localhost:{_port}");
private static int GetFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// Hands out the one cluster key <see cref="CentralCommandAuthInterceptor"/> expects. Stands
/// in for the real <c>MeshPskProvider</c> (secret-store lookups, caching, the known-cluster
/// gate) — out of scope here, where the subject is the HTTP-layer auth stack, not the PSK
/// resolution chain (that is <c>MeshPskProviderTests</c>' job).
/// </summary>
private sealed class FixedPskProvider(string clusterId, string psk) : IMeshPskProvider
{
public bool TryGetPsk(string clusterId2, [NotNullWhen(true)] out string? psk2)
{
psk2 = string.Equals(clusterId2, clusterId, StringComparison.Ordinal) ? psk : null;
return psk2 is not null;
}
public bool Matches(string clusterId2, string presented) =>
string.Equals(clusterId2, clusterId, StringComparison.Ordinal)
&& string.Equals(presented, psk, StringComparison.Ordinal);
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds) { }
public void Invalidate(string clusterId2) { }
public void InvalidateAll() { }
}
/// <summary>Completes an inbox with the first message it receives.</summary>
private sealed class CaptureActor : ReceiveActor
{
public static Props Props(TaskCompletionSource<object> sink) =>
Akka.Actor.Props.Create(() => new CaptureActor(sink));
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
}
}
@@ -0,0 +1,202 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using Akka.Actor;
using Akka.Hosting;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 T6 — pins the report's "driver-only nodes are structurally unaffected" finding
/// (archreview 03/S14): a node with no <c>admin</c> role never registers
/// <c>AddOtOpcUaAuth</c>/<c>UseAuthentication</c>/<c>UseAuthorization</c> at all, so it has no
/// cookie <c>FallbackPolicy</c> to opt out of in the first place — the PSK interceptors were
/// always the ONLY gate on this shape, with or without <c>.AllowAnonymous()</c>.
/// </summary>
/// <remarks>
/// A future "register auth everywhere for consistency" change would silently reintroduce 03/S14
/// for every site node in the fleet. This class is what turns that into a failing test instead of
/// a live outage: it asserts BOTH that the driver-only mesh endpoints behave identically to the
/// fused shape's (T4) AND that the reason they do — no <see cref="IAuthorizationPolicyProvider"/>
/// registered at all — actually holds, so a change that adds auth services here fails on the
/// second assertion even if it somehow left the first passing.
/// </remarks>
public sealed class MeshGrpcDriverOnlyPipelineTests : IAsyncLifetime
{
private const string NodeCommandKey = "test-driveronly-node-psk";
private const string TelemetryKey = "test-driveronly-telemetry-key";
private WebApplication _app = null!;
private ActorSystem _system = null!;
private int _port;
private ITelemetryLocalHub _hub = null!;
private TaskCompletionSource<object> _nodeInbox = null!;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcDriverOnlyPipelineTests).Assembly.GetName().Name!;
builder.Logging.ClearProviders();
// Deliberately NO AddOtOpcUaAuth, NO UseAuthentication, NO UseAuthorization — this IS the
// driver-only shape (Program.cs gates all three on hasAdmin). Nothing here should need them.
builder.Services.Configure<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
_system = ActorSystem.Create("mesh-driveronly-" + Guid.NewGuid().ToString("N"));
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
var registry = new ActorRegistry();
registry.Register<NodeCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_nodeInbox)));
builder.Services.AddSingleton(registry);
// The driver-role interceptor subset only (matches Program.cs's `if (hasDriver)` branch);
// no ConfigServeAuthInterceptor/CentralCommandAuthInterceptor — those are admin-role.
builder.Services.AddGrpc(o =>
{
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
o.Interceptors.Add<MeshCommandAuthInterceptor>();
});
_port = GetFreePort();
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
_app = builder.Build();
_hub = _app.Services.GetRequiredService<ITelemetryLocalHub>();
// hasAdmin: false — the driver-only shape. syncListenPort: 0 so LocalDbSync (admin-agnostic
// but out of scope here) is not mapped; configServeGrpcPort: 0 because a driver-only node
// never carries ConfigServe.
_app.MapOtOpcUaMeshGrpcServices(
hasAdmin: false, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 1);
await _app.StartAsync(TestContext.Current.CancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _app.StopAsync(TimeSpan.FromSeconds(10));
await _app.DisposeAsync();
await _system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
}
[Fact]
public void NoAuthorizationPolicyProviderIsRegistered()
{
// THE structural reason this shape is unaffected: with AddOtOpcUaAuth never called, there is
// no FallbackPolicy to opt out of. If a future change registers authorization services on a
// driver-only node, this goes red before any endpoint's behaviour does.
_app.Services.GetService<IAuthorizationPolicyProvider>().ShouldBeNull();
}
[Fact]
public async Task CorrectlyKeyed_NodeCommandExecute_Succeeds()
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {NodeCommandKey}" } };
await client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), headers,
cancellationToken: TestContext.Current.CancellationToken);
var received = await _nodeInbox.Task.WaitAsync(
TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
received.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
}
[Fact]
public async Task NoKey_NodeCommandExecute_FailsWithTheInterceptorsDetail()
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var ex = await Should.ThrowAsync<RpcException>(() => client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), new Metadata(),
cancellationToken: TestContext.Current.CancellationToken).ResponseAsync);
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Node command authentication failed.");
}
[Fact]
public async Task CorrectlyKeyed_TelemetrySubscribe_Succeeds()
{
// Health is snapshot-style (ITelemetryLocalHub.Emit / class remarks): emitting BEFORE the
// Subscribe call means the new subscriber's reader is primed with it immediately, with no
// race against a live delta.
_hub.Emit(new TelemetryItem.Health(new DriverHealthChanged(
"C1", "driver-1", "Healthy", DateTime.UtcNow, null, 0, DateTime.UtcNow)));
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {TelemetryKey}" } };
using var call = client.Subscribe(
new TelemetryStreamRequest { CorrelationId = "r3-01-t6" },
headers,
cancellationToken: TestContext.Current.CancellationToken);
(await call.ResponseStream.MoveNext(TestContext.Current.CancellationToken)).ShouldBeTrue();
call.ResponseStream.Current.DriverHealth.DriverInstanceId.ShouldBe("driver-1");
}
[Fact]
public async Task NoKey_TelemetrySubscribe_FailsWithTheInterceptorsDetail()
{
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
using var call = client.Subscribe(
new TelemetryStreamRequest { CorrelationId = "r3-01-t6-nokey" },
cancellationToken: TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Telemetry stream authentication failed.");
}
// ---------------------------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------------------------
private GrpcChannel Channel() => GrpcChannel.ForAddress($"http://localhost:{_port}");
private static int GetFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Completes an inbox with the first message it receives.</summary>
private sealed class CaptureActor : ReceiveActor
{
public static Props Props(TaskCompletionSource<object> sink) =>
Akka.Actor.Props.Create(() => new CaptureActor(sink));
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
}
}
@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 T7 — a source-scan guard, following the solution-root-walk idiom
/// <c>ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.FlushLoggingConventionTests</c> uses. Complements the
/// endpoint-metadata census (<c>MeshGrpcAnonymousMetadataTests</c>): the census catches a map
/// added to <c>MeshGrpcEndpoints</c> without the <c>AllowAnonymous</c> convention, but it cannot
/// catch a map added to <c>Program.cs</c> that bypasses the helper entirely — that shape never
/// goes through <c>MapOtOpcUaMeshGrpcServices</c> at all, so the census would simply never see it.
/// </summary>
/// <remarks>
/// Would have FAILED at HEAD before T1: <c>Program.cs</c> used to carry the five map sites
/// inline (<c>MapZbLocalDbSync()</c>, four <c>MapGrpcService&lt;T&gt;()</c> calls) with no
/// <c>MapOtOpcUaMeshGrpcServices</c> call at all.
/// </remarks>
public sealed class MeshGrpcMapConventionTests
{
private static readonly string ProgramCsPath = LocateProgramCs();
[Fact]
public void ProgramCs_ContainsNoDirectMeshGrpcMapSites_AndExactlyOneMapperCall()
{
var source = File.ReadAllText(ProgramCsPath);
// Guard-of-the-guard: a bad path (an empty file, or one that never had the mapper in the
// first place) must not pass this test by finding nothing to count.
source.ShouldNotBeNullOrWhiteSpace();
source.ShouldContain("MapOtOpcUaMeshGrpcServices(");
CountOccurrences(source, "MapGrpcService<").ShouldBe(0,
"every MapGrpcService<T>() call must live inside MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices, " +
"not inline in Program.cs (03/S14 — a map added outside the helper is invisible to the " +
"endpoint-metadata census).");
CountOccurrences(source, "MapZbLocalDbSync(").ShouldBe(0,
"the LocalDb sync map must live inside MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices, not inline " +
"in Program.cs, for the same reason as MapGrpcService<T>() above.");
CountOccurrences(source, "MapOtOpcUaMeshGrpcServices(").ShouldBe(1,
"Program.cs must call the mapper exactly once; more than one call would map the mesh gRPC " +
"services twice (a route conflict) and zero calls would drop them entirely.");
}
private static int CountOccurrences(string source, string needle)
{
var count = 0;
var index = 0;
while ((index = source.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += needle.Length;
}
return count;
}
private static string LocateProgramCs()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("Could not find solution root (ZB.MOM.WW.OtOpcUa.slnx).");
return Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host", "Program.cs");
}
}
@@ -236,8 +236,10 @@ public sealed class MeshRegistrationRoleScopingTests
}
/// <summary>
/// Pins the scanner's expected result shape against today's Program.cs — 6 markers,
/// 5 braced blocks + the 1 braceless ConfigDb gate, so 6 scanned ranges. Without this, a
/// Pins the scanner's expected result shape against today's Program.cs — 5 markers,
/// 4 braced blocks + the 1 braceless ConfigDb gate, so 5 scanned ranges (the data-plane
/// key-store branch moved into <c>DataPlaneAuthRegistration</c> for arch-review 03/S18, taking
/// one braced marker with it). Without this, a
/// scanner bug that silently drops or merges a marker (as the pre-fix version did to the
/// braceless marker — it fabricated a phantom range spanning an unrelated if-block and swallowed
/// a real nested 'if (hasAdmin)' block inside it, yielding 5 ranges instead of 6) would not
@@ -248,13 +250,13 @@ public sealed class MeshRegistrationRoleScopingTests
{
var source = ReadProgramSource();
CountOccurrences(source, HasAdminMarker).ShouldBe(6,
CountOccurrences(source, HasAdminMarker).ShouldBe(5,
"Program.cs's 'if (hasAdmin)' marker count changed. If a marker was added or removed, " +
"update this pin — and check whether the new/removed occurrence is braced or braceless — " +
"rather than letting the scanner silently mis-shape its result.");
var ranges = FindHasAdminBlockRanges(source);
ranges.Count.ShouldBe(6,
ranges.Count.ShouldBe(5,
"expected one scanned range per 'if (hasAdmin)' marker, including the braceless ConfigDb " +
"gate. A mismatch means the scanner adopted an unrelated brace or otherwise merged/dropped " +
"a marker instead of handling each one independently.");
@@ -0,0 +1,151 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Covers <see cref="AclRevalidationTimer"/> — the low-frequency trigger that makes key expiry reach
/// a live subscription without waiting for a deployment (arch-review 03/S19).
/// </summary>
public sealed class AclRevalidationTimerTests
{
/// <summary>A positive interval arms one timer whose due time and period are both that interval.</summary>
[Fact]
public void A_positive_interval_creates_a_timer_with_that_period()
{
var clock = new RecordingTimeProvider();
using var _ = new AclRevalidationTimer(
() => { }, TimeSpan.FromSeconds(30), clock, NullLogger.Instance);
clock.Created.Count.ShouldBe(1);
clock.Created[0].DueTime.ShouldBe(TimeSpan.FromSeconds(30));
clock.Created[0].Period.ShouldBe(TimeSpan.FromSeconds(30));
}
/// <summary>
/// A non-positive interval arms nothing at all, and says so once — a disabled guard should be
/// visible in the log rather than inferred from an absence of activity.
/// </summary>
[Fact]
public void A_zero_interval_creates_no_timer()
{
var clock = new RecordingTimeProvider();
var log = new CapturingLogger();
using var _ = new AclRevalidationTimer(() => { }, TimeSpan.Zero, clock, log);
clock.Created.ShouldBeEmpty();
log.Lines.ShouldContain(l => l.Contains("RevalidationIntervalSeconds", StringComparison.Ordinal));
}
/// <summary>
/// A pass that throws must not escape the tick.
/// </summary>
/// <remarks>
/// An unhandled exception on a timer callback thread takes the process down. The next tick is the
/// recovery, so the fault is logged and the timer keeps running.
/// </remarks>
[Fact]
public void A_throwing_pass_does_not_escape_the_tick()
{
var log = new CapturingLogger();
using var timer = new AclRevalidationTimer(
() => throw new InvalidOperationException("pass blew up"),
TimeSpan.FromSeconds(30),
new RecordingTimeProvider(),
log);
Should.NotThrow(timer.Tick);
log.Lines.ShouldContain(l => l.Contains("revalidation", StringComparison.OrdinalIgnoreCase));
}
/// <summary>A tick runs the pass — the control for the two "nothing happened" cases above.</summary>
[Fact]
public void A_tick_runs_the_pass()
{
var runs = 0;
using var timer = new AclRevalidationTimer(
() => runs++, TimeSpan.FromSeconds(30), new RecordingTimeProvider(), NullLogger.Instance);
timer.Tick();
timer.Tick();
runs.ShouldBe(2);
}
/// <summary>Disposing disposes the underlying timer and stops further ticks.</summary>
[Fact]
public void Dispose_stops_further_ticks()
{
var clock = new RecordingTimeProvider();
var runs = 0;
var timer = new AclRevalidationTimer(
() => runs++, TimeSpan.FromSeconds(30), clock, NullLogger.Instance);
timer.Dispose();
clock.Created[0].Disposed.ShouldBeTrue();
timer.Tick();
runs.ShouldBe(0, "a disposed timer must not run the pass again");
}
// ---------- helpers ----------
/// <summary>
/// A <see cref="TimeProvider"/> that records the timers asked of it instead of arming real ones.
/// </summary>
private sealed class RecordingTimeProvider : TimeProvider
{
/// <summary>Every timer this provider was asked to create, in order.</summary>
public List<FakeTimer> Created { get; } = [];
/// <inheritdoc />
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new FakeTimer(dueTime, period);
Created.Add(timer);
return timer;
}
/// <summary>A timer that does nothing but remember how it was asked for, and whether it was disposed.</summary>
internal sealed class FakeTimer(TimeSpan dueTime, TimeSpan period) : ITimer
{
public TimeSpan DueTime { get; } = dueTime;
public TimeSpan Period { get; } = period;
public bool Disposed { get; private set; }
public bool Change(TimeSpan due, TimeSpan p) => true;
public void Dispose() => Disposed = true;
public ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
}
/// <summary>Collects rendered log messages so a "said so once" claim can be asserted.</summary>
private sealed class CapturingLogger : ILogger
{
public List<string> Lines { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) => Lines.Add(formatter(state, exception));
}
}
@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Guards the role-conditional data-plane authentication wiring (arch-review 03/S18). A decorator
/// registered in one branch of a role conditional is the "built but never wired" shape, so the branch
/// each node role lands in is asserted rather than assumed.
/// </summary>
public sealed class ApiKeyStoreRegistrationTests
{
/// <summary>
/// The authorizer's clock and the registered clock must be the SAME instance.
/// </summary>
/// <remarks>
/// <see cref="DataPlaneAuthorizer"/> already documents that a divergence between two clocks denies
/// every request with a verdict indistinguishable from "no grant matched" — found once by a test
/// clock two hours ahead. One registered singleton threaded into every consumer makes the
/// divergence unrepresentable instead of merely unlikely, so the registration is what is pinned.
/// </remarks>
[Fact]
public void The_authorizer_and_the_key_store_share_one_TimeProvider()
{
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
var registered = sp.GetRequiredService<TimeProvider>();
var authorizer = sp.GetRequiredService<DataPlaneAuthorizer>();
registered.ShouldBeSameAs(Probe.Instance);
authorizer.Clock.ShouldBeSameAs(registered);
}
/// <summary>
/// An admin (or fused admin+driver) node's key store is the <see cref="BoundedApiKeyStore"/>
/// decorator, wrapping the ConfigDb store.
/// </summary>
/// <remarks>
/// The decorator lives in ONE branch of a role conditional, which is the "built but never wired"
/// shape OVERALL theme 3 names. Unwired, every unit test in <c>BoundedApiKeyStoreTests</c> still
/// passes and the deadline protects nothing.
/// </remarks>
[Fact]
public void An_admin_node_wraps_the_ConfigDb_store_in_the_bound()
{
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
var store = sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<BoundedApiKeyStore>();
store.Inner.ShouldBeOfType<ConfigDbApiKeyStore>();
store.Clock.ShouldBeSameAs(sp.GetRequiredService<TimeProvider>());
}
/// <summary>
/// A driver-only node's key store is the bare artifact store — deliberately NOT wrapped.
/// </summary>
/// <remarks>
/// It is two dictionary probes on an in-memory snapshot, so it can neither hang nor be worth
/// caching; wrapping it would add a deadline to a call that cannot miss one and a second copy of
/// data the snapshot already holds.
/// </remarks>
[Fact]
public void A_driver_only_node_gets_the_bare_artifact_store()
{
using var sp = BuildNode(hasAdmin: false, hasDriver: true);
sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<ArtifactApiKeyStore>();
}
/// <summary>
/// A node carrying neither role registers the deny-all authenticator <b>explicitly</b>, and no key
/// store at all.
/// </summary>
/// <remarks>
/// Explicitly, not by absence: <c>OtOpcUaServerHostedService</c> takes
/// <see cref="IOpcUaUserAuthenticator"/> as a required constructor parameter, and the built-in
/// container does not fall back to a default for an unregistered service — it throws, and the node
/// does not start. The assertion is on the singleton INSTANCE, so a future registration that
/// happened to resolve some other deny-all type still surfaces here.
/// </remarks>
[Fact]
public void A_node_with_neither_role_registers_the_deny_all_authenticator()
{
using var sp = BuildNode(hasAdmin: false, hasDriver: false);
sp.GetRequiredService<IOpcUaUserAuthenticator>().ShouldBeSameAs(NullOpcUaUserAuthenticator.Instance);
sp.GetService<IApiKeyStore>().ShouldBeNull("a node that serves no data plane has no keys to read");
}
/// <summary>Builds the data-plane authentication wiring for one node role combination.</summary>
/// <param name="hasAdmin">Whether the node carries the <c>admin</c> role.</param>
/// <param name="hasDriver">Whether the node carries the <c>driver</c> role.</param>
/// <returns>The built provider; the caller disposes it.</returns>
internal static ServiceProvider BuildNode(bool hasAdmin, bool hasDriver)
{
var services = new ServiceCollection();
services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(Probe.Instance);
services.Configure<ApiKeyOptions>(_ => { });
services.Configure<AclOptions>(_ => { });
services.Configure<AkkaClusterOptions>(o => o.Roles = ["admin", "driver", "cluster-MAIN"]);
services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
services.AddSingleton<PermissionTrieCache>();
services.AddSingleton<DataPlaneAuthorityState>();
if (hasAdmin)
{
services.AddDbContextFactory<OtOpcUaConfigDbContext>(
o => o.UseInMemoryDatabase($"apikey-reg-{Guid.NewGuid():N}"));
}
services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
return services.BuildServiceProvider();
}
/// <summary>A clock distinguishable from <see cref="TimeProvider.System"/> by reference.</summary>
private sealed class Probe : TimeProvider
{
public static readonly Probe Instance = new();
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
}
}
@@ -0,0 +1,533 @@
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Covers <see cref="BoundedApiKeyStore"/> — the deadline and TTL cache the OPC UA impersonation
/// callback's key lookup runs under (arch-review 03/S18).
/// </summary>
/// <remarks>
/// <para>
/// The deadline cases are driven through the <b>real</b> <see cref="ApiKeyOpcUaUserAuthenticator"/>
/// rather than the store directly, because the fail-closed conversion — a throw becoming the
/// standard opaque denial — is part of what is being asserted, and it lives in the authenticator's
/// catch-all rather than in the decorator.
/// </para>
/// <para>
/// ⚠️ <b>Deadline cases use a REAL short timeout; TTL cases use the fake clock.</b> The hand-rolled
/// <see cref="FakeClock"/> overrides <c>GetUtcNow()</c> only, so <c>CreateTimer</c> falls through to
/// the base implementation and produces a real system timer — advancing the fake clock does not
/// fire a <c>CancelAfter</c>. A deadline test "improved" to advance the fake clock would pass for
/// the wrong reason: the inner task simply never completing.
/// </para>
/// </remarks>
public sealed class BoundedApiKeyStoreTests
{
private const string Pepper = "unit-test-pepper";
private const string Principal = "scadabridge-dcl";
/// <summary>
/// A store that never answers must not hold the activation open: the deadline fires and the
/// activation is denied.
/// </summary>
/// <remarks>
/// The SDK raises impersonation under a <c>SessionManager</c>-wide lock, so a lookup that does not
/// return stalls every session activation in the server — not just this one. That is the whole of
/// 03/S18, and it is why the bound is a deadline rather than a cache.
/// </remarks>
[Fact]
public async Task A_blocked_store_denies_without_hanging_the_activation()
{
var (presented, inner) = SetUp();
inner.BlockForever = true;
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(5));
result.Success.ShouldBeFalse();
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
/// <summary>
/// The positive control for <see cref="A_blocked_store_denies_without_hanging_the_activation"/>:
/// the same fake, answering normally, still authenticates.
/// </summary>
/// <remarks>
/// Without it a build whose decorator denied <b>everything</b> would pass every deadline case in
/// this class, and the bound would look correct while having locked the fleet out.
/// </remarks>
[Fact]
public async Task A_reachable_store_still_authenticates()
{
var (presented, inner) = SetUp();
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
result.Success.ShouldBeTrue();
result.Principal.ShouldBe(Principal);
}
/// <summary>
/// A lookup that completes with a perfectly valid key <b>after</b> the deadline still denies —
/// nothing races back in past the bound.
/// </summary>
[Fact]
public async Task A_deadline_never_admits()
{
var (presented, inner) = SetUp();
var gate = new TaskCompletionSource();
inner.Gate = gate.Task;
var auth = Auth(Bounded(inner, timeoutMs: 100, cacheSeconds: 0));
var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
// Let the deadline pass, THEN hand the inner store its (valid) answer.
await Task.Delay(400);
gate.SetResult();
var result = await pending.WaitAsync(TimeSpan.FromSeconds(5));
result.Success.ShouldBeFalse();
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
/// <summary>
/// A non-positive <c>StoreTimeoutMs</c> disables the deadline, restoring the unbounded behaviour
/// byte-for-byte — the documented rollback.
/// </summary>
[Fact]
public async Task A_zero_timeout_disables_the_deadline()
{
var (presented, inner) = SetUp();
var gate = new TaskCompletionSource();
inner.Gate = gate.Task;
var auth = Auth(Bounded(inner, timeoutMs: 0, cacheSeconds: 0));
var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
// Far longer than any deadline this class configures; an active bound would have denied by now.
await Task.Delay(300);
pending.IsCompleted.ShouldBeFalse("a disabled deadline must not cancel a slow lookup");
gate.SetResult();
(await pending.WaitAsync(TimeSpan.FromSeconds(5))).Success.ShouldBeTrue();
}
/// <summary>The anonymous activation path gets the same deadline, and the same denial.</summary>
/// <remarks>
/// It runs on every unauthenticated connect, so leaving it unbounded would leave the
/// server-wide-stall hazard fully open on the busiest activation path.
/// </remarks>
[Fact]
public async Task Anonymous_grant_resolution_is_bounded_too()
{
var (_, inner) = SetUp();
inner.BlockForever = true;
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
var result = await auth.AuthenticateAnonymousAsync(CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(5));
result.Success.ShouldBeFalse();
}
// ---------- TTL read-through cache ----------
/// <summary>A second activation inside the TTL is served from the cache and never reaches the store.</summary>
[Fact]
public async Task A_second_activation_within_the_TTL_does_not_reach_the_store()
{
var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
clock.Advance(TimeSpan.FromSeconds(2));
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
inner.Calls.ShouldBe(1);
}
/// <summary>
/// FALSIFICATION CONTROL. A key revoked in the store is denied once the TTL has passed — the
/// cache expires rather than merely existing.
/// </summary>
/// <remarks>
/// A cache with no expiry at all passes every other case in this class: the hit test, the
/// read-through test and the fault test are all satisfied by "store once, serve forever". This is
/// the one that makes <c>StoreCacheSeconds</c> a bound rather than a claim.
/// </remarks>
[Fact]
public async Task A_revoked_key_is_denied_once_the_TTL_has_passed()
{
var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default))
.ShouldNotBeNull("non-vacuity: the key really was usable before the revocation");
inner.Revoked = true;
// Still inside the TTL — the documented, accepted staleness window.
clock.Advance(TimeSpan.FromSeconds(3));
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
// Past it — the store is re-read and the revocation lands.
clock.Advance(TimeSpan.FromSeconds(3));
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.Calls.ShouldBe(2);
}
/// <summary>A non-positive TTL disables the cache: every call reads through, today's behaviour.</summary>
[Fact]
public async Task TTL_zero_reads_through_every_time()
{
var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 0, clock);
for (var i = 0; i < 3; i++)
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
inner.Calls.ShouldBe(3);
}
/// <summary>
/// A fault is never cached: the next call reaches the store, so an outage cannot outlive itself.
/// </summary>
/// <remarks>
/// Caching a deny would be fail-closed and is still wrong — it would go on denying valid keys for
/// a further TTL after the database came back.
/// </remarks>
[Fact]
public async Task A_faulted_store_is_not_cached()
{
var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock);
inner.ThrowsRemaining = 1;
await Should.ThrowAsync<InvalidOperationException>(
() => sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default));
// Same instant — a cached fault would still be "fresh" here, so the clock cannot rescue this.
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
inner.Calls.ShouldBe(2);
}
/// <summary>The anonymous grant lookup is cached on the same terms, keyed by principal.</summary>
[Fact]
public async Task Anonymous_grants_are_cached_too()
{
var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
inner.Calls.ShouldBe(1);
clock.Advance(TimeSpan.FromSeconds(6));
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
inner.Calls.ShouldBe(2);
}
/// <summary>
/// Past the entry ceiling the cache clears wholesale and keeps answering — a key-id guessing flood
/// is a bounded memory event, not an unbounded one.
/// </summary>
/// <remarks>
/// Wholesale rather than per-entry eviction for the reason <c>SessionAclDecisionCache</c> already
/// documents: choosing WHICH entry to drop needs knowledge the cache does not have, and a
/// repopulate is cheap and cannot be wrong.
/// </remarks>
[Fact]
public async Task Cache_overflow_clears_wholesale_and_keeps_serving()
{
var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock);
// Seed the real key, then flood past the ceiling with ids that do not exist.
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
for (var i = 0; i < BoundedApiKeyStore.MaxCacheEntries + 1; i++)
await sut.FindAsync($"ghost-{i}", clock.GetUtcNow().UtcDateTime, default);
var callsAfterFlood = inner.Calls;
// Still correct after the clear — and the real key was evicted, so it is re-read rather than
// served from a cache the flood was supposed to have emptied.
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
inner.Calls.ShouldBe(callsAfterFlood + 1);
}
/// <summary>
/// A cached key whose <c>ExpiresUtc</c> passes <b>inside</b> the TTL is not served: the store is
/// re-read instead.
/// </summary>
/// <remarks>
/// <para>
/// Without this the TTL would resurrect an expired key for up to <c>StoreCacheSeconds</c> —
/// the exact axis 03/S19 exists to close, re-opened by 03/S18's own cache. The cache key is
/// <c>apiKeyId</c> alone (<c>utcNow</c> changes every call and would defeat the cache), so the
/// clock check has to happen on the way OUT of the cache.
/// </para>
/// <para>
/// The inner store is flipped to <c>Revoked</c> first, so "served from cache" and "re-read"
/// give different ANSWERS rather than only different call counts — a build that re-read and
/// then ignored the result could not pass.
/// </para>
/// </remarks>
[Fact]
public async Task A_cached_key_that_expires_by_the_clock_is_not_served()
{
var start = new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
var (_, inner) = SetUp(expiresUtc: start.UtcDateTime.AddSeconds(2));
var clock = new FakeClock(start);
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock);
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default))
.ShouldNotBeNull("non-vacuity: the key was usable and IS now cached");
inner.Revoked = true;
// Past ExpiresUtc (+2 s) but far inside the 60 s TTL — the entry is still "fresh" to the cache.
clock.Advance(TimeSpan.FromSeconds(5));
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.Calls.ShouldBe(2, "the expired entry must be re-read, not served");
}
// ---------- metering ----------
/// <summary>A deadline that fires meters <c>outcome=timeout</c> — the alarm signal for a sick ConfigDb.</summary>
[Fact]
public async Task A_timeout_meters_outcome_timeout()
{
var (_, inner) = SetUp();
inner.BlockForever = true;
using var meter = new MeterRecorder();
var sut = Bounded(inner, timeoutMs: 100, cacheSeconds: 0);
await Should.ThrowAsync<OperationCanceledException>(
() => sut.FindAsync(inner.KeyId, DateTime.UtcNow, default).WaitAsync(TimeSpan.FromSeconds(5)));
meter.WithOutcome("timeout").ShouldBe(1);
meter.WithOutcome("cache_hit").ShouldBe(0);
}
/// <summary>
/// A cache hit meters <c>outcome=cache_hit</c> — the ratio that tells an operator the TTL is
/// doing something. A missing stream on a busy fused central is how you notice
/// <c>StoreCacheSeconds</c> was set to 0 by accident.
/// </summary>
[Fact]
public async Task A_cache_hit_meters_outcome_cache_hit()
{
var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
using var meter = new MeterRecorder();
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
meter.WithOutcome("cache_hit").ShouldBe(0, "the first call is a miss");
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
meter.WithOutcome("cache_hit").ShouldBe(1);
}
/// <summary>A plain read-through — no deadline hit, no cache hit — meters nothing at all.</summary>
/// <remarks>
/// The counter names lookups the bound INTERVENED on. Metering every lookup would bury the two
/// outcomes that matter under the ordinary traffic they have to be spotted in.
/// </remarks>
[Fact]
public async Task A_plain_read_through_meters_neither()
{
var (_, inner) = SetUp();
using var meter = new MeterRecorder();
var sut = Bounded(inner, timeoutMs: 2000, cacheSeconds: 0);
await sut.FindAsync(inner.KeyId, DateTime.UtcNow, default);
meter.Total.ShouldBe(0);
}
// ---------- helpers ----------
private static BoundedApiKeyStore Bounded(
IApiKeyStore inner, int timeoutMs, int cacheSeconds, TimeProvider? clock = null) => new(
inner,
Options.Create(new ApiKeyOptions
{
Username = "apikey",
Pepper = Pepper,
StoreTimeoutMs = timeoutMs,
StoreCacheSeconds = cacheSeconds,
}),
NullLogger<BoundedApiKeyStore>.Instance,
clock ?? TimeProvider.System);
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
private static (string Presented, GatedApiKeyStore Store) SetUp(DateTime? expiresUtc = null)
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
{
ApiKeyId = apiKeyId,
Name = "test key",
SecretHash = hash,
Principal = Principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
ExpiresUtc = expiresUtc,
};
return (presented, new GatedApiKeyStore(key));
}
/// <summary>
/// An inner store whose answer can be withheld — a hang the EF in-memory provider cannot
/// represent — and whose calls are counted.
/// </summary>
private sealed class GatedApiKeyStore(ApiKey key) : IApiKeyStore
{
/// <summary>The id of the key this store holds.</summary>
public string KeyId => key.ApiKeyId;
/// <summary>When set, every call awaits this before answering.</summary>
public Task? Gate { get; set; }
/// <summary>When true, no call ever completes.</summary>
public bool BlockForever { get; set; }
/// <summary>How many calls reached this store (i.e. were not served from the cache).</summary>
public int Calls => _calls;
/// <summary>When true, the key lookup answers "no usable key" — a revocation.</summary>
public bool Revoked { get; set; }
/// <summary>Number of leading calls that throw before the store starts answering.</summary>
public int ThrowsRemaining { get; set; }
private int _calls;
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
await WaitAsync(ct).ConfigureAwait(false);
if (Revoked) return null;
return string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
? new ApiKeyLookup(key, [])
: null;
}
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
{
await WaitAsync(ct).ConfigureAwait(false);
return [];
}
private async Task WaitAsync(CancellationToken ct)
{
Interlocked.Increment(ref _calls);
if (ThrowsRemaining > 0)
{
ThrowsRemaining--;
throw new InvalidOperationException("store is down");
}
if (BlockForever) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
if (Gate is { } gate) await gate.WaitAsync(ct).ConfigureAwait(false);
}
}
/// <summary>Collects <c>otopcua.opcua.apikey_store.bounded</c> measurements by their outcome tag.</summary>
/// <remarks>
/// ⚠️ Filtered by instrument name only, which is normally the metrics-test leak (a listener sees
/// a PARALLEL test's measurements too). It is safe HERE for two checkable reasons:
/// <see cref="BoundedApiKeyStore"/> is the only thing in the product that writes this instrument,
/// and this assembly declares <c>CollectionBehavior(DisableTestParallelization = true)</c> in
/// <c>AssemblyInfo.cs</c>, so there is no parallel test to leak from. Removing that attribute
/// means adding a discriminating tag here.
/// </remarks>
private sealed class MeterRecorder : IDisposable
{
private readonly MeterListener _listener;
private readonly List<KeyValuePair<string, object?>[]> _tagSets = [];
private readonly Lock _gate = new();
private long _total;
public MeterRecorder()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName
&& instrument.Name == "otopcua.opcua.apikey_store.bounded")
{
listener.EnableMeasurementEvents(instrument);
}
},
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
lock (_gate)
{
_total += value;
_tagSets.Add(tags.ToArray());
}
});
_listener.Start();
}
/// <summary>Every measurement seen, regardless of tags.</summary>
public long Total
{
get { lock (_gate) return _total; }
}
/// <summary>How many measurements carried <c>outcome=<paramref name="outcome"/></c>.</summary>
/// <param name="outcome">The outcome tag value to count.</param>
/// <returns>The measurement count.</returns>
public int WithOutcome(string outcome)
{
lock (_gate)
return _tagSets.Count(set => set.Any(t => t.Key == "outcome" && Equals(t.Value, outcome)));
}
/// <inheritdoc />
public void Dispose() => _listener.Dispose();
}
/// <summary>
/// A hand-advanced clock. Overrides <c>GetUtcNow()</c> only — see the class remarks for why that
/// confines it to the TTL cases.
/// </summary>
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _now = utcNow;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the clock forward.</summary>
/// <param name="by">How far forward to move.</param>
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}
@@ -0,0 +1,144 @@
using Akka.Actor;
using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Pins the ACL revalidation timer's lifecycle on the hosted service (arch-review 03/S19) — that it is
/// armed at the configured interval, that <c>0</c> arms nothing, and that stopping disposes it.
/// </summary>
/// <remarks>
/// The timer is armed through <see cref="OtOpcUaServerHostedService.StartRevalidationTimer"/> rather
/// than by running <c>StartAsync</c>, because <c>StartAsync</c> boots the real OPC UA SDK (certificate
/// store, listening socket) and none of that is what this is about. Everything the timer's lifecycle
/// touches — the options, the clock, the disposal — is exercised exactly as production reaches it.
/// </remarks>
public sealed class OtOpcUaServerHostedServiceRevalidationTests
{
/// <summary>The configured interval is what the timer is armed at.</summary>
[Fact]
public void A_configured_interval_arms_one_timer_at_that_period()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 30);
sut.StartRevalidationTimer();
clock.Periods.ShouldBe([TimeSpan.FromSeconds(30)]);
}
/// <summary>
/// <c>RevalidationIntervalSeconds = 0</c> arms nothing — the documented rollback to
/// deploy-triggered-only revalidation.
/// </summary>
[Fact]
public void A_zero_interval_arms_nothing()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 0);
sut.StartRevalidationTimer();
clock.Periods.ShouldBeEmpty();
}
/// <summary>
/// <c>StopAsync</c> disposes the timer, so no tick can land on a node manager that is being torn
/// down.
/// </summary>
/// <remarks>
/// The disposal is the FIRST thing <c>StopAsync</c> does, ahead of
/// <c>SetDataPlaneAuthority(null)</c> / <c>SetDataPlaneAuthorizer(null)</c> — a stopping node sheds
/// the trigger before it sheds the thing the trigger drives.
/// </remarks>
[Fact]
public async Task StopAsync_disposes_the_timer()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 30);
sut.StartRevalidationTimer();
clock.Timers[0].Disposed.ShouldBeFalse("non-vacuity: the timer is live before the stop");
await sut.StopAsync(CancellationToken.None);
clock.Timers[0].Disposed.ShouldBeTrue();
}
/// <summary>Disposing the service disposes the timer too, for a host that never called StopAsync.</summary>
[Fact]
public async Task DisposeAsync_disposes_the_timer()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 30);
sut.StartRevalidationTimer();
await sut.DisposeAsync();
clock.Timers[0].Disposed.ShouldBeTrue();
}
// ---------- helpers ----------
private static OtOpcUaServerHostedService Service(TimeProvider clock, int intervalSeconds) => new(
Options.Create(new OpcUaApplicationHostOptions()),
new DeferredAddressSpaceSink(),
new DeferredServiceLevelPublisher(),
NullOpcUaUserAuthenticator.Instance,
NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance,
new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no ActorSystem is needed for the timer lifecycle"),
new ActorRegistry(),
dataPlaneAuthorizer: null,
Options.Create(new AclOptions { RevalidationIntervalSeconds = intervalSeconds }),
clock,
NullLoggerFactory.Instance);
/// <summary>Records the timers asked of it instead of arming real ones.</summary>
private sealed class RecordingTimeProvider : TimeProvider
{
/// <summary>Every timer this provider was asked to create, in order.</summary>
public List<FakeTimer> Timers { get; } = [];
/// <summary>The period each of those timers was armed at.</summary>
public IReadOnlyList<TimeSpan> Periods => [.. Timers.Select(t => t.Period)];
/// <inheritdoc />
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new FakeTimer(period);
Timers.Add(timer);
return timer;
}
/// <summary>A timer that remembers its period and whether it was disposed.</summary>
internal sealed class FakeTimer(TimeSpan period) : ITimer
{
public TimeSpan Period { get; } = period;
public bool Disposed { get; private set; }
public bool Change(TimeSpan dueTime, TimeSpan p) => true;
public void Dispose() => Disposed = true;
public ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
}
}
@@ -0,0 +1,370 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// arch-review 03/S19 over the wire — a key that expires by the CLOCK stops a live subscription, with
/// no deployment anywhere in the run.
/// </summary>
/// <remarks>
/// <para>
/// This cannot be reasoned into existence for the same reason
/// <c>AclSubscriptionEnforcementTests</c> gives: whether a monitored item can recover the identity
/// of the session that created it is an assumption about SDK behaviour. Here it must recover an
/// identity carrying an <b>API key id</b> — the field the per-call expiry re-check keys on — and
/// the existing rig has never exercised one, because it connects anonymously.
/// </para>
/// <para>
/// The tick is driven through the real <see cref="AclRevalidationTimer"/> rather than by calling
/// the node manager's pass directly, so what is asserted here is the whole T8 path: revert the
/// host's timer and the denial below cannot be produced by anything except a deployment.
/// </para>
/// </remarks>
public sealed class AclKeyExpiryEnforcementTests
{
private const string Cluster = "SITE-A";
private const string Device = "Plant/Modbus/dev1";
private const string Tag = "Plant/Modbus/dev1/Speed";
private const string Principal = "expiry-probe";
private const string KeyId = "k-expiry";
private static readonly DateTime Start = new(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// THE test this file exists for: the key's <c>ExpiresUtc</c> passes, a revalidation tick runs, and
/// the client is told <c>BadUserAccessDenied</c> — with <c>DataPlaneAuthorityState.Apply</c> called
/// exactly once, in setup.
/// </summary>
[Fact]
public async Task An_expired_key_stops_a_live_subscription_with_no_deploy()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclKeyExpiry", ct);
try
{
rig.Enforce(expiresUtc: Start.AddMinutes(10));
rig.Materialise();
rig.Write(1);
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
// Baseline: the key-carrying session's feed is live.
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
// Only the clock moves. No deployment, no new generation, no grant change.
rig.Advance(TimeSpan.FromMinutes(20));
rig.TickRevalidation();
await WaitUntilAsync(
() => received.Any(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied),
TimeSpan.FromSeconds(10));
// And the data really has stopped.
received.Clear();
rig.Write(2);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
received.ShouldNotContain(v => Equals(v.Value, 2f));
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// The negative control: identical run, clock advanced but not past <c>ExpiresUtc</c> ⇒ values keep
/// arriving.
/// </summary>
/// <remarks>
/// Without it the test above also passes on a build whose tick detaches every item it sees, which
/// would silence every subscription in the fleet once a minute.
/// </remarks>
[Fact]
public async Task An_unexpired_key_keeps_streaming_across_a_tick()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclKeyLive", ct);
try
{
rig.Enforce(expiresUtc: Start.AddHours(4));
rig.Materialise();
rig.Write(1);
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
rig.Advance(TimeSpan.FromMinutes(20));
rig.TickRevalidation();
rig.Write(2);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue("condition not met within timeout");
}
/// <summary>A booted server, a key-carrying client session, and a hand-driven revalidation tick.</summary>
private sealed class Rig : IAsyncDisposable
{
private readonly string _pkiRoot;
private readonly FakeClock _clock = new(Start);
private OpcUaApplicationHost _host = null!;
private OtOpcUaSdkServer _server = null!;
private AclRevalidationTimer? _timer;
private int _port;
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
private OtOpcUaNodeManager Nm => _server.NodeManager!;
public static async Task<Rig> StartAsync(string name, CancellationToken ct)
{
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
rig._port = AllocateFreePort();
rig._server = new OtOpcUaSdkServer();
rig._host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = $"OtOpcUa.{name}",
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
OpcUaPort = rig._port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = rig._pkiRoot,
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
AutoAcceptUntrustedClientCertificates = true,
},
NullLogger<OpcUaApplicationHost>.Instance,
new KeyPresentingAuthenticator());
await rig._host.StartAsync(rig._server, ct);
return rig;
}
/// <summary>
/// Switches enforcement on with one cluster-wide grant and one key expiring at
/// <paramref name="expiresUtc"/>, and arms the revalidation timer over the same clock.
/// </summary>
/// <param name="expiresUtc">When the key stops being usable.</param>
public void Enforce(DateTime expiresUtc)
{
var key = new ApiKey
{
ApiKeyId = KeyId,
Name = KeyId,
SecretHash = "hash",
Principal = Principal,
Enabled = true,
CreatedUtc = Start.AddDays(-1),
ExpiresUtc = expiresUtc,
};
var grant = new NodeAcl
{
NodeAclId = "acl-cluster",
ClusterId = Cluster,
Principal = Principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
};
var state = new DataPlaneAuthorityState();
// The ONLY Apply in the whole test. Everything after this moves the clock.
state.Apply(Cluster, "rev-1", [key], [grant], NoScopes);
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock);
// A long interval: the tick is driven by hand, so a real timer firing underneath would only
// make the test non-deterministic. What matters is that the pass is reached through the same
// component the hosted service arms.
_timer = new AclRevalidationTimer(
() => _server.NodeManager?.RevalidateMonitoredItems(),
TimeSpan.FromHours(1),
TimeProvider.System,
NullLogger.Instance);
}
/// <summary>Moves the authorizer's clock forward — the only thing that changes mid-test.</summary>
/// <param name="by">How far forward.</param>
public void Advance(TimeSpan by) => _clock.Advance(by);
/// <summary>Runs one revalidation tick through the host's own timer component.</summary>
public void TickRevalidation() => _timer!.Tick();
public void Materialise()
{
Nm.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw);
Nm.EnsureFolder("Plant/Modbus", "Plant", "Modbus", AddressSpaceRealm.Raw);
Nm.EnsureFolder(Device, "Plant/Modbus", "dev1", AddressSpaceRealm.Raw);
Nm.EnsureVariable(Tag, Device, "Speed", "Float", writable: false, AddressSpaceRealm.Raw);
}
public void Write(float value) =>
Nm.WriteValue(Tag, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
public ushort RawNamespace(ISession session)
{
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
ns.ShouldBeGreaterThan((ushort)0);
return ns;
}
public NodeId TagNodeId(ISession session) => new(Tag, RawNamespace(session));
public async Task<(Subscription Subscription, List<DataValue> Received)> SubscribeAsync(
ISession session, CancellationToken ct)
{
var received = new List<DataValue>();
var gate = new object();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = TagNodeId(session),
AttributeId = Attributes.Value,
SamplingInterval = 50,
QueueSize = 10,
};
item.Notification += (mi, _) =>
{
foreach (var v in mi.DequeueValues()) lock (gate) received.Add(v);
};
subscription.AddItem(item);
await subscription.ApplyChangesAsync(ct);
item.Status.Error.ShouldBeNull();
return (subscription, received);
}
/// <summary>Connects presenting the fixed <c>apikey</c> username with an <c>otua_</c> credential.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>The connected session.</returns>
public async Task<ISession> ConnectAsync(CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.AclKeyClient",
ApplicationUri = $"urn:OtOpcUa.AclKeyClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "AclKeyExpiryEnforcementTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new UserNameIdentityToken
{
UserName = "apikey",
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
}),
preferredLocales: null,
ct: ct);
}
public async ValueTask DisposeAsync()
{
_timer?.Dispose();
await _host.DisposeAsync();
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// Admits the fixed <c>apikey</c> username as <see cref="Principal"/>, carrying
/// <see cref="KeyId"/>.
/// </summary>
/// <remarks>
/// Deliberately not the production <c>ApiKeyOpcUaUserAuthenticator</c> over a fake store: the
/// credential-verification path (parse, peppered HMAC, store lookup, deny shapes) is covered by
/// <c>ApiKeyAuthenticationTests</c> and <c>BoundedApiKeyStoreTests</c> in the Host suite, and
/// none of it changes what this file is about — whether the SDK hands a monitored item back an
/// identity that still carries the key id. Grants come from the deployed snapshot, so this
/// returns no roles.
/// </remarks>
private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator
{
/// <inheritdoc />
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
string username, string password, CancellationToken ct) =>
Task.FromResult(
string.Equals(username, "apikey", StringComparison.Ordinal)
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId)
: OpcUaUserAuthResult.Deny("Invalid API key."));
}
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
private sealed class FakeClock(DateTime utcNow) : TimeProvider
{
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the clock forward.</summary>
/// <param name="by">How far forward.</param>
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}
}
@@ -25,6 +25,10 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
<!-- AclKeyExpiryEnforcementTests drives the real AclRevalidationTimer (arch-review 03/S19), so the
over-the-wire denial is produced by the same component the hosted service arms rather than by
a direct call to the node manager's pass. -->
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
</ItemGroup>
</Project>
@@ -615,8 +615,156 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A key that expires by the CLOCK stands its live monitored item down, with no deployment
/// anywhere in the test (arch-review 03/S19).
/// </summary>
/// <remarks>
/// <para>
/// The first pass is the falsification control, and it is not optional: without it a build
/// whose pass detached everything it saw — unconditionally, or because the authorizer denied
/// for some unrelated reason — would pass the second half and prove nothing about expiry.
/// </para>
/// <para>
/// <c>DataPlaneAuthorityState.Apply</c> is called exactly once here, in setup. Nothing
/// advances the generation between the two passes, so the only thing that changed is the
/// clock — which is the whole claim.
/// </para>
/// </remarks>
[Fact]
public async Task An_expired_key_stands_down_a_live_item_with_no_deploy()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
nm.Authorizer = KeyedSubscribeAuthorizer(
clock, out _, expiresUtc: new DateTime(2026, 8, 20, 12, 10, 0, DateTimeKind.Utc));
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(0, "the key is still valid, so nothing may stand down");
clock.Advance(TimeSpan.FromMinutes(20));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(1);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A key renewed with a later expiry re-attaches its item on the next pass — the clock axis
/// inherits the deploy axis's reversibility.
/// </summary>
/// <remarks>
/// The restore is asserted by standing the item down a SECOND time: the pass skips an item that is
/// already suppressed, so a second denial can only be queued if the item was genuinely restored
/// in between. That is why this pins the design decision not to close the session — a client that
/// renews its key recovers with no action of its own.
/// </remarks>
[Fact]
public async Task A_renewed_key_re_attaches_the_item_on_the_next_pass()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
nm.Authorizer = KeyedSubscribeAuthorizer(
clock, out var state, expiresUtc: new DateTime(2026, 8, 20, 12, 10, 0, DateTimeKind.Utc));
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
clock.Advance(TimeSpan.FromMinutes(20));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(1, "non-vacuity: the item really was stood down before the renewal");
// Renew: same key id, a later expiry.
state.Apply(
Cluster, "rev-2",
[Key(new DateTime(2026, 8, 20, 14, 0, 0, DateTimeKind.Utc))],
[GrantOn(NodePermissions.Subscribe)],
Scopes());
nm.RevalidateMonitoredItems();
// Expire it again. A still-suppressed item is skipped, so a second denial proves the restore.
clock.Advance(TimeSpan.FromHours(3));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(2);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A clock that moves but does not reach the expiry stands nothing down.</summary>
[Fact]
public async Task An_unexpired_key_is_untouched_by_a_clock_only_pass()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
nm.Authorizer = KeyedSubscribeAuthorizer(
clock, out _, expiresUtc: new DateTime(2026, 8, 20, 13, 0, 0, DateTimeKind.Utc));
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
clock.Advance(TimeSpan.FromMinutes(30));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(0);
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
/// <summary>The public id of the key the clock-driven expiry cases authenticate with.</summary>
private const string ExpiringKeyId = "k-expiry";
/// <summary>
/// A cluster-wide Subscribe grant PLUS a key that expires at <paramref name="expiresUtc"/>, so the
/// verdict moves with the clock rather than with a deployment.
/// </summary>
/// <param name="clock">The authorizer's clock — the one <c>Evaluate</c> checks expiry against.</param>
/// <param name="state">The authority state, so a test can renew the key without a second helper.</param>
/// <param name="expiresUtc">When the key stops being usable.</param>
/// <returns>The gate, enforcing.</returns>
private static DataPlaneAuthorizer KeyedSubscribeAuthorizer(
TimeProvider clock, out DataPlaneAuthorityState state, DateTime expiresUtc)
{
state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [Key(expiresUtc)], [GrantOn(NodePermissions.Subscribe)], Scopes());
return new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
}
/// <summary>The expiring key, at whatever expiry a case needs.</summary>
/// <param name="expiresUtc">When the key stops being usable.</param>
/// <returns>The key row.</returns>
private static ApiKey Key(DateTime expiresUtc) => new()
{
ApiKeyId = ExpiringKeyId,
Name = ExpiringKeyId,
SecretHash = "hash",
Principal = Principal,
Enabled = true,
CreatedUtc = new DateTime(2026, 8, 20, 0, 0, 0, DateTimeKind.Utc),
ExpiresUtc = expiresUtc,
};
/// <summary>
/// An identity carrying the key id, which is what makes the per-call expiry re-check apply — an
/// identity with no <c>ApiKeyId</c> skips it entirely.
/// </summary>
/// <returns>The identity.</returns>
private static RoleCarryingUserIdentity KeyedIdentity() =>
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, ExpiringKeyId);
/// <summary>A cluster-wide Subscribe grant — what <c>CreateMonitoredItems</c> resolves to.</summary>
private static DataPlaneAuthorizer SubscribeAuthorizer(TimeProvider clock)
{
@@ -137,7 +137,7 @@ public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBa
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, AuditDelay);
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
actor.Tell(new DriverInstanceActor.ConnectivityChanged(SilentDriver, Connected: false));
@@ -170,7 +170,7 @@ public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBa
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, AuditDelay);
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
actor.Tell(new DriverInstanceActor.ConnectivityChanged(RepublishingDriver, Connected: false));
@@ -211,7 +211,7 @@ public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBa
},
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, AuditDelay);
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
actor.Tell(new DriverInstanceActor.ConnectivityChanged(PartialDriver, Connected: false));
@@ -0,0 +1,630 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The repo's first driver-child <b>restart</b> suite (archreview 03/S16, 03/U6): what a child obtained
/// by each of the three incarnation routes is actually handed.
/// </summary>
/// <remarks>
/// <para>
/// <b>The falsification bar, and why this file exists at all.</b> An actor-restart test must assert a
/// FUNCTION the new incarnation performs — it subscribed, it re-initialised — never that the
/// incarnation EXISTS. Existence is exactly what
/// <c>DriverHostActorReconcileTests.RestartDriver_respawns_the_child_without_an_actor_name_collision</c>
/// asserts (no name collision, factory created a second driver, one child remains), and that test
/// passes green against the 03/S16 defect: a respawned child that connects and then reads nothing
/// forever. It is kept, because its own subject — the <c>InvalidActorNameException</c> — is real; it
/// is simply the weaker half of a pair, and this file is the other half.
/// </para>
/// <para>
/// <b>The three incarnation routes, and what re-provisions each.</b> (1) <i>Deploy reconcile</i> —
/// <c>ApplyDriverSpecs</c> → <c>SpawnChild</c>, then the apply's bulk desired-subscription push.
/// (2) <i>Operator Restart</i> — <c>HandleRestartDriver</c> stops the child and calls
/// <c>SpawnChild</c> with the cached spec; before 03/S16 that was the whole story, and the new child
/// started with an EMPTY desired set that nothing re-sent until the next deployment (the #488
/// periodic reconcile early-returns on an empty set, so it is structurally inert here).
/// (3) <i>Supervision restart</i> — Akka's default decider recreates the actor object around the
/// SAME driver instance without going through <c>SpawnChild</c> at all, so nothing re-sent
/// <c>InitializeRequested</c> either and the child sat in <c>Connecting</c> forever around a driver
/// its own <c>PostStop</c> had already shut down. A fourth route, <i>Force Reconnect</i>, re-uses the
/// live actor instance and needs nothing — a verified negative, pinned here so a future edit to the
/// restart path cannot break the path that was already correct.
/// </para>
/// <para>
/// <b>The supervision crash seam, and why the obvious ones do not work.</b> The seam used is the
/// driver's own <c>OnDataChange</c> carrying a NULL <c>Snapshot</c>
/// (<see cref="RecordingSubscribableDriver.FireMalformedDataChange"/>): <c>DataChangeEventArgs</c> is
/// a positional record with no null validation, and <c>DriverInstanceActor.OnDataChangeForward</c>
/// dereferences <c>msg.Snapshot</c> unguarded on the ACTOR thread, so it NREs inside the child and
/// the default decider Restarts it. Rejected alternatives, each for a specific reason: a throwing
/// <c>IDriverHealthPublisher</c> (<c>PublishHealthSnapshot</c> wraps the publish in a
/// <c>catch (Exception)</c>, so it never escapes); a throwing <c>SubscribeAsync</c> (also swallowed —
/// the stub already models it via <c>SubscribeFailuresBeforeSuccess</c>); <c>Kill.Instance</c> (the
/// default decider <b>Stops</b> on <c>ActorKilledException</c>, a different directive and therefore a
/// different code path); and an <c>internal</c> "throw this" test message (a production surface added
/// purely for a test). The fault is arbitrary by design — what is under test is the RECOVERY. If
/// <c>DataChangeEventArgs</c> ever gains null validation, the fallback is an explicit
/// <c>OneForOneStrategy(Directive.Restart)</c> on the host plus an internal test-only throw message.
/// </para>
/// </remarks>
public sealed class DriverHostActorRestartTests : RuntimeActorTestBase
{
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
private static readonly NodeId TestNode = NodeId.Parse("node-1:4053");
private const string DriverId = "DI-1";
private const string SpeedPath = "Plant/Modbus/dev1/speed";
private const string TempPath = "Plant/Modbus/dev1/temp";
private const string AlarmXPath = "Plant/Modbus/dev1/alarmX";
private const string AlarmYPath = "Plant/Modbus/dev1/alarmY";
/// <summary>TagConfig marking a raw tag as alarm-bearing (parsed by <c>TagConfigIntent</c>).</summary>
private const string AlarmConfig = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""";
/// <summary>Seeds + applies a v3 deployment whose single Modbus driver is ENABLED, so a real child is
/// spawned around a <see cref="RecordingSubscribableDriver"/>, and waits until that child has connected
/// and established its first subscription.</summary>
private (IActorRef Host, RecordingSubscribableDriverFactory Factory) DeployOneDriver(
IDbContextFactory<OtOpcUaConfigDbContext> db,
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
IReadOnlyDictionary<string, string>? tagConfigByTag = null)
{
var factory = new RecordingSubscribableDriverFactory("Modbus");
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
db, RevA, rawTags, Array.Empty<(string, string, string, string, string?)>(),
enabled: true, tagConfigByTag: tagConfigByTag);
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
return (host, factory);
}
/// <summary>
/// <b>The 03/S16 falsification.</b> An operator's Restart is supposed to recover a sick driver; before
/// the fix it silently killed it — the respawned child came up with <c>_desiredRefs</c> empty, and the
/// only <c>SetDesiredSubscriptions</c> sender in the repo was the apply's bulk push, so the driver
/// subscribed to nothing until the next deployment dispatch.
/// <para>
/// Deliberately NOT the pass criterion: that a second driver was created, or that one child
/// remains. Both are true against the defect.
/// </para>
/// </summary>
[Fact]
public void Operator_restart_re_subscribes_the_respawned_child()
{
var db = NewInMemoryDbFactory();
var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].SubscribeCount.ShouldBe(1);
factory.Created[0].LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
},
duration: PresenceBudget);
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(2, "the restart did not respawn the child around a new driver");
factory.Created[1].SubscribeCount.ShouldBeGreaterThanOrEqualTo(
1,
"the RESPAWNED child never subscribed — it is Connected and reading nothing, which is the " +
"03/S16 defect (an operator pressed Restart to recover a driver and silently killed it)");
factory.Created[1].LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
},
duration: PresenceBudget);
}
/// <summary>
/// The value refs and the alarm refs ride in ONE <c>SetDesiredSubscriptions</c> but are applied by two
/// different child code paths (the value set self-Tells <c>Subscribe</c>; the alarm set runs
/// <c>SubscribeDesiredAlarms</c> against the <c>IAlarmSource</c> feed, which stays gated shut until it
/// is called). Sending the message is therefore not evidence that both halves landed, so the alarm
/// half is pinned separately.
/// </summary>
[Fact]
public void Operator_restart_re_subscribes_alarm_refs_too()
{
var db = NewInMemoryDbFactory();
var (host, factory) = DeployOneDriver(
db,
new[]
{
(DriverId, "Modbus", "dev1", "speed", "Float", false),
(DriverId, "Modbus", "dev1", "temp", "Float", false),
},
// An alarm-bearing raw tag is EXCLUDED from the value set and carried in the alarm set instead.
new Dictionary<string, string>
{
["temp"] = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""",
});
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(1);
factory.Created[0].LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath });
},
duration: PresenceBudget);
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(2);
factory.Created[1].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(
1,
"the respawned child never re-subscribed its alarm refs — the IAlarmSource feed stays " +
"gated shut, so no native alarm transition can ever reach the host again");
factory.Created[1].LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath });
// The value half must survive the alarm half being present.
factory.Created[1].LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
},
duration: PresenceBudget);
}
/// <summary>
/// The ordering guard for the spawn-time send. <c>ApplyDriverSpecs</c> (which spawns) runs BEFORE
/// <c>ApplyDesiredSubscriptions</c> (which rebuilds the authority), so a child spawned by a fresh
/// deploy is momentarily handed the PREVIOUS apply's set. That transient must never be acted on: it
/// is superseded by the bulk push on the same sender→receiver pair (Akka guarantees per-pair
/// ordering) while the child is still <c>Connecting</c>, where <c>SetDesiredSubscriptions</c> only
/// STORES; the subscribe happens on the Connected entry, from whatever was stored last.
/// <para>
/// Revision B swaps the driver's tag, so A's and B's sets are distinguishable. Note the raw tags
/// are folded into the merged <c>DriverConfig</c>, so a tag-set change DOES change the driver
/// spec and therefore respawns the child (#516) — which is precisely the arrangement under test:
/// a brand-new child, spawned while the authority still holds A, must end up subscribed to B.
/// </para>
/// <para>
/// <b>What is asserted is CONVERGENCE, not inertness</b>, and the difference was measured rather
/// than assumed. The child runs concurrently with the rest of the apply handler, so a driver
/// whose <c>InitializeAsync</c> completes instantly (this stub does) can reach Connected and
/// subscribe the transient stale set BEFORE the bulk push arrives — observed here as
/// <c>SubscribeCount == 2</c>. Akka's per-pair ordering still guarantees the final state is B's,
/// and <c>HandleSubscribeAsync</c> drops the prior subscription before establishing the new one,
/// so the cost is one extra round-trip on a respawning deploy. Pinning the count would pin a
/// race; the final refs are the invariant.
/// </para>
/// </summary>
[Fact]
public void A_child_spawned_by_a_fresh_deploy_ends_with_the_NEW_refs()
{
var db = NewInMemoryDbFactory();
var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
AwaitAssert(
() => factory.Created[0].LastSubscribedRefs.ShouldBe(new[] { SpeedPath }),
duration: PresenceBudget);
var revB = V3ArtifactSeeder.SeedV3Deployment(
db, RevB,
new[] { (DriverId, "Modbus", "dev1", "temp", "Float", false) },
Array.Empty<(string, string, string, string, string?)>(),
enabled: true);
host.Tell(new Commons.Messages.Deploy.DispatchDeployment(revB, RevB, CorrelationId.NewId()));
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(2, "the tag-set change re-spawns the child (#516 config respawn)");
factory.Created[1].LastSubscribedRefs.ShouldBe(
new[] { TempPath },
"the newly-spawned child is on the PREVIOUS revision's refs — the spawn-time set was not superseded");
},
duration: PresenceBudget);
// A Restart on top of that must hand the new child B's set, not A's — i.e. the authority really is
// repopulated WHOLE on every apply rather than accumulated.
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(3);
factory.Created[2].LastSubscribedRefs.ShouldBe(new[] { TempPath });
},
duration: PresenceBudget);
}
/// <summary>
/// The empty-set semantics are load-bearing and must not degrade into "skip the send": an EMPTY value
/// set is what tells the child to <c>Unsubscribe</c> and drop a stale handle, so it must be SENT.
/// <para>
/// The subject is a driver whose only raw tag is alarm-bearing: alarm tags are excluded from the
/// value set and carried in the alarm one, so its value half is genuinely empty while its alarm
/// half is not — and both halves ride in ONE <c>SetDesiredSubscriptions</c>. That is what makes
/// the absence assertion non-vacuous: <c>SubscribeAlarmsCount >= 1</c> proves the message
/// ARRIVED, and <c>SubscribeCount == 0</c> then proves the empty value half was honoured rather
/// than the whole send being skipped. Asserting only "it did not subscribe" would pass equally
/// against a build that sent the respawned child nothing at all — the 03/S16 defect itself.
/// </para>
/// </summary>
[Fact]
public void Restart_of_a_driver_with_an_empty_value_set_still_sends_the_set()
{
var db = NewInMemoryDbFactory();
var (host, factory) = DeployOneDriver(
db,
new[] { (DriverId, "Modbus", "dev1", "temp", "Float", false) },
new Dictionary<string, string>
{
["temp"] = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""",
});
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(1);
},
duration: PresenceBudget);
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(2);
var respawned = factory.Created[1];
respawned.InitializeCount.ShouldBe(1, "the respawned child was never initialised");
// Positive control: the SetDesiredSubscriptions genuinely reached the respawned child.
respawned.SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(
1, "the respawned child received no desired set at all — the absence below would be vacuous");
respawned.LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath });
respawned.SubscribeCount.ShouldBe(
0, "an EMPTY value set must route to Unsubscribe / no-op, never to a spurious subscribe");
},
duration: PresenceBudget);
}
/// <summary>
/// <b>Review finding F1 — the alarm subscription must FOLLOW a changed desired set, not latch.</b>
/// The value side converges by construction (<c>HandleSubscribeAsync</c> drops the prior subscription
/// before establishing the new one, and <c>StoreDesiredSubscriptions</c> is a full replace), but the
/// alarm side is guarded by <c>_alarmSubscriptionHandle is not null</c> in BOTH
/// <c>SubscribeDesiredAlarms</c> and <c>HandleSubscribeAlarmsAsync</c> — so once a handle exists it
/// was never re-established for the life of the connection.
/// <para>
/// That became reachable when every spawn started carrying a desired set: a tag-set edit forces a
/// respawn (raw tags fold into the merged <c>DriverConfig</c>, #516), and a fast-initialising
/// driver connects and subscribes the transient spawn-time set — the PREVIOUS revision's alarm
/// refs — before the bulk push lands. The bulk push then early-returns, latching the old set for
/// the connection's life. Drivers that FILTER on <c>sourceNodeIds</c> (OPC UA Client, AB CIP,
/// FOCAS) would silently never deliver the newly-added alarm.
/// </para>
/// </summary>
[Fact]
public void A_deploy_that_widens_the_alarm_set_re_subscribes_the_new_refs()
{
var db = NewInMemoryDbFactory();
var (host, factory) = DeployOneDriver(
db,
new[] { (DriverId, "Modbus", "dev1", "alarmX", "Float", false) },
new Dictionary<string, string> { ["alarmX"] = AlarmConfig });
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].LastSubscribedAlarmRefs.ShouldBe(new[] { AlarmXPath }, ignoreOrder: true);
},
duration: PresenceBudget);
// Revision B WIDENS the alarm set: X stays, Y is added.
var revB = V3ArtifactSeeder.SeedV3Deployment(
db, RevB,
new[]
{
(DriverId, "Modbus", "dev1", "alarmX", "Float", false),
(DriverId, "Modbus", "dev1", "alarmY", "Float", false),
},
Array.Empty<(string, string, string, string, string?)>(),
enabled: true,
tagConfigByTag: new Dictionary<string, string>
{
["alarmX"] = AlarmConfig,
["alarmY"] = AlarmConfig,
});
host.Tell(new Commons.Messages.Deploy.DispatchDeployment(revB, RevB, CorrelationId.NewId()));
AwaitAssert(
() =>
{
var current = factory.Created[^1];
current.LastSubscribedAlarmRefs.ShouldBe(
new[] { AlarmXPath, AlarmYPath },
ignoreOrder: true,
"the driver is still subscribed to the PREVIOUS revision's alarm refs — the alarm " +
"subscription latched on the transient spawn-time set and never followed the new one");
},
duration: PresenceBudget);
}
/// <summary>
/// <b>The repo's first genuine Akka SUPERVISION-restart test (03/U6).</b> Not a stop, not a stub: the
/// driver raises a malformed <c>OnDataChange</c>, the actor NREs on its own thread, and Akka's default
/// decider Restarts the child in place.
/// <para>
/// A restart is strictly worse than the operator-Restart case 03/S16 names. It recreates the actor
/// object around the SAME driver instance without going through <c>SpawnChild</c>, so nothing
/// re-sends <c>InitializeRequested</c> — while <c>PreRestart</c> has already run <c>PostStop</c>,
/// which called <c>ShutdownAsync</c> on that very driver. The child therefore sat in
/// <c>Connecting</c> forever around a shut-down driver, with its desired set gone too, and
/// silently: Akka logs the fault, nothing logs the non-recovery.
/// </para>
/// <para>
/// The assertions are FUNCTIONS of the new incarnation — it re-initialised, it re-subscribed the
/// deployed refs — never that it exists. <c>Created.Count == 1</c> is what distinguishes this from
/// the operator path: a restart must NOT produce a second driver object.
/// </para>
/// </summary>
[Fact]
public void Supervision_restart_reinitialises_and_re_subscribes_the_child()
{
var db = NewInMemoryDbFactory();
var (_, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].SubscribeCount.ShouldBe(1);
},
duration: PresenceBudget);
var driver = factory.Created[0];
driver.InitializeCount.ShouldBe(1);
// The crash. Subscribing is what attaches the actor's OnDataChange handler, so this only reaches the
// actor thread because the arrangement above got as far as a live subscription.
driver.FireMalformedDataChange(SpeedPath);
// FIRST: prove the crash seam actually tripped supervision, before asserting anything about the
// recovery. PreRestart runs PostStop, which calls ShutdownAsync on the driver — so a raised
// ShutdownCount is the evidence that a restart happened at all. Without this the assertions below
// would be ambiguous: "the child was not re-provisioned" and "the child never crashed" look
// identical from the outside, and a seam that silently stopped working would read as a passing
// regression test forever.
AwaitAssert(
() => driver.ShutdownCount.ShouldBeGreaterThanOrEqualTo(
1, "the crash seam did not trip supervision — nothing below is testing a restart"),
duration: PresenceBudget);
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(
1, "a supervision restart must re-use the same driver object, not respawn the child");
driver.InitializeCount.ShouldBe(
2,
"the restarted child was never re-initialised — it is sitting in Connecting around a " +
"driver its own PostStop already shut down (03/S6, the worse half of 03/S16)");
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(
2, "the restarted child never re-subscribed — its desired set died with the old instance");
driver.LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
},
duration: PresenceBudget);
}
/// <summary>
/// <b>Review finding F2 — a reprovision must be routed by INCARNATION, not just by driver id.</b>
/// <c>ReprovisionRequested</c> is delivered asynchronously, so one can still be in flight from an old
/// incarnation when <c>HandleRestartDriver</c> has already stopped that child and swapped in a new
/// one under the same <c>DriverInstanceId</c>. Routing on the id alone delivers it to the NEW child,
/// which is in <c>Connecting</c> around a healthy driver: it triggers a second, concurrent
/// <c>InitializeAsync</c> that races the very dispose-before-overwrite the #527 teardown exists to
/// serialise (<c>ModbusDriver._transport</c>, <c>MqttDriver._connection</c>).
/// <para>
/// A stale sender is simulated directly — a probe standing in for the dead incarnation — because
/// provoking the real race would be timing-dependent. The assertion is an ABSENCE, so it carries
/// a positive control: the current child is proven alive and provisioned first, and its
/// <c>InitializeCount</c> must then not move.
/// </para>
/// </summary>
[Fact]
public void A_reprovision_from_a_stale_incarnation_is_ignored()
{
var db = NewInMemoryDbFactory();
// The child must be in CONNECTING for this defect to bite: InitializeRequested is unhandled in the
// Connected behaviour, so an already-connected child would swallow the stale reprovision for the
// wrong reason and the test would pass vacuously. The gated init holds it in Connecting.
var factory = new RecordingSubscribableDriverFactory("Modbus", gateInitialize: true);
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
db, RevA,
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
Array.Empty<(string, string, string, string, string?)>(),
enabled: true);
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
// Positive control: the current child is alive and has begun its one and only initialise.
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].InitializeCount.ShouldBe(1);
},
duration: PresenceBudget);
var driver = factory.Created[0];
// A ReprovisionRequested that did NOT come from the child the host currently holds.
var staleIncarnation = CreateTestProbe();
host.Tell(
new DriverInstanceActor.ReprovisionRequested(DriverId, "NullReferenceException"),
staleIncarnation.Ref);
// Round-trip a message the host must answer, so the reprovision above is provably processed before
// the absence is asserted — otherwise "nothing happened yet" would pass for "it was ignored".
var coordinator = CreateTestProbe();
host.Tell(new Commons.Messages.Fleet.GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(PresenceBudget);
driver.InitializeCount.ShouldBe(
1,
"a reprovision from a dead incarnation reached the CURRENT child and started a second, " +
"concurrent InitializeAsync on a driver that was already connected");
}
/// <summary>
/// <b>Review finding F3 - a crash-looping driver must not become a reconnect storm.</b> There is no
/// <c>SupervisorStrategy</c> anywhere under <c>src/Server</c>, so the default decider restarts a
/// faulting child <b>immediately and without limit</b>. That was survivable while a restart did
/// nothing; the reprovision hook now makes every one of them do
/// <c>ShutdownAsync</c> + <c>InitializeAsync</c> + <c>SubscribeAsync</c>, so a driver that faults on
/// every value would hammer its device as fast as it can crash.
/// <para>
/// The reprovision is therefore rate-limited per driver. Crucially it is <b>deferred, not
/// dropped</b> - the last request must still be honoured, or a child whose final crash fell
/// inside the window would be left un-provisioned forever, which is the very failure this whole
/// plan exists to close. Two genuine back-to-back supervision restarts: the first reprovision
/// applies immediately, the second is held and then lands.
/// </para>
/// </summary>
[Fact]
public void A_second_reprovision_inside_the_backoff_window_is_deferred_not_dropped()
{
var db = NewInMemoryDbFactory();
// Generous relative to the in-memory step time below, so "inside the window" is a decision rather
// than a race, while still far under the presence budget the deferred landing waits on.
var backoff = TimeSpan.FromSeconds(3);
var factory = new RecordingSubscribableDriverFactory("Modbus");
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
db, RevA,
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
Array.Empty<(string, string, string, string, string?)>(),
enabled: true);
V3ArtifactSeeder.SpawnHostAndApply(
this, db, TestNode, deploymentId, RevA, factory, reprovisionBackoff: backoff);
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].SubscribeCount.ShouldBe(1);
},
duration: PresenceBudget);
var driver = factory.Created[0];
// Crash 1: the first reprovision has no prior stamp, so it applies immediately.
driver.FireMalformedDataChange(SpeedPath);
AwaitAssert(
() =>
{
driver.InitializeCount.ShouldBe(2);
driver.SubscribeCount.ShouldBe(2, "the re-subscribe must land before the second crash is armed");
},
duration: PresenceBudget);
// Crash 2, immediately after - well inside the window.
driver.FireMalformedDataChange(SpeedPath);
AwaitAssert(
() => driver.ShutdownCount.ShouldBeGreaterThanOrEqualTo(
2, "the second crash seam did not trip supervision - nothing below is testing a backoff"),
duration: PresenceBudget);
driver.InitializeCount.ShouldBe(
2, "the second reprovision was applied immediately - the backoff is not rate-limiting anything");
// ...but it must still land once the window elapses.
AwaitAssert(
() => driver.InitializeCount.ShouldBe(
3, "the deferred reprovision was DROPPED, not deferred"),
duration: PresenceBudget);
}
/// <summary>
/// The <c>_children.Keys</c> arm of the desired-set authority's union. A driver with NO authored tags
/// is absent from both the value- and alarm-ref maps, so keying the authority off those maps alone
/// would leave it with no entry at all — and "absent" must not be allowed to differ from "empty",
/// because an empty set is what tells a child to <c>Unsubscribe</c> and drop a stale handle.
/// <para>
/// Asserted the only way it is observable: the tagless child is initialised (so it is provably
/// alive and was provisioned) and never subscribes, while its tag-owning sibling in the same
/// deployment does — the sibling is the positive control proving the apply's push pass actually
/// ran, so "it did not subscribe" cannot pass for "nothing happened".
/// </para>
/// </summary>
[Fact]
public void A_running_driver_with_no_authored_tags_is_handed_an_empty_set()
{
var db = NewInMemoryDbFactory();
var factory = new RecordingSubscribableDriverFactory("Modbus");
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
db, RevA,
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
Array.Empty<(string, string, string, string, string?)>(),
enabled: true,
taglessDrivers: new[] { "DI-TAGLESS" });
V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(2);
// Positive control: the sibling that DOES have a tag subscribed, so the push pass ran.
factory.Created.Single(d => d.DriverInstanceId == DriverId)
.SubscribeCount.ShouldBe(1);
factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS")
.InitializeCount.ShouldBe(1);
},
duration: PresenceBudget);
factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS")
.SubscribeCount.ShouldBe(
0, "a driver with no authored tags must be handed an EMPTY set, never a spurious subscribe");
}
/// <summary>
/// The verified negative, pinned so this plan's edits to the restart paths cannot break the one that
/// was already correct. Force Reconnect (<c>ReconnectDriver</c>) Tells <c>ForceReconnect</c> to the
/// SAME child actor: the instance survives, so <c>_desiredRefs</c>/<c>_desiredAlarmRefs</c> are intact
/// and the reconnect's Connected entry re-applies them by itself. Nothing needs re-provisioning, and
/// no new driver object is created.
/// </summary>
/// <remarks>
/// The budget here is deliberately wider than <see cref="RuntimeActorTestBase.PresenceBudget"/>:
/// <c>SpawnChild</c> gives the child the production <c>DefaultReconnectInterval</c> (10 s) and the
/// retry is a periodic timer, so the reconnect cannot land sooner than that. A budget is spent only
/// when the assertion fails, so widening it costs nothing on the happy path.
/// </remarks>
[Fact]
public void Force_reconnect_keeps_the_desired_set_and_re_subscribes()
{
var db = NewInMemoryDbFactory();
var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1);
factory.Created[0].SubscribeCount.ShouldBe(1);
},
duration: PresenceBudget);
var driver = factory.Created[0];
host.Tell(new ReconnectDriver("c1", DriverId, "tester", Guid.NewGuid()));
AwaitAssert(
() =>
{
factory.Created.Count.ShouldBe(1, "Force Reconnect must NOT respawn the child");
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(
2, "the reconnected driver never re-subscribed its (surviving) desired set");
driver.LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
},
duration: TimeSpan.FromSeconds(30));
}
}
@@ -123,7 +123,7 @@ public sealed class DriverHostCommsLossRecoveryTests : RuntimeActorTestBase
(Driver: driverId, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) },
driversEnabled: true);
enabled: true);
var (host, publish, _) = SpawnHostAndApply(
this, db, TestNode, deploymentId, RevA,
@@ -40,6 +40,10 @@ public sealed class DriverInstanceActorCommsLossWatchdogTests : RuntimeActorTest
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50);
private static readonly TimeSpan FastTimeout = TimeSpan.FromMilliseconds(250);
/// <summary>The threshold used by the per-connection grace pair (03/S17). Deliberately an order of
/// magnitude above <see cref="FastPoll"/> so the absence window below is a decision, not a race.</summary>
private static readonly TimeSpan Grace = TimeSpan.FromMilliseconds(1500);
/// <summary>Drives the actor to Connected and returns once the connect has been announced.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Parent) ConnectDriver(
HealthControlledStubDriver driver,
@@ -87,6 +91,14 @@ public sealed class DriverInstanceActorCommsLossWatchdogTests : RuntimeActorTest
/// <c>ModbusDriver.ReadAsync</c> leaves latched whenever the bad tag is read last — but it is still
/// reading successfully, so <c>LastSuccessfulRead</c> keeps advancing. It must be left alone.
/// A state-only watchdog would tear this driver down on every threshold, forever.
/// <para>
/// <b>Falsification partner (03/S17):</b>
/// <see cref="A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again"/>.
/// The silence baseline is <c>max(LastSuccessfulRead, _connectedSinceUtc)</c>, and each test kills
/// one term of that max — this one goes red if the <c>LastSuccessfulRead</c> term is dropped (its
/// connect ages far past the threshold while its reads keep succeeding), that one goes red if the
/// <c>_connectedSinceUtc</c> term is dropped (i.e. if the original <c>??</c> is restored).
/// </para>
/// </summary>
[Fact]
public void A_degraded_driver_that_is_still_reading_successfully_is_left_alone()
@@ -258,6 +270,77 @@ public sealed class DriverInstanceActorCommsLossWatchdogTests : RuntimeActorTest
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>
/// 03/S17 — <b>the grace is per connection.</b> Drivers deliberately <em>preserve</em>
/// <see cref="DriverHealth.LastSuccessfulRead"/> across Degraded/Faulted health writes (that
/// preservation is the #527 design), so for a device that accepts a TCP connection but never serves
/// data the stamp stays frozen from before the outage — already older than the threshold. Measuring
/// silence from that stamp alone (<c>??</c>) makes the <b>first</b> health tick of every new
/// connection re-trip, so the reconnect cycle runs at health-tick cadence (~30 s in production)
/// instead of the designed grace. Measuring from <c>max(LastSuccessfulRead, _connectedSinceUtc)</c>
/// gives each connection its own full window, which is exactly what the <c>_connectedSinceUtc</c>
/// re-stamp on every Connected entry already promises in its comment.
/// <para>
/// <b>Falsification partner:</b>
/// <see cref="A_degraded_driver_that_is_still_reading_successfully_is_left_alone"/> — see its
/// remarks for how the pair covers both terms of the max.
/// </para>
/// <para>
/// The absence window carries the house positive control: a proven number of health-poll ticks
/// processed <em>after</em> the reconnect, so "the watchdog did not re-trip" cannot pass because
/// the actor stopped ticking. It is also deliberately far shorter than <see cref="Grace"/>, so
/// it asserts a delay, not a disablement — <see cref="A_half_broken_device_is_still_declared_lost_once_the_grace_elapses"/>
/// is the anti-vacuity half.
/// </para>
/// </summary>
[Fact]
public void A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again()
{
var driver = new HealthControlledStubDriver
{
// The half-broken device: the socket connects, every read fails, and the last successful read is
// frozen well before the outage — preserved across the Degraded writes, as every driver does.
Health = new DriverHealth(DriverState.Degraded, DateTime.UtcNow.AddMinutes(-5), "reads time out"),
};
var (_, parent) = ConnectDriver(driver, commsLossTimeout: Grace, reconnectInterval: TimeSpan.FromMilliseconds(100));
// The first trip is correct and expected — the device really has been silent for five minutes.
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget)
.Connected.ShouldBeFalse();
// The retry re-initialises successfully (the socket still accepts), so a NEW connection starts here.
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget)
.Connected.ShouldBeTrue();
var ticksAtReconnect = driver.HealthReadCount;
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
(driver.HealthReadCount - ticksAtReconnect).ShouldBeGreaterThanOrEqualTo(
4,
"health-poll ticks did not run after the reconnect — the absence assertion above would be vacuous");
}
/// <summary>
/// The anti-vacuity half of the 03/S17 pair: the per-connection grace <b>delays</b> the watchdog, it
/// does not disable it. The same half-broken device is still declared lost once the grace elapses —
/// without this, a "fix" that simply stopped evaluating the watchdog after the first connection would
/// pass <see cref="A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again"/>
/// and look correct.
/// </summary>
[Fact]
public void A_half_broken_device_is_still_declared_lost_once_the_grace_elapses()
{
var driver = new HealthControlledStubDriver
{
Health = new DriverHealth(DriverState.Degraded, DateTime.UtcNow.AddMinutes(-5), "reads time out"),
};
var (_, parent) = ConnectDriver(driver, commsLossTimeout: Grace, reconnectInterval: TimeSpan.FromMilliseconds(100));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeTrue();
// The second connection serves no data either, so once ITS grace elapses the watchdog trips again.
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
}
}
/// <summary>
@@ -188,3 +188,215 @@ internal sealed class StubHandle : ISubscriptionHandle
/// <summary>Gets the diagnostic ID of the subscription.</summary>
public string DiagnosticId => "stub-sub";
}
/// <summary>
/// A per-instance recording driver for the restart / re-subscribe suite (03/S16, U6): unlike
/// <see cref="SubscribableStubDriver"/> it carries the <b>spec's</b> <see cref="DriverInstanceId"/>
/// (<see cref="StubDriver"/> hard-codes a constant on a non-virtual member), and it counts and records the
/// alarm subscription alongside the value one — the two travel in a single
/// <c>SetDesiredSubscriptions</c> but through two different child code paths, so both need observing.
/// </summary>
/// <remarks>
/// <para>
/// <b>The falsification bar these counters exist for:</b> a restart test must assert a FUNCTION the new
/// incarnation performs — it subscribed, it re-initialised — never that the incarnation exists.
/// Existence is what
/// <c>DriverHostActorReconcileTests.RestartDriver_respawns_the_child_without_an_actor_name_collision</c>
/// asserts, and that test passes against the very defect 03/S16 names.
/// </para>
/// <para>
/// <b><see cref="FireMalformedDataChange"/> is the supervision crash seam</b> — see
/// <c>DriverHostActorRestartTests</c>'s file remarks for why the obvious alternatives do not work.
/// </para>
/// </remarks>
internal sealed class RecordingSubscribableDriver : IDriver, ISubscribable, IAlarmSource
{
private readonly Lock _gate = new();
private readonly StubHandle _handle = new();
private readonly RecordingAlarmHandle _alarmHandle = new();
private int _initializeCount;
private int _shutdownCount;
private int _subscribeCount;
private int _subscribeAlarmsCount;
private IReadOnlyList<string>? _lastSubscribedRefs;
private IReadOnlyList<string>? _lastSubscribedAlarmRefs;
/// <summary>Initializes a new recording driver stamped with the spec's identity.</summary>
/// <param name="driverInstanceId">The driver instance id from the spec that created it.</param>
/// <param name="driverType">The driver type from the spec that created it.</param>
public RecordingSubscribableDriver(string driverInstanceId, string driverType)
{
DriverInstanceId = driverInstanceId;
DriverType = driverType;
}
/// <inheritdoc />
public string DriverInstanceId { get; }
/// <inheritdoc />
public string DriverType { get; }
/// <summary>Number of <see cref="InitializeAsync"/> calls. A supervision restart re-uses the SAME driver
/// object (the Props closure captured it), so this going 1 to 2 is how the reprovision is observed.</summary>
public int InitializeCount => Volatile.Read(ref _initializeCount);
/// <summary>Number of <see cref="ShutdownAsync"/> calls — a restart's <c>PostStop</c> raises it.</summary>
public int ShutdownCount => Volatile.Read(ref _shutdownCount);
/// <summary>Number of <see cref="SubscribeAsync"/> calls.</summary>
public int SubscribeCount => Volatile.Read(ref _subscribeCount);
/// <summary>Number of <see cref="SubscribeAlarmsAsync"/> calls.</summary>
public int SubscribeAlarmsCount => Volatile.Read(ref _subscribeAlarmsCount);
/// <summary>The refs passed to the most recent <see cref="SubscribeAsync"/>, or null if never called.</summary>
public IReadOnlyList<string>? LastSubscribedRefs
{
get { lock (_gate) return _lastSubscribedRefs; }
}
/// <summary>The refs passed to the most recent <see cref="SubscribeAlarmsAsync"/>, or null if never called.</summary>
public IReadOnlyList<string>? LastSubscribedAlarmRefs
{
get { lock (_gate) return _lastSubscribedAlarmRefs; }
}
/// <summary>
/// When set, <see cref="InitializeAsync"/> awaits it instead of completing inline — so the actor
/// stays in <c>Connecting</c> for as long as the test wants. Needed because several defects only
/// bite a child that has NOT yet connected (<c>InitializeRequested</c> is unhandled in
/// <c>Connected</c>, so a test built on an already-connected child passes vacuously).
/// </summary>
public TaskCompletionSource? InitializeGate { get; set; }
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _initializeCount);
if (InitializeGate is { } gate) await gate.Task.ConfigureAwait(false);
}
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref _shutdownCount);
return Task.CompletedTask;
}
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <inheritdoc />
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _subscribeCount);
lock (_gate) _lastSubscribedRefs = fullReferences.ToArray();
return Task.FromResult<ISubscriptionHandle>(_handle);
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <inheritdoc />
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _subscribeAlarmsCount);
lock (_gate) _lastSubscribedAlarmRefs = sourceNodeIds.ToArray();
return Task.FromResult<IAlarmSubscriptionHandle>(_alarmHandle);
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Fires a well-formed data change (the ordinary production path).</summary>
/// <param name="fullRef">The reference whose value changed.</param>
/// <param name="value">The new value.</param>
public void FireDataChange(string fullRef, object? value) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(
_handle, fullRef, new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow)));
/// <summary>
/// Raises <see cref="OnDataChange"/> with a NULL <c>Snapshot</c> — the supervision crash seam.
/// <c>DataChangeEventArgs</c> is a positional record with no null validation, and the actor's
/// <c>OnDataChangeForward</c> dereferences <c>msg.Snapshot</c> unguarded ON THE ACTOR THREAD, so this
/// produces a genuine unhandled exception inside the child, which Akka's default decider turns into a
/// Restart. The fault itself is arbitrary; what is under test is the RECOVERY.
/// </summary>
/// <param name="fullRef">The reference to name in the malformed event.</param>
public void FireMalformedDataChange(string fullRef) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, null!));
/// <summary>Raises <see cref="OnAlarmEvent"/>; present so the event is not merely written-to-never-read.</summary>
/// <param name="conditionId">The condition id to raise.</param>
public void FireAlarm(string conditionId) =>
OnAlarmEvent?.Invoke(this, new AlarmEventArgs(
_alarmHandle, conditionId, conditionId, "AlarmCondition", "test", AlarmSeverity.High, DateTime.UtcNow));
private sealed class RecordingAlarmHandle : IAlarmSubscriptionHandle
{
/// <inheritdoc />
public string DiagnosticId => "recording-alarm-sub";
}
}
/// <summary>
/// An <see cref="IDriverFactory"/> that hands out <see cref="RecordingSubscribableDriver"/>s and KEEPS
/// every one it created, in creation order. The list — not a single "the driver" reference — is the point:
/// an operator Restart tears the child down and spawns a new one around a NEW driver object, so the
/// assertion subject is <c>Created[1]</c>, while a SUPERVISION restart re-uses <c>Created[0]</c> (the Props
/// closure captured it) and part of the assertion is that <c>Created.Count</c> stayed 1.
/// </summary>
internal sealed class RecordingSubscribableDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly Lock _gate = new();
private readonly List<RecordingSubscribableDriver> _created = new();
private readonly bool _gateInitialize;
/// <summary>Initializes a factory that materialises exactly one driver type.</summary>
/// <param name="supportedType">The driver type this factory answers for.</param>
/// <param name="gateInitialize">When true, every created driver gets an un-completed
/// <see cref="RecordingSubscribableDriver.InitializeGate"/>, so its child actor stays in
/// <c>Connecting</c> until the test completes it.</param>
public RecordingSubscribableDriverFactory(string supportedType, bool gateInitialize = false)
{
_supportedType = supportedType;
_gateInitialize = gateInitialize;
}
/// <summary>Every driver this factory created, in creation order.</summary>
public IReadOnlyList<RecordingSubscribableDriver> Created
{
get { lock (_gate) return _created.ToArray(); }
}
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
var driver = new RecordingSubscribableDriver(driverInstanceId, driverType);
if (_gateInitialize)
driver.InitializeGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
lock (_gate) _created.Add(driver);
return driver;
}
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
@@ -37,20 +37,35 @@ public static class V3ArtifactSeeder
/// <param name="rev">The revision hash the deployment is sealed under.</param>
/// <param name="rawTags">The raw tags to materialise, one per (driver, device, tag).</param>
/// <param name="unsRefs">UNS references projecting a raw tag (by <c>BackingTag</c>) into an equipment.</param>
/// <param name="driversEnabled">Whether the seeded DriverInstance rows are Enabled — i.e. whether the
/// host SPAWNS a child around them. False (the default) keeps the routing suites child-free; a suite
/// that needs a real <c>DriverInstanceActor</c> sets it and supplies a driver factory.</param>
/// <param name="enabled">Whether the seeded <c>DriverInstances</c> are Enabled — i.e. whether applying this
/// artifact actually SPAWNS driver children. Defaults to <c>false</c>, the shape every pre-existing caller
/// relies on: the value-routing suites inject values straight into the host and want no children at all.
/// The restart/re-subscribe suite (03/S16) passes <c>true</c>, because its whole subject is what a spawned
/// child is handed.</param>
/// <param name="tagConfigByTag">Optional per-tag <c>TagConfig</c> JSON, keyed by tag name; tags not named
/// here keep the default <c>"{}"</c>. The only way to seed an <b>alarm-bearing</b> raw tag (the alarm intent
/// is parsed out of <c>TagConfig.alarm</c> by <c>TagConfigIntent</c>), which the alarm-ref half of the
/// restart suite needs — an alarm-bearing raw tag is excluded from the VALUE subscription set and carried
/// in the alarm one instead, so the two travel through different child code paths.</param>
/// <param name="taglessDrivers">Extra <c>DriverInstance</c> ids carrying NO devices and NO tags. The
/// only way to express a driver that is a running child yet absent from both the value- and alarm-ref
/// maps — the <c>_children.Keys</c> arm of <c>ApplyDesiredSubscriptions</c>'s union, which must still
/// hand that child an explicit EMPTY set rather than skipping it.</param>
/// <returns>The seeded deployment's id.</returns>
public static DeploymentId SeedV3Deployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs,
bool driversEnabled = false)
bool enabled = false,
IReadOnlyDictionary<string, string>? tagConfigByTag = null,
string[]? taglessDrivers = null)
{
var drivers = rawTags
.Select(t => (t.Driver, t.DriverName))
.Distinct()
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = driversEnabled })
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
.Concat((taglessDrivers ?? Array.Empty<string>())
.Select(id => new { DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
.ToArray();
var devices = rawTags
.Select(t => (t.Driver, t.Device))
@@ -65,7 +80,7 @@ public static class V3ArtifactSeeder
Name = t.Tag,
DataType = t.DataType,
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
TagConfig = "{}",
TagConfig = tagConfigByTag is not null && tagConfigByTag.TryGetValue(t.Tag, out var tc) ? tc : "{}",
}).ToArray();
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
@@ -119,21 +134,26 @@ public static class V3ArtifactSeeder
/// <param name="localNode">The host's local node id.</param>
/// <param name="deploymentId">The deployment to dispatch.</param>
/// <param name="rev">The revision hash the deployment was sealed under.</param>
/// <param name="driverFactory">Optional driver factory forwarded to <c>DriverHostActor.Props</c>. Null (the
/// default, preserving every existing call site) leaves the host on its own default, which materialises no
/// real drivers. Supply one — with <c>enabled: true</c> on the seed — to actually spawn driver children.</param>
/// <param name="reprovisionBackoff">Optional per-driver supervision-reprovision rate limit forwarded to
/// <c>DriverHostActor.Props</c>; null keeps the production default. Exists so a test can observe the
/// deferral without waiting the production window.</param>
/// <param name="commsRecoveryAuditDelay">Optional 03/S15 audit delay — how long after a reconnect the
/// host checks for refs still latched <c>BadNoCommunication</c>. Null keeps the production default,
/// which is far longer than any test's lifetime.</param>
/// <param name="driverFactory">Optional factory the host builds its driver children from. Null (the
/// default) leaves the null factory, which — with <c>driversEnabled: false</c> — means no child is
/// spawned at all.</param>
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
TimeSpan? commsRecoveryAuditDelay = null,
IDriverFactory? driverFactory = null)
IDriverFactory? driverFactory = null,
TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null)
{
var publish = kit.CreateTestProbe();
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, commsRecoveryAuditDelay, driverFactory);
var (actor, mux, coordinator) =
Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff, commsRecoveryAuditDelay);
Dispatch(actor, coordinator, deploymentId, rev);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
@@ -153,15 +173,15 @@ public static class V3ArtifactSeeder
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
NodeId localNode, DeploymentId deploymentId, RevisionHash rev)
{
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null);
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null, driverFactory: null, reprovisionBackoff: null);
Dispatch(actor, coordinator, deploymentId, rev);
return (actor, mux);
}
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
IActorRef? opcUaPublishActor, TimeSpan? commsRecoveryAuditDelay = null,
IDriverFactory? driverFactory = null)
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff,
TimeSpan? commsRecoveryAuditDelay = null)
{
var coordinator = kit.CreateTestProbe();
var mux = kit.CreateTestProbe();
@@ -175,6 +195,7 @@ public static class V3ArtifactSeeder
opcUaPublishActor: opcUaPublishActor,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref,
reprovisionBackoff: reprovisionBackoff,
commsRecoveryAuditDelay: commsRecoveryAuditDelay));
return (actor, mux, coordinator);