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:
+241
@@ -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));
|
||||
}
|
||||
}
|
||||
+202
@@ -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<T>()</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");
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+370
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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>
|
||||
|
||||
+148
@@ -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)
|
||||
{
|
||||
|
||||
+3
-3
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+83
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user