Merge local master 7bcb6331 into remed/lane-FOCAS

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:51:26 -04:00
105 changed files with 10429 additions and 717 deletions
@@ -31,6 +31,26 @@ public sealed class DiagnoseTests
=> Diag("""var c = new System.Net.Http.HttpClient(); return 0;""")
.ShouldContain(x => x.Severity == 8);
// Sandbox hardening parity (R05:10): the editor runs the SAME ForbiddenTypeAnalyzer the publish gate
// runs, so every newly-denied form must surface as an OTSCRIPT_FORBIDDEN marker here — editor accepts
// ⇔ publish accepts. One case per deny mechanism (namespace, member, type), plus the allow-list.
[Theory]
[InlineData("""new System.Threading.SemaphoreSlim(0).Wait(); return 0;""")] // System.Threading namespace
[InlineData("""System.Threading.SpinWait.SpinUntil(() => false); return 0;""")]
[InlineData("""var t = System.Type.GetType("System.IO.File"); return t is null ? 0 : 1;""")] // forbidden member
[InlineData("""return typeof(string).GetMethods().Length;""")]
[InlineData("""var o = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(string)); return 0;""")] // forbidden type
public void Hardened_sandbox_forms_are_flagged_in_the_editor(string code)
=> Diag(code).ShouldContain(x => x.Code == "OTSCRIPT_FORBIDDEN" && x.Severity == 8);
[Theory]
[InlineData("""var t = System.Threading.CancellationToken.None; return t.IsCancellationRequested ? 1 : 0;""")]
[InlineData("""var x = 41; System.Threading.Interlocked.Increment(ref x); return x;""")]
[InlineData("""var x = 42; return System.Threading.Volatile.Read(ref x);""")]
[InlineData("""object o = 42; return o.GetType().IsValueType ? 1 : 0;""")]
public void Allow_listed_forms_are_not_flagged_in_the_editor(string code)
=> Diag(code).ShouldBeEmpty();
[Fact] public void Dynamic_tag_path_is_flagged()
=> Diag("""var p = "A"; return ctx.GetTag(p).Value;""")
.ShouldContain(x => x.Message.Contains("literal"));
@@ -0,0 +1,159 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:3.1 — the admin singleton's mailbox used to be suspended for the whole of every
/// <c>ReceiveAsync</c>: one Test Connect against a black-holed PLC (60 s clamp) or one large deploy
/// compose stalled every other admin operation fleet-wide, alarm acks included. Both now run off the
/// actor thread; only the "is one in flight" check stays serialised.
/// </summary>
public sealed class AdminOperationsActorMailboxTests : ControlPlaneActorTestBase
{
private static readonly TimeSpan AckBudget = TimeSpan.FromSeconds(1);
/// <summary>An alarm ack completes in under a second while a 60 s Test Connect is still pending.</summary>
[Fact]
public void Alarm_ack_completes_while_a_60s_test_connect_is_pending()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var probe = new BlockingProbe();
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, new IDriverProbe[] { probe }));
var connectClient = CreateTestProbe("connect-client");
connectClient.Send(actor, new TestDriverConnect("Blocking", "{}", TimeoutSeconds: 60, Guid.NewGuid()));
probe.Started.Wait(PresenceBudget).ShouldBeTrue("the probe must be in flight before the ack is sent");
actor.Tell(new AcknowledgeAlarmCommand("alarm-1", "op", null, CorrelationId.NewId()));
var ack = ExpectMsg<AcknowledgeAlarmResult>(AckBudget);
ack.Ok.ShouldBeTrue();
// Positive control: the probe reply still lands, with the right sender, once the probe returns.
connectClient.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
probe.Release(new DriverProbeResult(true, null, TimeSpan.FromMilliseconds(5)));
var result = connectClient.ExpectMsg<TestDriverConnectResult>(PresenceBudget);
result.Ok.ShouldBeTrue();
result.LatencyMs.ShouldNotBeNull();
}
/// <summary>A probe that times out still answers its own sender with the clamped timeout message.</summary>
[Fact]
public void Test_connect_timeout_is_reported_to_its_sender()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var probe = new BlockingProbe(); // never released: the CTS fires at the 1 s clamp floor
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect("Blocking", "{}", TimeoutSeconds: 1, Guid.NewGuid()));
var result = ExpectMsg<TestDriverConnectResult>(PresenceBudget);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("timed out");
}
/// <summary>
/// The deploy compose (snapshot + validate + gzip + row write) runs off the actor: an alarm ack
/// completes while the compose is parked on a gated DB context, and the deploy still completes
/// (Accepted + dispatched) once the gate opens.
/// </summary>
[Fact]
public void Alarm_ack_completes_while_a_deploy_compose_is_in_flight()
{
var gated = new GatedDbFactory(NewInMemoryDbFactory());
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(gated, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
var deployer = CreateTestProbe("deployer");
gated.Hold();
deployer.Send(actor, new StartDeployment("joe", CorrelationId.NewId()));
gated.Entered.Wait(PresenceBudget).ShouldBeTrue("the compose must be parked before the ack is sent");
actor.Tell(new AcknowledgeAlarmCommand("alarm-2", "op", null, CorrelationId.NewId()));
ExpectMsg<AcknowledgeAlarmResult>(AckBudget).Ok.ShouldBeTrue();
// Positive control: the deploy completes once released.
deployer.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
gated.Release();
deployer.ExpectMsg<StartDeploymentResult>(PresenceBudget).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
}
/// <summary>
/// Only the in-flight check is serialised — and it must still catch a second request that arrives
/// while the first compose has not yet written its row (the DB alone would let both through).
/// </summary>
[Fact]
public void Second_StartDeployment_during_an_in_flight_compose_is_refused()
{
var gated = new GatedDbFactory(NewInMemoryDbFactory());
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(gated, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
var first = CreateTestProbe("first");
var second = CreateTestProbe("second");
gated.Hold();
first.Send(actor, new StartDeployment("joe", CorrelationId.NewId()));
gated.Entered.Wait(PresenceBudget).ShouldBeTrue();
second.Send(actor, new StartDeployment("kim", CorrelationId.NewId()));
second.ExpectMsg<StartDeploymentResult>(AckBudget).Outcome.ShouldBe(StartDeploymentOutcome.AnotherDeploymentInFlight);
gated.Release();
first.ExpectMsg<StartDeploymentResult>(PresenceBudget).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
}
private sealed class BlockingProbe : IDriverProbe
{
private readonly TaskCompletionSource<DriverProbeResult> _tcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public ManualResetEventSlim Started { get; } = new();
public string DriverType => "Blocking";
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
Started.Set();
return await _tcs.Task.WaitAsync(ct);
}
public void Release(DriverProbeResult result) => _tcs.TrySetResult(result);
}
/// <summary>
/// Passes synchronous context creation through (the serialised in-flight check) and parks the
/// asynchronous one (the off-actor compose) on a gate while <see cref="Hold"/> is in effect.
/// </summary>
private sealed class GatedDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private volatile TaskCompletionSource? _gate;
public ManualResetEventSlim Entered { get; } = new();
public void Hold() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
public void Release() => _gate?.TrySetResult();
public OtOpcUaConfigDbContext CreateDbContext() => inner.CreateDbContext();
public async Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
{
Entered.Set();
if (_gate is { } gate) await gate.Task.WaitAsync(cancellationToken);
return inner.CreateDbContext();
}
}
}
@@ -0,0 +1,68 @@
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.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:4.1 — admin operations echoed <c>ex.Message</c> to the UI, so a SqlException's connection-string
/// fragment reached any ConfigEditor browser session. The reply is now a fixed sentence plus the request's
/// correlation id; the detail stays in the central log.
/// </summary>
public sealed class AdminOperationsActorServerFaultMessageTests : ControlPlaneActorTestBase
{
private const string Secret = "Server=sql-prod-01;Password=hunter2";
[Fact]
public void StartDeployment_server_fault_reply_carries_the_correlation_id_and_not_the_exception_text()
{
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(new ThrowingDbFactory(), coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
var correlationId = CorrelationId.NewId();
actor.Tell(new StartDeployment("joe", correlationId));
var reply = ExpectMsg<StartDeploymentResult>(PresenceBudget);
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
reply.Message.ShouldNotBeNull();
reply.Message.ShouldNotContain("hunter2");
reply.Message.ShouldNotContain("sql-prod-01");
reply.Message.ShouldContain(correlationId.ToString()); // the operator can find the log line
}
[Fact]
public void TestDriverConnect_probe_throw_reply_carries_the_correlation_id_and_not_the_exception_text()
{
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), coordinator.Ref, new IDriverProbe[] { new ThrowingProbe() }));
var correlationId = Guid.NewGuid();
actor.Tell(new TestDriverConnect("Throwing", "{}", 5, correlationId));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull();
reply.Message.ShouldNotContain("hunter2");
reply.Message.ShouldContain(correlationId.ToString());
}
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
throw new InvalidOperationException("Cannot open database: " + Secret);
}
private sealed class ThrowingProbe : IDriverProbe
{
public string DriverType => "Throwing";
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) =>
throw new InvalidOperationException("probe blew up: " + Secret);
}
}
@@ -0,0 +1,133 @@
using System.IO.Compression;
using System.Text;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
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.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:3.2 — <c>StartDeployment</c> used to read the whole config DB twice: once for the validator,
/// once for the composer. An edit landing between the two shipped an artifact the validator never saw.
/// The deploy now validates and composes ONE snapshot, so an edit that lands mid-deploy is either
/// validated (it was in the snapshot) or absent from the artifact (it was not) — never shipped unseen.
/// </summary>
public sealed class AdminOperationsActorSingleSnapshotTests : ControlPlaneActorTestBase
{
private const string LateEquipmentId = "EQ-late-and-NOT-canonical";
/// <summary>
/// An Equipment with a non-canonical id (the validator rejects those with <c>EquipmentIdNotDerived</c>)
/// is committed by another context while the snapshot read is in progress, AFTER the Equipment table
/// has been read. The validator cannot see it, so the deploy is Accepted — and the artifact must not
/// carry it either. Positive control: the row really landed in the DB, and a deploy started after it
/// is Rejected on exactly that rule.
/// </summary>
[Fact]
public void Edit_landing_between_validate_and_compose_cannot_ship_unvalidated()
{
// One shared root: an InMemory store is scoped to the internal service provider, and a context
// carrying an interceptor builds its own provider — without the shared root the intercepting
// context would read an EMPTY database and the whole test would be vacuous.
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var plain = new RootedDbFactory(dbName, root, interceptor: null);
using (var db = plain.CreateDbContext())
{
// A Tag to materialise — Tags is read AFTER Equipment by the snapshot factory, so an insert
// fired on the first Tag lands "between" the two tables of one read.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "d", ClusterId = "", Name = "drv", DriverType = "Modbus", DriverConfig = "{}",
});
db.Devices.Add(new Device { DeviceId = "dev", DriverInstanceId = "d", Name = "dev", DeviceConfig = "{}" });
db.Tags.Add(new Tag
{
TagId = "tag-speed", DeviceId = "dev", Name = "speed", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
});
db.SaveChanges();
}
var injecting = new RootedDbFactory(dbName, root, new LateEquipmentInterceptor(plain));
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(injecting, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var reply = ExpectMsg<StartDeploymentResult>(PresenceBudget);
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, reply.Message);
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
using (var verify = plain.CreateDbContext())
{
// The injection happened...
verify.Equipment.Any(e => e.EquipmentId == LateEquipmentId).ShouldBeTrue();
// ...and the shipped artifact does not contain what the validator never saw.
var row = verify.Deployments.Single();
Inflate(row.ArtifactBlob).ShouldNotContain(LateEquipmentId);
// The coordinator is a probe here, so seal the row by hand to let the next deploy through.
row.Status = DeploymentStatus.Sealed;
verify.SaveChanges();
}
// Positive control: now that the row is visible to a fresh read, the gate rejects it.
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var second = ExpectMsg<StartDeploymentResult>(PresenceBudget);
second.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
second.Message.ShouldNotBeNull();
second.Message.ShouldContain("EquipmentIdNotDerived");
}
private static string Inflate(byte[] blob)
{
using var gz = new GZipStream(new MemoryStream(blob), CompressionMode.Decompress);
using var reader = new StreamReader(gz, Encoding.UTF8);
return reader.ReadToEnd();
}
/// <summary>On the first Tag materialised, commits a non-canonical Equipment through ANOTHER context.</summary>
private sealed class LateEquipmentInterceptor(IDbContextFactory<OtOpcUaConfigDbContext> other)
: IMaterializationInterceptor
{
private int _fired;
public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
{
if (entity is Tag && Interlocked.Exchange(ref _fired, 1) == 0)
{
using var db = other.CreateDbContext();
db.Equipment.Add(new Equipment
{
EquipmentUuid = Guid.NewGuid(), EquipmentId = LateEquipmentId, Name = "late",
UnsLineId = "line-a", MachineCode = "m",
});
db.SaveChanges();
}
return entity;
}
}
private sealed class RootedDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor? interceptor)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext()
{
var builder = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName, root);
if (interceptor is not null) builder.AddInterceptors(interceptor);
return new OtOpcUaConfigDbContext(builder.Options);
}
}
}
@@ -0,0 +1,191 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Update;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:2.6 — one constraint hit used to drop the whole flush. The writer now detaches the rejected
/// row(s) and re-saves, so a duplicate <c>EventId</c> costs exactly that row.
/// </summary>
public sealed class AuditWriterActorRejectedRowTests : ControlPlaneActorTestBase
{
[Fact]
public void A_rejected_row_is_dropped_and_the_rest_of_the_batch_is_persisted()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var duplicate = Guid.NewGuid();
var rejecting = new RejectingDbFactory(dbName, root, new AlwaysRejectBatchContaining(duplicate));
var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting));
var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList();
ids.Insert(7, duplicate);
foreach (var id in ids) actor.Tell(NewEvent(id));
Watch(actor);
actor.Tell(PoisonPill.Instance); // PostStop flushes
ExpectTerminated(actor);
using var db = rejecting.CreateDbContext();
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
persisted.Count.ShouldBe(20, "every row but the rejected one lands");
persisted.ShouldNotContain(duplicate);
// Positive control: the rows on either side of the rejected one are present.
persisted.ShouldContain(ids[6]);
persisted.ShouldContain(ids[8]);
}
/// <summary>
/// Fix round 1 / I4 — a FAITHFUL SQL Server shape: the provider sends the batch as one statement batch,
/// so the <see cref="DbUpdateException"/> for one duplicate names EVERY entry in it. Detaching
/// <c>ex.Entries</c> therefore dropped the whole flush. The writer must drop only the rows whose
/// EventId is already persisted and keep the rest.
/// </summary>
[Fact]
public void A_whole_batch_rejection_drops_only_the_rows_already_persisted()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var factory = new RejectingDbFactory(dbName, root, new RejectWholeBatchOnDuplicate());
var duplicate = Guid.NewGuid();
using (var seed = factory.CreateDbContext())
{
seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate });
seed.SaveChanges();
}
var actor = Sys.ActorOf(AuditWriterActor.Props(factory));
var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList();
ids.Insert(7, duplicate);
foreach (var id in ids) actor.Tell(NewEvent(id));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor);
using var db = factory.CreateDbContext();
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
persisted.Count.ShouldBe(21, "the 20 new rows land beside the one that was already there");
persisted.Count(id => id == duplicate).ShouldBe(1);
db.ConfigAuditLogs.Single(r => r.EventId == duplicate).Principal.ShouldBe("earlier");
}
/// <summary>The same contract against the REAL provider and the real filtered unique index.</summary>
[Fact]
[Trait("Category", "SchemaCompliance")]
public void On_sql_server_a_duplicate_event_id_costs_only_that_row()
{
using var sql = new SqlServerTestDatabase();
var duplicate = Guid.NewGuid();
using (var seed = sql.NewContext())
{
seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate });
seed.SaveChanges();
}
var actor = Sys.ActorOf(AuditWriterActor.Props(sql.Factory()));
var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList();
ids.Insert(7, duplicate);
foreach (var id in ids) actor.Tell(NewEvent(id));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor, PresenceBudget);
using var db = sql.NewContext();
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
persisted.Count.ShouldBe(21);
persisted.Count(id => id == duplicate).ShouldBe(1);
foreach (var id in ids) persisted.ShouldContain(id);
}
/// <summary>A failure that names no entry is a whole-batch failure, exactly as before.</summary>
[Fact]
public void A_connection_level_failure_still_drops_the_batch_without_looping()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var rejecting = new RejectingDbFactory(dbName, root, new AlwaysFailWithoutEntries());
var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting));
for (var i = 0; i < 5; i++) actor.Tell(NewEvent(Guid.NewGuid()));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor); // terminates ⇒ the retry loop did not spin
using var db = rejecting.CreateDbContext();
db.ConfigAuditLogs.Count().ShouldBe(0);
}
private static AuditEvent NewEvent(Guid eventId) => new()
{
EventId = eventId, Category = "Config", Action = "Edit", Actor = "joe",
OccurredAtUtc = DateTimeOffset.UtcNow, Outcome = AuditOutcome.Success,
DetailsJson = "{}", SourceNode = "node-a", CorrelationId = Guid.NewGuid(),
};
/// <summary>
/// A row the database refuses on its own terms (not a duplicate — e.g. a per-row constraint), every
/// time it is in the save. Faithful to SQL Server's batch shape: the exception names EVERY added
/// entry, so only a row-by-row retry can isolate the offender.
/// </summary>
private sealed class AlwaysRejectBatchContaining(Guid eventId) : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
if (eventData.Context is null) return result;
var added = eventData.Context.ChangeTracker.Entries<ConfigAuditLog>()
.Where(e => e.State == EntityState.Added).ToList();
if (!added.Any(e => e.Entity.EventId == eventId)) return result;
#pragma warning disable EF1001
var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList();
#pragma warning restore EF1001
throw new DbUpdateException("The statement conflicted with a CHECK constraint (simulated, whole batch)", entries);
}
}
/// <summary>
/// Models SQL Server's batched insert: when ANY added row's EventId is already stored, the whole save
/// fails with an exception naming EVERY added entry (the InMemory provider enforces no unique index,
/// so this interceptor is the constraint).
/// </summary>
private sealed class RejectWholeBatchOnDuplicate : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
if (eventData.Context is not OtOpcUaConfigDbContext ctx) return result;
var added = ctx.ChangeTracker.Entries<ConfigAuditLog>().Where(e => e.State == EntityState.Added).ToList();
var ids = added.Select(e => e.Entity.EventId).Where(id => id != null).ToList();
if (ids.Count == 0 || !ctx.ConfigAuditLogs.AsNoTracking().Any(r => ids.Contains(r.EventId))) return result;
#pragma warning disable EF1001
var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList();
#pragma warning restore EF1001
throw new DbUpdateException("Cannot insert duplicate key row (simulated 2601, whole batch)", entries);
}
}
private sealed class AlwaysFailWithoutEntries : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result) =>
throw new DbUpdateException("connection lost (simulated)");
}
private sealed class RejectingDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor interceptor)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => new(
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName, root)
.AddInterceptors(interceptor)
.Options);
}
}
@@ -930,6 +930,34 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
/// <summary>A spawned gRPC-mode actor plus the two seams the tests observe it through.</summary>
private sealed record GrpcRig(IActorRef Actor, RecordingDialer Dialer, RecordingPskProvider Psk);
/// <summary>
/// R03:1.3 — every contact refresh hands the dialer the CURRENT endpoint set so it can release the
/// channels of endpoints that left it.
/// </summary>
[Fact]
public void Each_contact_refresh_tells_the_dialer_which_endpoints_remain()
{
var factory = NewInMemoryDbFactory();
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false), ("host-b:4053", "host-b", 4056, true, false));
var rig = SpawnGrpcRig(factory);
AwaitContactsLoaded(rig);
// The dialer is resolved lazily on first use; a dispatch forces that, and the NEXT refresh prunes.
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5));
using (var db = factory.CreateDbContext())
{
db.ClusterNodes.Single(n => n.NodeId == "host-b:4053").MaintenanceMode = true;
db.SaveChanges();
}
rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts());
AwaitCondition(
() => rig.Dialer.RetainCalls.Any(set => set.Count == 1 && set.Single().Contains("host-a")),
TimeSpan.FromSeconds(5));
rig.Dialer.RetainCalls.Last().ShouldBe(new[] { "http://host-a:4056" });
}
private GrpcRig SpawnGrpcRig(
IDbContextFactory<OtOpcUaConfigDbContext> factory,
RecordingDialer? dialer = null,
@@ -1014,6 +1042,19 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
: Task.CompletedTask;
}
/// <summary>Every endpoint set handed to <see cref="RetainOnly"/>, in order.</summary>
public IReadOnlyList<IReadOnlyCollection<string>> RetainCalls
{
get { lock (_gate) return _retainCalls.ToList(); }
}
private readonly List<IReadOnlyCollection<string>> _retainCalls = [];
public void RetainOnly(IReadOnlyCollection<string> nodeEndpoints)
{
lock (_gate) _retainCalls.Add(nodeEndpoints.ToList());
}
/// <summary>One recorded dial.</summary>
public sealed record Dial(string Endpoint, CommandEnvelope Envelope, string ClusterId);
}
@@ -224,6 +224,35 @@ public sealed class MeshGrpcCommandDialerTests
Should.NotThrow(dialer.Dispose);
}
/// <summary>
/// R03:1.3 — a channel for an endpoint that left the dispatch set (host renamed, port moved) is
/// released on the next refresh instead of living, with its idle connection, for the life of central.
/// Positive control: a dial to that endpoint afterwards rebuilds a channel.
/// </summary>
[Fact]
public async Task RetainOnly_releases_channels_for_endpoints_that_left_the_dispatch_set()
{
var dialer = new MeshGrpcCommandDialer(
new FakePskProvider(("SITE-A", "psk-site-a")),
Options.Create(new MeshTransportOptions { DispatchTimeoutSeconds = 1 }),
NullLogger<MeshGrpcCommandDialer>.Instance);
foreach (var endpoint in new[] { "http://127.0.0.1:1", "http://127.0.0.1:2" })
{
await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync(endpoint, Envelope(), "SITE-A", CancellationToken.None));
}
dialer.CachedChannelCount.ShouldBe(2);
dialer.RetainOnly(new[] { "http://127.0.0.1:1" });
dialer.CachedChannelCount.ShouldBe(1);
await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync("http://127.0.0.1:2", Envelope(), "SITE-A", CancellationToken.None));
dialer.CachedChannelCount.ShouldBe(2, "a returning endpoint gets a fresh channel");
Should.NotThrow(dialer.Dispose);
}
[Fact]
public void It_activates_from_the_container_despite_the_optional_client_factory_parameter()
{
@@ -0,0 +1,200 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication;
/// <summary>
/// R03:3.4 — central invalidated every cached key on every contact refresh (default 60 s), so the
/// first inbound ack per cluster per minute blocked a gRPC thread on a synchronous, 3 s-bounded store
/// read and was denied past the bound. <see cref="MeshPskProvider.RefreshAsync"/> pre-resolves the
/// keys off the request path and swaps them in atomically.
/// </summary>
public sealed class MeshPskProviderRefreshTests
{
private static MeshPskProvider Provider(ISecretResolver resolver) =>
new(Options.Create(new MeshTransportOptions()), resolver, NullLogger<MeshPskProvider>.Instance);
[Fact]
public async Task After_a_refresh_the_lookup_is_served_without_touching_the_store()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
resolver.Calls.ShouldBe(1);
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("k-1");
resolver.Calls.ShouldBe(1, "the request path did not consult the store");
provider.Matches("SITE-A", "k-1").ShouldBeTrue();
}
/// <summary>
/// While a refresh is in flight the PREVIOUS key is still served (no invalidate-then-reread window);
/// once it completes the rotated key is served. Positive control on both sides of the swap.
/// </summary>
[Fact]
public async Task A_rotated_key_is_swapped_in_atomically_with_the_old_key_served_until_then()
{
var resolver = new GatedResolver { Value = "old" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out var before).ShouldBeTrue();
before.ShouldBe("old");
resolver.Value = "new";
resolver.Hold();
var refresh = provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
resolver.Entered.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue();
// Mid-refresh: the old key, from memory — the store is NOT re-read on this path.
var callsMidRefresh = resolver.Calls;
provider.TryGetPsk("SITE-A", out var during).ShouldBeTrue();
during.ShouldBe("old");
resolver.Calls.ShouldBe(callsMidRefresh);
resolver.Release();
await refresh;
provider.TryGetPsk("SITE-A", out var after).ShouldBeTrue();
after.ShouldBe("new");
}
[Fact]
public async Task A_cluster_whose_refresh_fails_keeps_its_previous_key()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
resolver.Throw = new InvalidOperationException("store offline");
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("k-1");
}
/// <summary>
/// Fix round 1 / I8 — "the store says there is no such secret" is a REVOCATION, not a read failure:
/// the previous key must stop being served (from the refreshed snapshot AND the lazy cache). A thrown
/// read keeps the previous key (the test above); a definitive not-found drops it.
/// </summary>
[Fact]
public async Task A_secret_deleted_from_the_store_revokes_the_key_on_the_next_refresh()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.Matches("SITE-A", "k-1").ShouldBeTrue("positive control: the key was in service");
resolver.Value = null; // the secret was deleted
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out _).ShouldBeFalse();
provider.Matches("SITE-A", "k-1").ShouldBeFalse();
}
/// <summary>The same revocation reaches a key that was first served from the LAZY cache, not a refresh.</summary>
[Fact]
public async Task A_deleted_secret_also_evicts_a_lazily_cached_key()
{
var resolver = new GatedResolver { Value = "k-lazy" };
var provider = Provider(resolver);
provider.TryGetPsk("SITE-A", out var lazy).ShouldBeTrue();
lazy.ShouldBe("k-lazy");
resolver.Value = null;
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out _).ShouldBeFalse();
}
/// <summary>Fix round 1 minor — the refresh fans out to the store with BOUNDED concurrency, not one read per cluster at once.</summary>
[Fact]
public async Task Refresh_store_reads_are_concurrency_bounded()
{
var resolver = new ConcurrencyTrackingResolver();
var provider = Provider(resolver);
var clusters = Enumerable.Range(0, 40).Select(i => $"SITE-{i}").ToList();
await provider.RefreshAsync(clusters, CancellationToken.None);
resolver.Calls.ShouldBe(40);
resolver.MaxConcurrent.ShouldBeLessThanOrEqualTo(MeshPskProvider.MaxConcurrentRefreshReads);
resolver.MaxConcurrent.ShouldBeGreaterThan(1, "still concurrent, just bounded");
foreach (var c in clusters) provider.TryGetPsk(c, out _).ShouldBeTrue();
}
private sealed class ConcurrencyTrackingResolver : ISecretResolver
{
private int _inFlight;
private int _max;
private int _calls;
public int MaxConcurrent => Volatile.Read(ref _max);
public int Calls => Volatile.Read(ref _calls);
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
var now = Interlocked.Increment(ref _inFlight);
int seen;
while (now > (seen = Volatile.Read(ref _max)) && Interlocked.CompareExchange(ref _max, now, seen) != seen) { }
await Task.Delay(20, ct);
Interlocked.Decrement(ref _inFlight);
return "k-" + name.Value;
}
}
/// <summary>A cluster that was never refreshed still resolves lazily on the request path (unchanged behaviour).</summary>
[Fact]
public async Task A_cluster_outside_the_refresh_set_still_resolves_lazily()
{
var resolver = new GatedResolver { Value = "k-b" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-B", out var psk).ShouldBeTrue();
psk.ShouldBe("k-b");
resolver.Calls.ShouldBe(2);
}
[Fact]
public async Task InvalidateAll_drops_refreshed_keys_too()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.InvalidateAll();
resolver.Value = "k-2";
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("k-2");
}
private sealed class GatedResolver : ISecretResolver
{
private int _calls;
private volatile TaskCompletionSource? _gate;
public string? Value { get; set; }
public Exception? Throw { get; set; }
public int Calls => Volatile.Read(ref _calls);
public ManualResetEventSlim Entered { get; } = new();
public void Hold() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
public void Release() => _gate?.TrySetResult();
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
Entered.Set();
if (_gate is { } gate) await gate.Task.WaitAsync(ct);
if (Throw is not null) throw Throw;
return Value;
}
}
}
@@ -0,0 +1,87 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Fix round 1 minor — revision-hash stability across the R03:3.2 refactor. The pre-change composer read
/// every table with a SQL <c>ORDER BY</c> (the database's case-INSENSITIVE collation); the refactor re-sorted
/// in memory with an ORDINAL comparer, which orders mixed-case keys differently ("B-1" before "a-2"). The
/// same configuration would then hash differently after the upgrade: drift detection reports every node
/// drifted, and a re-deploy of an unchanged config shows a "new" revision. This pins the composer's output
/// BYTE-identical to a verbatim replica of the pre-change composer on the real provider.
/// </summary>
[Trait("Category", "SchemaCompliance")]
public sealed class ConfigComposerSqlServerHashStabilityTests
{
[Fact]
public async Task Composer_output_is_byte_identical_to_the_pre_change_composer_for_mixed_case_keys()
{
using var sql = new SqlServerTestDatabase();
await using (var seed = sql.NewContext())
{
// Collation order a-2, B-1, c-3; ordinal order B-1, a-2, c-3.
foreach (var id in new[] { "c-3", "B-1", "a-2" })
seed.Scripts.Add(new Script { ScriptId = id, Name = id, SourceCode = "return 1;", SourceHash = id });
await seed.SaveChangesAsync();
}
await using var legacyDb = sql.NewContext();
var legacy = await LegacySnapshotAndFlattenAsync(legacyDb);
await using var viaDb = sql.NewContext();
var current = await ConfigComposer.SnapshotAndFlattenAsync(viaDb);
current.Blob.ShouldBe(legacy.Blob);
current.RevisionHash.ShouldBe(legacy.RevisionHash);
// The deploy path's form (validate + compose from ONE snapshot) must agree too.
await using var deployDb = sql.NewContext();
var viaSnapshot = ConfigComposer.Compose(await DraftSnapshotFactory.FromConfigDbAsync(deployDb));
viaSnapshot.RevisionHash.ShouldBe(legacy.RevisionHash);
}
/// <summary>Verbatim replica of <c>ConfigComposer.SnapshotAndFlattenAsync</c> before R03:3.2 (ad2b5c40).</summary>
private static async Task<(byte[] Blob, string RevisionHash)> LegacySnapshotAndFlattenAsync(OtOpcUaConfigDbContext db)
{
var ct = CancellationToken.None;
var snapshot = new
{
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
};
var json = JsonSerializer.SerializeToUtf8Bytes(snapshot, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
});
using var output = new MemoryStream(json.Length / 8);
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true))
gzip.Write(json, 0, json.Length);
var blob = output.ToArray();
return (blob, Convert.ToHexStringLower(SHA256.HashData(blob)));
}
}
@@ -222,6 +222,47 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
withoutOverride.ResilienceConfig.ShouldBeNull("an unset override stays null ⇒ tier defaults at spawn");
}
/// <summary>
/// R03:3.2 — the deploy path composes from the <see cref="Configuration.Validation.DraftSnapshot"/> it
/// validated rather than re-reading the DB. <see cref="ConfigComposer.Compose"/> over the factory's
/// snapshot must therefore be byte-identical to the DB overload, or the drift probe (which still
/// uses the DB overload) would report every deployment as drifted.
/// </summary>
[Fact]
public async Task Compose_over_the_factory_snapshot_is_byte_identical_to_the_db_overload()
{
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ApiKeys.Add(new ApiKey
{
ApiKeyId = "k1", Name = "scada", SecretHash = "h", Principal = "scadabridge", CreatedUtc = FixedTimestamp,
});
db.Scripts.Add(new Script { ScriptId = "s-1", Name = "s", SourceCode = "return 1;", SourceHash = "x" });
await db.SaveChangesAsync();
}
var viaDb = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var snapshot = await Configuration.Validation.DraftSnapshotFactory.FromConfigDbAsync(f.CreateDbContext());
var viaSnapshot = ConfigComposer.Compose(snapshot);
viaSnapshot.RevisionHash.ShouldBe(viaDb.RevisionHash);
viaSnapshot.Blob.ShouldBe(viaDb.Blob);
// The composed content is the snapshot's, not something re-read: the keys and clusters are in it.
var json = Inflate(viaSnapshot.Blob);
json.ShouldContain("\"ApiKeyId\":\"k1\"");
json.IndexOf("cluster-a", StringComparison.Ordinal).ShouldBeLessThan(json.IndexOf("cluster-b", StringComparison.Ordinal));
}
private static string Inflate(byte[] blob)
{
using var gz = new GZipStream(new MemoryStream(blob), CompressionMode.Decompress);
using var reader = new StreamReader(gz, Encoding.UTF8);
return reader.ReadToEnd();
}
private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static ServerCluster NewCluster(string id) => new()
@@ -0,0 +1,440 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
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.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Review 03 §2.2: a <c>Deployment</c> row left in <c>Dispatching</c> with NO <c>NodeDeploymentState</c>
/// rows — <c>HandleDispatch</c> threw before its <c>SaveChanges</c>, or the process died between the
/// AdminOps row write and the coordinator's <c>Tell</c> — used to be recovered with an EMPTY expected
/// set, at which point the deadline hit the <c>0 == 0</c> "race" branch and returned without touching
/// the row. Every later <c>StartDeployment</c> was then refused <c>AnotherDeploymentInFlight</c> forever.
/// </summary>
public sealed class ConfigPublishCoordinatorRecoveryTests : ControlPlaneActorTestBase
{
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('c', 64));
private static readonly TimeSpan NoTimeout = TimeSpan.FromMinutes(5);
/// <summary>
/// A lost dispatch that is still inside the apply deadline is re-driven: PreStart re-dispatches it,
/// the expected set is seeded from the enabled rows, and the node's ack seals it.
/// </summary>
[Fact]
public void Recovered_row_with_no_expected_set_is_redispatched_and_then_seals()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow);
SeedNodes(dbFactory, "site-a-1:4053");
// No DispatchDeployment is ever sent — PreStart alone has to recover it.
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
}
/// <summary>
/// A lost dispatch that is already past the apply deadline is closed as <c>TimedOut</c> with a reason
/// naming the condition — never left in <c>Dispatching</c> — and the next deployment proceeds
/// (positive control: it is dispatched, acked and sealed through the same coordinator).
/// </summary>
[Fact]
public void Recovered_row_past_deadline_with_no_expected_set_is_timed_out_not_wedged()
{
var dbFactory = NewInMemoryDbFactory();
var staleId = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow.AddMinutes(-10));
SeedNodes(dbFactory, "site-a-1:4053");
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
var row = db.Deployments.Single(d => d.DeploymentId == staleId.Value);
row.Status.ShouldBe(DeploymentStatus.TimedOut);
row.FailureReason.ShouldNotBeNull();
row.FailureReason.ShouldContain("lost");
}, duration: PresenceBudget);
// Positive control: the coordinator is not wedged — a fresh deployment runs end to end.
var nextId = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow);
actor.Tell(new DispatchDeployment(nextId, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == nextId.Value).Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
actor.Tell(new ApplyAck(nextId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == nextId.Value).Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
}
/// <summary>
/// An <c>AwaitingApplyAcks</c> row with no rows (the process died between the status flip and the
/// empty-fleet seal) is re-driven too, and with no enabled nodes it seals empty exactly as a fresh
/// dispatch would — the recovery keys on the missing expected set, not on the status value.
/// </summary>
[Fact]
public void Recovered_AwaitingApplyAcks_row_with_no_rows_and_no_fleet_seals_empty()
{
var dbFactory = NewInMemoryDbFactory();
SeedDeployment(dbFactory, DeploymentStatus.AwaitingApplyAcks, DateTime.UtcNow);
Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
db.NodeDeploymentStates.ShouldBeEmpty();
}, duration: PresenceBudget);
}
/// <summary>
/// The brief's scenario end to end: <c>HandleDispatch</c> throws once (a transient fault seeding the
/// expected set). The coordinator restarts, recovers the <c>Dispatching</c> row, re-dispatches it and
/// the deployment completes; a second <c>StartDeployment</c> is then Accepted (positive control: a
/// normal deploy seals).
/// </summary>
[Fact]
public async Task HandleDispatch_throw_is_recovered_and_the_next_StartDeployment_succeeds()
{
var dbName = Guid.NewGuid().ToString("N");
var adminFactory = NewInMemoryDbFactory(dbName);
var faulting = new OneShotFaultingDbFactory(NewInMemoryDbFactory(dbName));
SeedNodes(adminFactory, "site-a-1:4053");
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(faulting, NoTimeout));
var admin = Sys.ActorOf(AdminOperationsActor.Props(adminFactory, coordinator, Enumerable.Empty<IDriverProbe>()));
// Arm the fault AFTER PreStart has run (it would otherwise fault the recovery read itself and
// the actor would be stopped, not restarted). Identify is processed after creation, so a
// resolved selection proves PreStart completed.
await Sys.ActorSelection(coordinator.Path).ResolveOne(PresenceBudget);
faulting.FailNext();
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var first = ExpectMsg<StartDeploymentResult>(PresenceBudget);
first.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
first.DeploymentId.ShouldNotBeNull();
// The fault fired, the actor restarted, PreStart re-dispatched: the expected set lands.
AwaitAssert(() =>
{
faulting.Faulted.ShouldBeTrue();
using var db = adminFactory.CreateDbContext();
db.NodeDeploymentStates.Count(s => s.DeploymentId == first.DeploymentId.Value.Value).ShouldBe(1);
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
coordinator.Tell(new ApplyAck(first.DeploymentId.Value, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = adminFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
// Positive control: nothing is wedged — the next deploy is Accepted and seals too.
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var second = ExpectMsg<StartDeploymentResult>(PresenceBudget);
second.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
coordinator.Tell(new ApplyAck(second.DeploymentId!.Value, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = adminFactory.CreateDbContext();
db.Deployments.Count(d => d.Status == DeploymentStatus.Sealed).ShouldBe(2);
}, duration: PresenceBudget);
}
/// <summary>
/// Fix round 1 / I7 — the window PreStart cannot see: the Dispatching row is committed AFTER the
/// coordinator's PreStart ran, and the coordinator is never told (the admin singleton died between
/// its commit and the Tell). Nothing would ever close it. The in-flight check now closes a
/// Dispatching row with NO expected-ack rows once it is past the apply deadline, so the next
/// StartDeployment is Accepted (and seals — positive control on the pipeline).
/// </summary>
[Fact]
public async Task Lost_dispatch_committed_after_PreStart_is_closed_by_the_next_StartDeployment()
{
var dbFactory = NewInMemoryDbFactory();
SeedNodes(dbFactory, "site-a-1:4053");
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
await Sys.ActorSelection(coordinator.Path).ResolveOne(PresenceBudget); // PreStart saw nothing
var stale = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow.AddMinutes(-10));
var admin = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator, Enumerable.Empty<IDriverProbe>(), applyDeadline: NoTimeout));
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var result = ExpectMsg<StartDeploymentResult>(PresenceBudget);
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, result.Message);
using (var db = dbFactory.CreateDbContext())
{
var row = db.Deployments.Single(d => d.DeploymentId == stale.Value);
row.Status.ShouldBe(DeploymentStatus.TimedOut);
row.FailureReason.ShouldNotBeNull();
row.FailureReason.ShouldContain("lost");
}
coordinator.Tell(new ApplyAck(result.DeploymentId!.Value, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == result.DeploymentId.Value.Value).Status
.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
}
/// <summary>Control for the closure: the same row INSIDE the deadline is still in flight and still refuses.</summary>
[Fact]
public void Dispatching_row_inside_the_deadline_still_refuses_a_new_deployment()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe();
var pending = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow.AddSeconds(-5));
var admin = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), applyDeadline: NoTimeout));
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var result = ExpectMsg<StartDeploymentResult>(PresenceBudget);
result.Outcome.ShouldBe(StartDeploymentOutcome.AnotherDeploymentInFlight);
result.DeploymentId.ShouldBe(pending);
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Dispatching);
}
/// <summary>
/// Fix round 1 minor — a late or duplicated <c>DispatchDeployment</c> for a row that is already
/// terminal (it raced the lost-dispatch closure above, or a re-delivery after a seal) must not flip
/// it back to AwaitingApplyAcks or seed an expected set. Positive control: a live row dispatched right
/// after it IS seeded (so the first message was processed, not merely not-yet-processed).
/// </summary>
[Theory]
[InlineData(DeploymentStatus.Sealed)]
[InlineData(DeploymentStatus.TimedOut)]
[InlineData(DeploymentStatus.PartiallyFailed)]
public void Dispatch_of_a_terminal_row_is_ignored(DeploymentStatus terminal)
{
var dbFactory = NewInMemoryDbFactory();
SeedNodes(dbFactory, "site-a-1:4053");
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
var done = SeedDeployment(dbFactory, terminal, DateTime.UtcNow.AddMinutes(-1));
coordinator.Tell(new DispatchDeployment(done, TestRevision, CorrelationId.NewId()));
var live = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow);
coordinator.Tell(new DispatchDeployment(live, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == live.Value).Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
using var verify = dbFactory.CreateDbContext();
verify.Deployments.Single(d => d.DeploymentId == done.Value).Status.ShouldBe(terminal);
verify.NodeDeploymentStates.Count(s => s.DeploymentId == done.Value).ShouldBe(0);
}
/// <summary>
/// Fix round 1 minor — status transitions update a blob-free stub (key + RowVersion) instead of
/// materialising the multi-MB row. Proven on the REAL provider, where the stub's RowVersion is the
/// optimistic-concurrency predicate: dispatch → ack → Sealed lands, and the artifact bytes are
/// untouched by the partial UPDATEs.
/// </summary>
[Fact]
[Trait("Category", "SchemaCompliance")]
public void On_sql_server_status_transitions_do_not_touch_the_artifact_blob()
{
using var sql = new SqlServerTestDatabase();
var factory = sql.Factory();
SeedNodes(factory, "site-a-1:4053");
var blob = Enumerable.Range(0, 4096).Select(i => (byte)i).ToArray();
var id = DeploymentId.NewId();
using (var db = sql.NewContext())
{
db.Deployments.Add(new Deployment
{
DeploymentId = id.Value, RevisionHash = TestRevision.Value, Status = DeploymentStatus.Dispatching,
CreatedBy = "test", ArtifactBlob = blob,
});
db.SaveChanges();
}
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(factory, NoTimeout));
AwaitAssert(() =>
{
using var db = sql.NewContext();
db.Deployments.AsNoTracking().Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget); // PreStart re-drives the un-dispatched row
coordinator.Tell(new ApplyAck(id, NodeId.Parse("site-a-1:4053"), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = sql.NewContext();
var row = db.Deployments.AsNoTracking().Single();
row.Status.ShouldBe(DeploymentStatus.Sealed);
row.SealedAtUtc.ShouldNotBeNull();
row.ArtifactBlob.ShouldBe(blob);
}, duration: PresenceBudget);
}
/// <summary>
/// Re-review 1 — the reopen race. AdminOps' lost-dispatch closure commits TimedOut AFTER the coordinator's
/// terminal guard read the row as Dispatching but BEFORE the AwaitingApplyAcks flip. The flip is
/// conditioned on the guard's RowVersion, so on SQL Server it becomes a concurrency conflict and the row
/// stays TimedOut with no expected set. Positive control: the coordinator is not wedged — the next live
/// dispatch is seeded and flipped.
/// </summary>
[Fact]
[Trait("Category", "SchemaCompliance")]
public async Task On_sql_server_a_closure_between_guard_and_flip_is_not_reopened()
{
using var sql = new SqlServerTestDatabase();
var inner = sql.Factory();
SeedNodes(inner, "site-a-1:4053");
var interposer = new InterposingDbFactory(inner);
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(interposer, NoTimeout));
await Sys.ActorSelection(coordinator.Path).ResolveOne(PresenceBudget); // PreStart ran over an empty table
// The race proper: a new Dispatching row whose dispatch is interleaved with an AdminOps-style closure.
var victim = SeedDeployment(inner, DeploymentStatus.Dispatching, DateTime.UtcNow);
interposer.OnSecondCreate(() =>
{
using var db = sql.NewContext();
db.Database.ExecuteSqlRaw(
"UPDATE Deployment SET Status = {0}, FailureReason = N'closed as lost' WHERE DeploymentId = {1}",
(int)DeploymentStatus.TimedOut, victim.Value);
});
coordinator.Tell(new DispatchDeployment(victim, TestRevision, CorrelationId.NewId()));
AwaitAssert(() => interposer.Fired.ShouldBeTrue(), duration: PresenceBudget);
// Positive control + ordering: a live row dispatched after it IS flipped, so the victim's dispatch was processed.
var live = SeedDeployment(inner, DeploymentStatus.Dispatching, DateTime.UtcNow);
coordinator.Tell(new DispatchDeployment(live, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = sql.NewContext();
db.Deployments.AsNoTracking().Single(d => d.DeploymentId == live.Value).Status
.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
using var verify = sql.NewContext();
verify.Deployments.AsNoTracking().Single(d => d.DeploymentId == victim.Value).Status
.ShouldBe(DeploymentStatus.TimedOut, "the closure must not be reopened");
verify.NodeDeploymentStates.Count(s => s.DeploymentId == victim.Value).ShouldBe(0);
}
/// <summary>Runs an action just before handing out the SECOND context after arming (HandleDispatch: guard, then flip).</summary>
private sealed class InterposingDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private Action? _action;
private int _countdown;
public volatile bool Fired;
public void OnSecondCreate(Action action)
{
_action = action;
Volatile.Write(ref _countdown, 2);
}
public OtOpcUaConfigDbContext CreateDbContext()
{
if (Volatile.Read(ref _countdown) > 0 && Interlocked.Decrement(ref _countdown) == 0)
{
_action?.Invoke();
Fired = true;
}
return inner.CreateDbContext();
}
}
private static DeploymentId SeedDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentStatus status, DateTime createdAtUtc)
{
var id = DeploymentId.NewId();
using var db = dbFactory.CreateDbContext();
db.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = TestRevision.Value,
Status = status,
CreatedBy = "test",
CreatedAtUtc = createdAtUtc,
});
db.SaveChanges();
return id;
}
private static void SeedNodes(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params string[] nodeIds)
{
using var db = dbFactory.CreateDbContext();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "site-a",
NodeCount = (byte)nodeIds.Length, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
foreach (var nodeId in nodeIds)
{
db.ClusterNodes.Add(new ClusterNode
{
NodeId = nodeId, ClusterId = "SITE-A", Host = nodeId.Split(':')[0],
ApplicationUri = $"urn:OtOpcUa:{nodeId}", Enabled = true, CreatedBy = "test",
});
}
db.SaveChanges();
}
/// <summary>Throws from exactly one <c>CreateDbContext</c> call after <see cref="FailNext"/>.</summary>
private sealed class OneShotFaultingDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private int _armed;
public bool Faulted { get; private set; }
public void FailNext() => Interlocked.Exchange(ref _armed, 1);
public OtOpcUaConfigDbContext CreateDbContext()
{
if (Interlocked.Exchange(ref _armed, 0) == 1)
{
Faulted = true;
throw new InvalidOperationException("injected transient fault seeding the expected set");
}
return inner.CreateDbContext();
}
}
}
@@ -0,0 +1,233 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Retention;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:1.2 — every deploy stored the whole fleet snapshot forever. The retention sweep empties the
/// blob on old terminal rows (never deletes a row, never touches an in-flight one, never the newest N,
/// never one a node applied recently).
/// </summary>
public sealed class DeploymentRetentionTests : ControlPlaneActorTestBase
{
private static readonly DateTime SweepNow = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Sweep_empties_blobs_beyond_the_keep_window_and_leaves_the_newest_intact()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 12, mixInTimedOut: false); // ids[0] newest … ids[11] oldest
var options = new DeploymentRetentionOptions { KeepSealed = 5, ReferencedDays = 30, MaxRowsPerSweep = 200 };
int pruned;
await using (var db = f.CreateDbContext())
pruned = await DeploymentRetentionActor.SweepAsync(db, options, SweepNow);
pruned.ShouldBe(7);
await using var verify = f.CreateDbContext();
foreach (var id in ids.Take(5))
(await verify.Deployments.SingleAsync(d => d.DeploymentId == id)).ArtifactBlob.Length.ShouldBeGreaterThan(0, "newest 5 keep their blob");
foreach (var id in ids.Skip(5))
(await verify.Deployments.SingleAsync(d => d.DeploymentId == id)).ArtifactBlob.ShouldBeEmpty("older rows are emptied");
// Rows survive: the audit record is intact.
(await verify.Deployments.CountAsync()).ShouldBe(12);
// Idempotent: a second sweep finds nothing.
await using var again = f.CreateDbContext();
(await DeploymentRetentionActor.SweepAsync(again, options, SweepNow)).ShouldBe(0);
}
[Fact]
public async Task Sweep_keeps_a_row_a_node_applied_recently_and_never_touches_an_in_flight_row()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 4);
var inFlight = Guid.NewGuid();
await using (var db = f.CreateDbContext())
{
db.ClusterNodes.Add(new ClusterNode
{
NodeId = "site-a-1:4053", ClusterId = "SITE-A", Host = "site-a-1",
ApplicationUri = "urn:x", Enabled = true, CreatedBy = "test",
});
// ids[3] is the oldest; a node applied it yesterday, so it stays.
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = "site-a-1:4053", DeploymentId = ids[3], Status = NodeDeploymentStatus.Applied,
StartedAtUtc = SweepNow.AddDays(-1), AppliedAtUtc = SweepNow.AddDays(-1),
});
// ids[2]: applied long ago — not protected.
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = "site-a-1:4053", DeploymentId = ids[2], Status = NodeDeploymentStatus.Applied,
StartedAtUtc = SweepNow.AddDays(-90), AppliedAtUtc = SweepNow.AddDays(-90),
});
// An ancient in-flight row with a blob — never touched.
db.Deployments.Add(new Deployment
{
DeploymentId = inFlight, RevisionHash = new string('f', 64), Status = DeploymentStatus.AwaitingApplyAcks,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddDays(-400), ArtifactBlob = new byte[] { 1, 2, 3 },
});
await db.SaveChangesAsync();
}
var options = new DeploymentRetentionOptions { KeepSealed = 1, ReferencedDays = 30 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(2); // ids[1], ids[2]
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[0])).ArtifactBlob.ShouldNotBeEmpty("newest");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[1])).ArtifactBlob.ShouldBeEmpty();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[2])).ArtifactBlob.ShouldBeEmpty("applied 90 days ago");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[3])).ArtifactBlob.ShouldNotBeEmpty("applied yesterday");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == inFlight)).ArtifactBlob.ShouldNotBeEmpty("in flight");
}
/// <summary>
/// Fix round 1 / I3 — the keep window counts SEALED rows, not "terminal" ones. A burst of 60 failed
/// deploys used to fill a 50-row terminal window on its own and push the fleet's last good (Sealed)
/// artifact out of it — the one every boot / drift / fallback path reads. The newest Sealed blob is
/// never pruned; the unreferenced TimedOut rows are (positive control: the sweep did run).
/// </summary>
[Fact]
public async Task Sixty_timed_out_deploys_never_prune_the_newest_sealed_blob()
{
var f = NewInMemoryDbFactory();
var sealedId = Guid.NewGuid();
var timedOut = new List<Guid>();
await using (var db = f.CreateDbContext())
{
db.Deployments.Add(new Deployment
{
DeploymentId = sealedId, RevisionHash = new string('s', 64), Status = DeploymentStatus.Sealed,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddDays(-200), SealedAtUtc = SweepNow.AddDays(-200),
ArtifactBlob = new byte[] { 1, 2, 3 },
});
for (var i = 0; i < 60; i++)
{
var id = Guid.NewGuid();
timedOut.Add(id);
db.Deployments.Add(new Deployment
{
DeploymentId = id, RevisionHash = new string('t', 64), Status = DeploymentStatus.TimedOut,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddHours(-i), ArtifactBlob = new byte[] { 9 },
});
}
await db.SaveChangesAsync();
}
var options = new DeploymentRetentionOptions { KeepSealed = 50, ReferencedDays = 30, MaxRowsPerSweep = 200 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(60);
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == sealedId)).ArtifactBlob
.ShouldNotBeEmpty("the newest Sealed artifact is never pruned");
(await verify.Deployments.CountAsync(d => timedOut.Contains(d.DeploymentId) && d.ArtifactBlob.Length == 0))
.ShouldBe(60);
}
/// <summary>Even with KeepSealed misconfigured to 0, the newest Sealed row keeps its blob.</summary>
[Fact]
public async Task The_newest_sealed_blob_survives_a_zero_keep_window()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 3, mixInTimedOut: false);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, new DeploymentRetentionOptions { KeepSealed = 0 }, SweepNow))
.ShouldBe(2);
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[0])).ArtifactBlob.ShouldNotBeEmpty();
}
[Fact]
public async Task Sweep_is_bounded_per_call_and_drains_a_backlog_over_calls()
{
var f = NewInMemoryDbFactory();
Seed(f, sealedCount: 10);
var options = new DeploymentRetentionOptions { KeepSealed = 2, MaxRowsPerSweep = 3 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(3);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(3);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(2);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(0);
}
/// <summary>The actor runs the same rule off its thread and reports back.</summary>
[Fact]
public void Actor_sweeps_on_request_and_reports_the_count()
{
var f = NewInMemoryDbFactory();
Seed(f, sealedCount: 6);
var actor = Sys.ActorOf(DeploymentRetentionActor.Props(f, new DeploymentRetentionOptions
{
KeepSealed = 4, InitialDelaySeconds = 3600, IntervalMinutes = 60,
}));
actor.Tell(DeploymentRetentionActor.Sweep.Instance);
ExpectMsg<DeploymentRetentionActor.SweepCompleted>(PresenceBudget).Pruned.ShouldBe(2);
using var verify = f.CreateDbContext();
verify.Deployments.Count(d => d.ArtifactBlob.Length == 0).ShouldBe(2);
}
[Fact]
public void Options_bind_from_configuration_with_defaults()
{
var defaults = DeploymentRetentionOptions.Bind(null);
defaults.Enabled.ShouldBeTrue();
defaults.KeepSealed.ShouldBe(50);
defaults.ReferencedDays.ShouldBe(30);
defaults.IntervalMinutes.ShouldBe(60);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Deployments:Retention:KeepSealed"] = "7",
["Deployments:Retention:Enabled"] = "false",
})
.Build();
var bound = DeploymentRetentionOptions.Bind(config);
bound.KeepSealed.ShouldBe(7);
bound.Enabled.ShouldBeFalse();
bound.ReferencedDays.ShouldBe(30);
}
/// <summary>Seeds sealed deployments, newest first; each one hour older than the previous.</summary>
private static List<Guid> Seed(IDbContextFactory<OtOpcUaConfigDbContext> f, int sealedCount, bool mixInTimedOut = true)
{
var ids = new List<Guid>();
using var db = f.CreateDbContext();
for (var i = 0; i < sealedCount; i++)
{
var id = Guid.NewGuid();
ids.Add(id);
db.Deployments.Add(new Deployment
{
DeploymentId = id,
RevisionHash = new string('a', 64),
Status = mixInTimedOut && i % 3 == 2 ? DeploymentStatus.TimedOut : DeploymentStatus.Sealed,
CreatedBy = "test",
CreatedAtUtc = SweepNow.AddHours(-i),
SealedAtUtc = SweepNow.AddHours(-i),
ArtifactBlob = new byte[] { 0x1f, 0x8b, (byte)i },
});
}
db.SaveChanges();
return ids;
}
}
@@ -0,0 +1,77 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
/// <summary>
/// A migrated, throw-away config database on the central SQL Server, for the few ControlPlane behaviours
/// the InMemory provider cannot model (a SQL Server <c>DbUpdateException</c> names the WHOLE statement
/// batch; collation-ordered reads). Same server / credentials / env overrides as
/// <c>Configuration.Tests.SchemaComplianceFixture</c> (<c>OTOPCUA_CONFIG_TEST_SERVER</c>,
/// <c>OTOPCUA_CONFIG_TEST_SA_PASSWORD</c>; default the shared docker host's dev SA), and the same database
/// name shape, so that suite's 24 h orphan reaper also reclaims a database a killed run leaks here.
/// </summary>
public sealed class SqlServerTestDatabase : IDisposable
{
private const string DefaultServer = "10.100.0.35,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
/// <summary>Gets the connection string of the migrated database.</summary>
public string ConnectionString { get; }
private readonly string _databaseName;
/// <summary>Creates and migrates the database.</summary>
public SqlServerTestDatabase()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
_databaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
ConnectionString =
$"Server={server};Database={_databaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;";
using var ctx = NewContext();
ctx.Database.Migrate();
}
/// <summary>A new context on the database.</summary>
/// <returns>The context.</returns>
public OtOpcUaConfigDbContext NewContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseSqlServer(ConnectionString).Options);
/// <summary>A factory over <see cref="NewContext"/>.</summary>
/// <returns>The factory.</returns>
public IDbContextFactory<OtOpcUaConfigDbContext> Factory() => new ContextFactory(this);
/// <summary>Drops the database (best effort; a leak is reaped by the Configuration suite).</summary>
public void Dispose()
{
try
{
SqlConnection.ClearAllPools();
var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString;
using var conn = new SqlConnection(master);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
DECLARE @sql nvarchar(max);
IF DB_ID(@name) IS NOT NULL
BEGIN
SET @sql = N'ALTER DATABASE ' + QUOTENAME(@name) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE; ' +
N'DROP DATABASE ' + QUOTENAME(@name) + N';';
EXEC sp_executesql @sql;
END";
cmd.Parameters.Add(new SqlParameter("@name", _databaseName));
cmd.ExecuteNonQuery();
}
catch (Exception)
{
// Best effort — see the class remarks.
}
}
private sealed class ContextFactory(SqlServerTestDatabase owner) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => owner.NewContext();
}
}
@@ -0,0 +1,64 @@
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// <see cref="RoslynVirtualTagEvaluator"/> runs every compiled expression through a
/// <see cref="ScriptRunGuard"/> keyed by virtual-tag id: a runaway expression leaks one orphaned pool
/// thread, not one per trigger, later triggers are refused, the tag is quarantined after three consecutive timeouts (surfacing
/// as a <c>Failure</c> the actor publishes Bad for), and the quarantine lifts at the deploy boundary.
/// </summary>
public sealed class RoslynVirtualTagEvaluatorRunGuardTests
{
private static ScriptRootLogger NoOpScriptRoot() => new(new LoggerConfiguration().CreateLogger());
private const string SpinScript =
"var end = System.DateTime.UtcNow.AddSeconds(6); while (System.DateTime.UtcNow < end) { } return 0;";
private static readonly Dictionary<string, object?> NoDeps = new();
[Fact]
public void Runaway_expression_is_refused_while_its_orphan_runs_and_a_sibling_keeps_evaluating()
{
using var sut = new RoslynVirtualTagEvaluator(
NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(60));
var first = sut.Evaluate("vt-loop", SpinScript, NoDeps);
first.Success.ShouldBeFalse();
first.Reason.ShouldNotBeNull();
first.Reason.ShouldContain("timed out");
// Refusals, not strikes (fix round 1, I1): the tag stays Bad but is not quarantined by them.
sut.Evaluate("vt-loop", SpinScript, NoDeps).Reason.ShouldBe(ScriptRunGuard.PreviousRunStillRunningReason);
sut.Evaluate("vt-loop", SpinScript, NoDeps).Reason.ShouldBe(ScriptRunGuard.PreviousRunStillRunningReason);
sut.QuarantinedScripts.ShouldBeEmpty();
sut.RunGuard.StartedRunCount.ShouldBe(1, "one orphan, not one per trigger");
var sibling = sut.Evaluate("vt-ok", "return 42;", NoDeps);
sibling.Success.ShouldBeTrue(sibling.Reason);
sibling.Value.ShouldBe(42);
}
[Fact]
public void ResetQuarantines_lifts_the_quarantine_as_a_redeploy_does()
{
using var sut = new RoslynVirtualTagEvaluator(
NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(60),
new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = 1 });
sut.Evaluate("vt-loop", SpinScript, NoDeps).Reason!.ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
sut.QuarantinedScripts.ShouldNotBeEmpty();
sut.ResetQuarantines();
sut.QuarantinedScripts.ShouldBeEmpty();
var fixedRun = sut.Evaluate("vt-loop", "return 7;", NoDeps);
fixedRun.Success.ShouldBeTrue(fixedRun.Reason);
fixedRun.Value.ShouldBe(7);
}
}
@@ -489,12 +489,18 @@ public sealed class RoslynVirtualTagEvaluatorTests
// most evaluations then hit the warm cache instead of recompiling. Neither the cadence nor the
// iteration count is a correctness knob any more — the assertions below hold for any number of
// clears landing inside a single evaluation, and for any number of evaluations completed.
var evalLoops = Enumerable.Range(0, loopCount).Select(_ => Task.Run(() =>
// One virtual-tag id PER LOOP, sharing the expression. The race under test lives in the compile
// cache, which is keyed by source, so twelve ids on one expression contend for exactly the entry
// twelve copies of one id did — while twelve concurrent evaluations of ONE id is a shape production
// never produces (one VirtualTagActor per id serialises them) and exactly the shape the per-tag
// ScriptRunGuard refuses ("previous evaluation still running", then quarantine).
var evalLoops = Enumerable.Range(0, loopCount).Select(loop => Task.Run(() =>
{
var vtagId = $"vt-race-{loop}";
var done = 0;
for (var i = 0; i < MaxIterationsPerLoop && Environment.TickCount64 < stopAtMs; i++)
{
var r = sut.Evaluate("vt-race", expr, new Dictionary<string, object?> { ["a"] = i });
var r = sut.Evaluate(vtagId, expr, new Dictionary<string, object?> { ["a"] = i });
done++;
if (!r.Success)
{
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// R03:2.3 — an unset or placeholder pepper must fail host start, not surface as "Invalid API key"
/// on the first activation.
/// </summary>
public sealed class ApiKeyOptionsValidatorTests
{
private static ValidateOptionsResult Validate(ApiKeyOptions options) =>
new ApiKeyOptionsValidator().Validate(Options.DefaultName, options);
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Blank_pepper_fails(string pepper)
{
var result = Validate(new ApiKeyOptions { Pepper = pepper });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull();
result.FailureMessage.ShouldContain("Pepper is required");
}
[Fact]
public void Short_pepper_fails()
{
var result = Validate(new ApiKeyOptions { Pepper = "x" });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull();
result.FailureMessage.ShouldContain($"at least {ApiKeyOptionsValidator.MinPepperLength}");
}
[Fact]
public void Blank_username_fails()
{
var result = Validate(new ApiKeyOptions { Pepper = new string('p', 32), Username = " " });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull();
result.FailureMessage.ShouldContain("Username");
}
/// <summary>Positive control: the docker-dev rig's pepper and the defaults pass unchanged.</summary>
[Fact]
public void Configured_pepper_passes()
{
Validate(new ApiKeyOptions { Pepper = "docker-dev-apikey-pepper-not-for-production" })
.Succeeded.ShouldBeTrue();
}
}
@@ -49,6 +49,41 @@ public sealed class ActiveTierRegistrationTests
ClusterPrimaryRegistration(hasAdmin).Tags.ShouldContain(ZbHealthTags.Active);
}
/// <summary>
/// R03:2.1 + fix round 1 / I6 (coordinator ruling) — the OPC UA endpoint probe's tiers by role:
/// <list type="bullet">
/// <item>driver-only: Ready + Active (it cannot serve and must not be routed to);</item>
/// <item>fused admin+driver: Ready ONLY, until the ServiceLevel calculator consumes the fact
/// (F-C1) — Traefik routes the AdminUI on <c>/health/active</c>, so a failed SDK on the active
/// central node must not take the AdminUI out of rotation;</item>
/// <item>admin-only: absent (it starts no endpoint to be degraded by).</item>
/// </list>
/// </summary>
[Theory]
[InlineData(false, true, true, true)]
[InlineData(true, true, true, false)]
[InlineData(true, false, false, false)]
public void Opcua_endpoint_probe_tiers_follow_the_node_roles(bool hasAdmin, bool hasDriver, bool ready, bool active)
{
var registrations = new ServiceCollection()
.AddOtOpcUaHealth(hasAdmin: hasAdmin, hasDriver: hasDriver)
.BuildServiceProvider()
.GetRequiredService<IOptions<HealthCheckServiceOptions>>()
.Value.Registrations
.Where(r => r.Name == OpcUaEndpointHealthCheck.Name)
.ToList();
if (!ready && !active)
{
registrations.ShouldBeEmpty();
return;
}
var registration = registrations.ShouldHaveSingleItem();
registration.Tags.Contains(ZbHealthTags.Ready).ShouldBe(ready);
registration.Tags.Contains(ZbHealthTags.Active).ShouldBe(active);
}
[Fact]
public void Active_tier_check_is_not_on_the_ready_tier()
{
@@ -0,0 +1,203 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// R03:3.3 — the negative cache was keyed by key id, so a flood of DISTINCT well-formed ids cost one
/// SQL query per attempt under the SessionManager lock, no credential needed. Two guards:
/// the known-id set (unknown ids refused in memory) and the miss budget (a token bucket on lookups
/// that reach the store and miss, for when the set is unavailable).
/// </summary>
public sealed class BoundedApiKeyStoreKnownIdTests
{
private static readonly DateTimeOffset T0 = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task An_unknown_id_is_refused_without_reaching_the_store_and_a_known_one_still_does()
{
var inner = new ListingStore("k-real");
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 5);
(await sut.FindAsync("k-guess-1", T0.UtcDateTime, default)).ShouldBeNull();
(await sut.FindAsync("k-guess-2", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(0, "unknown ids never reach the store");
inner.ListCalls.ShouldBe(1, "one set load serves every unknown id inside the TTL");
// Positive control: the real id reaches the store and is found.
(await sut.FindAsync("k-real", T0.UtcDateTime, default)).ShouldNotBeNull();
inner.FindCalls.ShouldBe(1);
}
[Fact]
public async Task The_set_is_refreshed_after_the_TTL_so_a_newly_minted_key_becomes_visible()
{
var inner = new ListingStore("k-old");
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 5);
(await sut.FindAsync("k-new", T0.UtcDateTime, default)).ShouldBeNull();
inner.Mint("k-new");
// Still inside the TTL: the stale set refuses it (the documented window).
clock.Advance(TimeSpan.FromSeconds(3));
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(0);
clock.Advance(TimeSpan.FromSeconds(3));
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
inner.ListCalls.ShouldBe(2);
}
[Fact]
public async Task A_failed_set_load_falls_back_to_the_per_id_lookup_and_keeps_an_older_set()
{
var inner = new ListingStore("k-real") { ListThrows = true };
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 5);
// No set yet and the load fails: the per-id path runs (old behaviour), nothing is wedged.
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(1);
sut.KnownKeyIds.ShouldBeNull();
inner.ListThrows = false;
clock.Advance(TimeSpan.FromSeconds(6));
(await sut.FindAsync("k-guess-2", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
sut.KnownKeyIds.ShouldNotBeNull();
inner.FindCalls.ShouldBe(1, "once the set loaded, the guess was refused in memory");
// A later failed refresh keeps the previous set in force.
inner.ListThrows = true;
clock.Advance(TimeSpan.FromSeconds(6));
(await sut.FindAsync("k-guess-3", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(1);
}
/// <summary>With no id set available, misses are rate-limited; hits are not charged; time refills.</summary>
[Fact]
public async Task The_miss_budget_bounds_store_misses_when_no_set_is_available()
{
var inner = new ListingStore("k-real") { SupportsListing = false };
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 0, missBurst: 3, refillPerSecond: 1);
for (var i = 0; i < 3; i++)
(await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(3);
// Budget exhausted: refused without a store read, as a throw the authenticator denies on.
await Should.ThrowAsync<InvalidOperationException>(() => sut.FindAsync("k-guess-9", T0.UtcDateTime, default));
inner.FindCalls.ShouldBe(3);
// Refill: one second restores one token.
clock.Advance(TimeSpan.FromSeconds(1));
(await sut.FindAsync("k-guess-10", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(4);
// A hit is not a miss: it reaches the store even with the bucket empty? No — it needs a token to
// be attempted, but the token is refunded, so the next attempt still has it. Prove the refund.
clock.Advance(TimeSpan.FromSeconds(1));
(await sut.FindAsync("k-real", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
(await sut.FindAsync("k-guess-11", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(6);
}
[Fact]
public async Task The_guards_are_off_when_the_cache_is_off_restoring_a_read_per_lookup()
{
var inner = new ListingStore("k-real");
var sut = Bounded(inner, new FakeClock(T0), cacheSeconds: 0, missBurst: 0);
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(2);
inner.ListCalls.ShouldBe(0);
}
/// <summary>
/// Fix round 1 minor — an inner store that cannot list ids switches the known-id set off for THIS
/// decorator only. It used to do so by writing <c>KnownIdSet = false</c> into the guard options it was
/// handed, a shared options object, so one store's capability leaked into every other consumer.
/// Positive control: a second decorator over a listing store on the same options instance still uses
/// the set.
/// </summary>
[Fact]
public async Task An_unlisting_store_does_not_mutate_the_shared_guard_options()
{
var guard = new ApiKeyStoreGuardOptions { MissBurst = 100, MissRefillPerSecond = 20 };
var options = Options.Create(new ApiKeyOptions
{
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 5,
});
var clock = new FakeClock(T0);
var unlisting = new ListingStore("k-real") { SupportsListing = false };
var first = new BoundedApiKeyStore(unlisting, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
(await first.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
unlisting.FindCalls.ShouldBe(1, "no set: the per-id path ran");
guard.KnownIdSet.ShouldBeTrue("the shared options object is never written");
var listing = new ListingStore("k-real");
var second = new BoundedApiKeyStore(listing, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
(await second.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
listing.FindCalls.ShouldBe(0, "the second decorator still refuses unknown ids in memory");
listing.ListCalls.ShouldBe(1);
}
private static BoundedApiKeyStore Bounded(
IApiKeyStore inner, TimeProvider clock, int cacheSeconds, int missBurst = 100, double refillPerSecond = 20) => new(
inner,
Options.Create(new ApiKeyOptions
{
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = cacheSeconds,
}),
NullLogger<BoundedApiKeyStore>.Instance,
clock,
new ApiKeyStoreGuardOptions { MissBurst = missBurst, MissRefillPerSecond = refillPerSecond });
private sealed class ListingStore(params string[] ids) : IApiKeyStore
{
private readonly HashSet<string> _ids = ids.ToHashSet(StringComparer.Ordinal);
private int _findCalls;
private int _listCalls;
public bool SupportsListing { get; set; } = true;
public bool ListThrows { get; set; }
public int FindCalls => _findCalls;
public int ListCalls => _listCalls;
public void Mint(string id) => _ids.Add(id);
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
Interlocked.Increment(ref _findCalls);
return Task.FromResult(_ids.Contains(apiKeyId)
? new ApiKeyLookup(new ApiKey { ApiKeyId = apiKeyId, Name = "k", SecretHash = "h", Principal = "p" }, [])
: null);
}
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
public Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct)
{
if (!SupportsListing) return Task.FromResult<IReadOnlySet<string>?>(null);
Interlocked.Increment(ref _listCalls);
if (ListThrows) throw new InvalidOperationException("ConfigDb unreachable (simulated)");
return Task.FromResult<IReadOnlySet<string>?>(_ids.ToHashSet(StringComparer.Ordinal));
}
}
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _now = utcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}
@@ -363,7 +363,10 @@ public sealed class BoundedApiKeyStoreTests
{
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);
// The miss budget (R03:3.3) is OFF here on purpose: this fact floods 1025 distinct unknown ids — the
// exact shape the budget refuses — to pin the cache CEILING, which is a separate rule.
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock,
guard: new ApiKeyStoreGuardOptions { MissBurst = 0 });
// 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();
@@ -477,7 +480,8 @@ public sealed class BoundedApiKeyStoreTests
int timeoutMs,
int cacheSeconds,
TimeProvider? clock = null,
ILogger<BoundedApiKeyStore>? log = null) => new(
ILogger<BoundedApiKeyStore>? log = null,
ApiKeyStoreGuardOptions? guard = null) => new(
inner,
Options.Create(new ApiKeyOptions
{
@@ -487,7 +491,8 @@ public sealed class BoundedApiKeyStoreTests
StoreCacheSeconds = cacheSeconds,
}),
log ?? NullLogger<BoundedApiKeyStore>.Instance,
clock ?? TimeProvider.System);
clock ?? TimeProvider.System,
guard);
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store,
@@ -0,0 +1,213 @@
using System.Net;
using System.Net.Sockets;
using Akka.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
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.Health;
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>
/// R03:2.1 / 1.4 — a failed OPC UA SDK start used to be one Error line: the node stayed Ready, joined
/// its mesh, and acked deploys through the Null sink while clients got connection-refused. The start
/// outcome is now a fact (<see cref="IOpcUaEndpointState"/>) the ready/active tiers read, a driver-only
/// node rethrows so the process exits, and <c>StopAsync</c> actually stops the SDK.
/// </summary>
public sealed class OtOpcUaServerHostedServiceStartFailureTests
{
private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(30);
/// <summary>
/// Port already bound ⇒ the SDK cannot listen ⇒ the state is Failed, the probe is Unhealthy and the
/// real <c>/health/ready</c> endpoint answers 503 — while StartAsync itself returns (fused node).
/// Positive control: the same endpoint answers 200 once the state is Started.
/// </summary>
[Fact]
public async Task Port_in_use_marks_the_endpoint_failed_and_health_ready_answers_503()
{
using var deadline = new CancellationTokenSource(Deadline);
using var squatter = SquatAllInterfaces(out var port);
var pki = NewPki();
var state = new OpcUaEndpointState();
var fused = EffectiveRoles.Resolve(new[] { "admin", "driver" }, envRoles: null);
var service = NewService(port, pki, state, fused);
try
{
await service.StartAsync(deadline.Token); // must NOT throw on a fused node
state.Status.ShouldBe(OpcUaEndpointStatus.Failed);
state.FailureReason.ShouldNotBeNullOrWhiteSpace();
var probe = await new OpcUaEndpointHealthCheck(state).CheckHealthAsync(new HealthCheckContext());
probe.Status.ShouldBe(HealthStatus.Unhealthy);
probe.Description.ShouldNotBeNull();
probe.Description.ShouldContain("failed to start");
await using var web = BuildHealthHost(state);
await web.StartAsync(deadline.Token);
using var http = new HttpClient { BaseAddress = new Uri(web.Urls.First()) };
(await http.GetAsync("/health/ready", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
(await http.GetAsync("/health/active", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
// Positive control on the endpoint: the same host answers 200 for a started endpoint, so the
// 503 above measured the probe and not a broken health pipeline.
state.MarkStarted();
(await http.GetAsync("/health/ready", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.OK);
await web.StopAsync(deadline.Token);
}
finally
{
await service.DisposeAsync();
squatter.Stop();
Cleanup(pki);
}
}
/// <summary>A driver-only node has nothing else to stay up for: the failed start propagates.</summary>
[Fact]
public async Task Driver_only_node_rethrows_when_the_sdk_fails_to_start()
{
using var deadline = new CancellationTokenSource(Deadline);
using var squatter = SquatAllInterfaces(out var port);
var pki = NewPki();
var state = new OpcUaEndpointState();
var driverOnly = EffectiveRoles.Resolve(new[] { "driver" }, envRoles: null);
var service = NewService(port, pki, state, driverOnly);
try
{
await Should.ThrowAsync<Exception>(() => service.StartAsync(deadline.Token));
state.Status.ShouldBe(OpcUaEndpointStatus.Failed);
}
finally
{
await service.DisposeAsync();
squatter.Stop();
Cleanup(pki);
}
}
/// <summary>
/// A clean start is Started and listening; <c>StopAsync</c> closes the port (connection refused, not
/// a stale-but-listening endpoint) and marks Stopped; <c>DisposeAsync</c> twice afterwards is a no-op.
/// </summary>
[Fact]
public async Task StopAsync_closes_the_port_and_DisposeAsync_is_idempotent()
{
using var deadline = new CancellationTokenSource(Deadline);
var port = FreePort();
var pki = NewPki();
var state = new OpcUaEndpointState();
var service = NewService(port, pki, state, EffectiveRoles.Resolve(new[] { "driver" }, envRoles: null));
try
{
await service.StartAsync(deadline.Token);
state.Status.ShouldBe(OpcUaEndpointStatus.Started);
using (var client = new TcpClient())
{
await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token);
client.Connected.ShouldBeTrue("positive control: the endpoint is listening after StartAsync");
}
await service.StopAsync(deadline.Token);
state.Status.ShouldBe(OpcUaEndpointStatus.Stopped);
using (var client = new TcpClient())
{
await Should.ThrowAsync<SocketException>(async () => await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token));
}
await service.DisposeAsync();
await service.DisposeAsync(); // idempotent
}
finally
{
await service.DisposeAsync();
Cleanup(pki);
}
}
private static OtOpcUaServerHostedService NewService(int port, string pki, OpcUaEndpointState state, EffectiveRoles roles) =>
new(
Options.Create(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.StartFailure",
ApplicationUri = $"urn:OtOpcUa.StartFailure:{Guid.NewGuid():N}",
OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = pki,
}),
new DeferredAddressSpaceSink(), new DeferredServiceLevelPublisher(),
NullOpcUaUserAuthenticator.Instance, NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance, new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no actor system required"), new ActorRegistry(),
null, Options.Create(new AclOptions()), TimeProvider.System, NullLoggerFactory.Instance,
endpointState: state, roles: roles);
/// <summary>The production health registration (driver role) over the supplied state, on a loopback Kestrel.</summary>
private static WebApplication BuildHealthHost(OpcUaEndpointState state)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Services.AddSingleton<IOpcUaEndpointState>(state);
builder.Services.AddOtOpcUaHealth(hasAdmin: false, hasDriver: true);
// The akka + cluster-primary probes need a running ActorSystem; keep only the probe under test so
// the status measured is the endpoint's. (The registration shape itself is pinned separately.)
builder.Services.Configure<HealthCheckServiceOptions>(o =>
{
foreach (var r in o.Registrations.Where(r => r.Name != OpcUaEndpointHealthCheck.Name).ToList())
o.Registrations.Remove(r);
});
var app = builder.Build();
app.MapOtOpcUaHealth();
return app;
}
/// <summary>
/// Holds a port on the IPv4 AND IPv6 wildcard addresses. The SDK binds its listener on the
/// wildcards, and on macOS a loopback-only squatter does not conflict with a wildcard bind (both
/// sockets carry SO_REUSEADDR), so a loopback squatter would let the SDK start — the first run of
/// this test proved exactly that. The SDK also only fails when NEITHER family binds.
/// </summary>
private static TcpListener SquatAllInterfaces(out int port)
{
var squatter = new TcpListener(IPAddress.IPv6Any, 0);
squatter.Server.DualMode = true;
squatter.ExclusiveAddressUse = true;
squatter.Start();
port = ((IPEndPoint)squatter.LocalEndpoint).Port;
return squatter;
}
private static int FreePort()
{
using var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
private static string NewPki() => Path.Combine(Path.GetTempPath(), $"otopcua-startfail-{Guid.NewGuid():N}");
private static void Cleanup(string pki)
{
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
}
}
@@ -15,6 +15,28 @@ public sealed class DependencyMuxTagUpstreamSourceTests
private static DataValueSnapshot Good(object? value, DateTime ts)
=> new(value, 0u, ts, ts);
/// <summary>R02:L3 — the host prunes the retained snapshots to the loaded dependency-ref union after each
/// (re)load, so a renamed/removed raw path stops being retained; observers are untouched.</summary>
[Fact]
public void RetainOnly_drops_snapshots_outside_the_set_and_keeps_observers()
{
var src = new DependencyMuxTagUpstreamSource();
var ts = DateTime.UtcNow;
src.Push("keep", Good(1, ts));
src.Push("old/renamed", Good(2, ts));
var seen = new List<string>();
using var sub = src.SubscribeTag("old/renamed", (p, _) => seen.Add(p));
src.RetainOnly(new HashSet<string>(StringComparer.Ordinal) { "keep" }).ShouldBe(1);
src.RetainedPathCount.ShouldBe(1);
src.ReadTag("keep").Value.ShouldBe(1);
src.ReadTag("old/renamed").StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData);
src.Push("old/renamed", Good(3, ts));
// Pruning retained values does not unsubscribe anyone.
seen.ShouldBe(new[] { "old/renamed" });
}
[Fact]
public void Push_then_ReadTag_returns_the_pushed_snapshot()
{
@@ -0,0 +1,145 @@
using Akka.Actor;
using Akka.TestKit;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
/// <summary>
/// R02:H1 — a supervision restart of <see cref="ScriptedAlarmHostActor"/> used to dispose the
/// Props-captured engine and re-create the actor around the disposed instance, so every later
/// <c>ApplyScriptedAlarms</c> failed and the node's scripted alarms silently died. With the factory
/// Props each incarnation builds its own engine; with the legacy Props a restart only detaches. Both
/// shapes must load cleanly after a throw inside a <see cref="VirtualTagActor.DependencyValueBatch"/>
/// handler. Also R02:H3 — an inbound alarm command's engine op is bounded.
/// </summary>
public sealed class ScriptedAlarmHostActorRestartTests : RuntimeActorTestBase
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
private static EquipmentScriptedAlarmPlan Plan(string id = "alm-1", string depRef = "M.T") =>
new(ScriptedAlarmId: id, EquipmentId: "Plant/Line1/M", Name: "HighTemp", AlarmType: "AlarmCondition",
Severity: 800, MessageTemplate: "condition", PredicateScriptId: $"{id}-script",
PredicateSource: $"return (int)ctx.GetTag(\"{depRef}\").Value > 90;",
DependencyRefs: new[] { depRef }, HistorizeToAveva: true, Retain: true, Enabled: true);
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream, IAlarmStateStore? store = null)
{
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(upstream, store ?? new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
}
private static (DependencyMuxTagUpstreamSource, ScriptedAlarmEngine) Pair()
{
var up = new DependencyMuxTagUpstreamSource();
return (up, BuildEngine(up));
}
/// <summary>Apply, crash the handler (a null TagId makes <c>Push</c> throw → restart), apply again
/// and prove the second load registers interest and the alarm still activates.</summary>
private void RestartThenReload(IActorRef host, TestProbe publish, TestProbe mux)
{
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
// The crash: a batch whose item has a null TagId makes the upstream Push throw out of the handler
// (the documented, deliberately unguarded path) → supervision restarts the actor.
host.Tell(new VirtualTagActor.DependencyValueBatch(new[]
{
new VirtualTagActor.DependencyValueChanged(null!, 1, DateTime.UtcNow),
}));
mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>(Timeout); // PostStop of the crashed incarnation
// A later apply must load cleanly on the restarted incarnation…
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
reg.TagRefs.ShouldContain("M.T");
// …and the alarm must still work end to end.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
state.AlarmNodeId.ShouldBe("alm-1");
state.State.Active.ShouldBeTrue();
}
[Fact]
public void Factory_Props_survives_a_handler_throw_and_loads_cleanly_afterwards()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var built = 0;
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, () => { built++; return Pair(); }));
RestartThenReload(host, publish, mux);
built.ShouldBe(2, "the restarted incarnation built its own engine");
}
[Fact]
public void Legacy_instance_Props_survives_a_handler_throw_because_a_restart_only_detaches()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (upstream, engine) = Pair();
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine));
RestartThenReload(host, publish, mux);
}
[Fact]
public void An_alarm_command_whose_store_hangs_is_bounded_and_the_mailbox_moves_on()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var store = new HangingSaveStore();
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref,
() => { var up = new DependencyMuxTagUpstreamSource(); return (up, BuildEngine(up, store)); },
commandTimeout: TimeSpan.FromMilliseconds(300)));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout).State.Active.ShouldBeTrue();
store.HangNext = true;
host.Tell(new AlarmCommand("alm-1", "Acknowledge", "alice", "ack", null));
// The hung save would have suspended the mailbox indefinitely; the bound releases it, so the
// next dependency value is processed and the alarm clears.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout).State.Active.ShouldBeFalse();
store.ObservedCancellation.ShouldBeTrue();
}
/// <summary>Store whose next SaveAsync blocks until its token is cancelled.</summary>
private sealed class HangingSaveStore : IAlarmStateStore
{
private readonly InMemoryAlarmStateStore _inner = new();
public volatile bool HangNext;
public volatile bool ObservedCancellation;
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct) => _inner.LoadAsync(alarmId, ct);
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct) => _inner.LoadAllAsync(ct);
public Task RemoveAsync(string alarmId, CancellationToken ct) => _inner.RemoveAsync(alarmId, ct);
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
if (HangNext)
{
HangNext = false;
try { await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); }
catch (OperationCanceledException) { ObservedCancellation = true; throw; }
}
await _inner.SaveAsync(state, ct);
}
}
}
@@ -0,0 +1,184 @@
using System.Collections.Concurrent;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>
/// R07:H6 — the mux retains the last value of every ref a REPLAY-OPTING subscriber
/// (<see cref="DependencyMuxActor.RegisterInterest.ReplayRetained"/>) is registered for, keeps it for a
/// short grace after the last such subscriber leaves, and replays it to a registration that asks. A
/// Calculation driver Restart (new driver + new adapter) therefore republishes from current inputs instead
/// of sitting <c>BadWaitingForInitialData</c> until every input happens to change. Fix round 1 (I2): a
/// non-opting subscriber (the historization recorder) retains nothing, retention is released after the
/// grace, and a removed source's values are evicted so its last Good is never replayed.
/// </summary>
public sealed class DependencyMuxReplayTests : RuntimeActorTestBase
{
private static DriverInstanceActor.AttributeValueBatchPublished Burst(params (string Ref, object? Value)[] items) =>
new("driver-1", items.Select(i => new DriverInstanceActor.AttributeValueItem(i.Ref, i.Value, OpcUaStatus.Good, DateTime.UtcNow)).ToArray());
[Fact]
public void RegisterInterest_with_replay_receives_the_retained_values_for_its_refs_only()
{
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var first = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a", "b" }, first.Ref, ReplayRetained: true));
mux.Tell(Burst(("a", 1), ("b", 2), ("c", 3)));
first.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget).Items.Count.ShouldBe(2);
// The first subscriber goes away; retention must survive the unregister (that is the Restart shape).
mux.Tell(new DependencyMuxActor.UnregisterInterest(first.Ref));
var second = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a", "c" }, second.Ref, ReplayRetained: true));
var replay = second.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget);
replay.Items.Select(i => i.TagId).ShouldBe(new[] { "a" }, "'c' was never registered by a replay-opting subscriber, so it is not retained; 'b' is not in this subscriber's refs");
replay.Items[0].Value.ShouldBe(1);
}
[Fact]
public void RegisterInterest_without_replay_receives_nothing_and_a_fresh_ref_replays_nothing()
{
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var seed = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a" }, seed.Ref));
mux.Tell(Burst(("a", 1)));
seed.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget);
var opted = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a" }, opted.Ref)); // default: no replay
opted.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
var fresh = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "never-published" }, fresh.Ref, ReplayRetained: true));
fresh.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>I2 — a subscriber that did NOT opt into replay (the continuous-historization recorder registers
/// every historized ref) retains nothing; retention is bounded to the refs a replay-opting subscriber named.</summary>
[Fact]
public void Only_refs_named_by_a_replay_opting_subscriber_are_retained()
{
var mux = ActorOfAsTestActorRef<DependencyMuxActor>(DependencyMuxActor.Props());
var recorder = CreateTestProbe();
var calc = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "h1", "h2", "h3", "shared" }, recorder.Ref));
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "r", "shared" }, calc.Ref, ReplayRetained: true));
mux.Tell(Burst(("h1", 1), ("h2", 2), ("h3", 3), ("r", 4), ("shared", 5)));
recorder.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget).Items.Count.ShouldBe(4, "the recorder is still DELIVERED to");
calc.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget);
mux.UnderlyingActor.RetainedRefCount.ShouldBe(2, "only 'r' and 'shared' — the recorder-only refs retain nothing");
var late = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "h1", "r" }, late.Ref, ReplayRetained: true));
late.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget)
.Items.Select(i => i.TagId).ShouldBe(new[] { "r" }, "positive control: the opted-in ref replays; 'h1' was never retained");
// Unregistering everyone ends up with no entries at all once the grace is over — tested below; here
// the recorder leaving changes nothing about retention.
mux.Tell(new DependencyMuxActor.UnregisterInterest(recorder.Ref));
mux.UnderlyingActor.RetainedRefCount.ShouldBe(2);
}
/// <summary>I2 — when the last replay-opting subscriber of a ref leaves, its retained value survives a short
/// grace (the restart window: old adapter gone, new one not yet registered) and is then dropped.</summary>
[Fact]
public void Retention_is_released_after_the_grace_once_no_replay_subscriber_remains()
{
var mux = ActorOfAsTestActorRef<DependencyMuxActor>(DependencyMuxActor.Props(TimeSpan.FromMilliseconds(200)));
var gen1 = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a" }, gen1.Ref, ReplayRetained: true));
mux.Tell(Burst(("a", 1)));
gen1.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget);
mux.Tell(new DependencyMuxActor.UnregisterInterest(gen1.Ref));
// Positive control: inside the grace a returning subscriber still gets the value.
var gen2 = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a" }, gen2.Ref, ReplayRetained: true));
gen2.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget).Items.Single().Value.ShouldBe(1);
mux.Tell(new DependencyMuxActor.UnregisterInterest(gen2.Ref));
// Past the grace: nothing retained, no entry left behind, and a returning subscriber replays nothing.
AwaitAssert(() =>
{
mux.UnderlyingActor.RetainedRefCount.ShouldBe(0);
mux.UnderlyingActor.TrackedRefCount.ShouldBe(0);
}, PresenceBudget);
var gen3 = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a" }, gen3.Ref, ReplayRetained: true));
gen3.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>I2 — a removed source's retained values are evicted, so its last Good is never replayed to a
/// subscriber that (re)registers after the removal. Values from other sources are untouched.</summary>
[Fact]
public void SourceRemoved_evicts_that_sources_retained_values_only()
{
var mux = ActorOfAsTestActorRef<DependencyMuxActor>(DependencyMuxActor.Props());
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a", "b" }, sub.Ref, ReplayRetained: true));
mux.Tell(new DriverInstanceActor.AttributeValueBatchPublished("driver-1",
[new DriverInstanceActor.AttributeValueItem("a", 1, OpcUaStatus.Good, DateTime.UtcNow)]));
mux.Tell(new DriverInstanceActor.AttributeValueBatchPublished("driver-2",
[new DriverInstanceActor.AttributeValueItem("b", 2, OpcUaStatus.Good, DateTime.UtcNow)]));
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget);
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget);
mux.Tell(new DependencyMuxActor.SourceRemoved("driver-1"));
mux.UnderlyingActor.RetainedRefCount.ShouldBe(1);
var restarted = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a", "b" }, restarted.Ref, ReplayRetained: true));
restarted.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget)
.Items.Select(i => i.TagId).ShouldBe(new[] { "b" }, "driver-1 was removed: its last Good must not be replayed");
}
/// <summary>The user-facing scenario: a calc tag computes, the driver is restarted (new instance + new
/// adapter) with NO input changing afterwards, and the new instance republishes the current value.</summary>
[Fact]
public async Task A_Calculation_driver_restart_republishes_from_the_current_inputs()
{
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var tag = new RawTagEntry("calc/e/sum",
"{\"scriptId\":\"s\",\"changeTriggered\":true,\"scriptSource\":\"return (double)ctx.GetTag(\\\"src/a\\\").Value + (double)ctx.GetTag(\\\"src/b\\\").Value;\"}",
WriteIdempotent: false);
// Generation 1: wire the driver through the real adapter and feed a burst.
var gen1 = new CalculationDriver(new CalculationDriverOptions { RawTags = [tag] }, "calc-drv");
var published1 = new ConcurrentQueue<DataChangeEventArgs>();
gen1.OnDataChange += (_, e) => published1.Enqueue(e);
await gen1.InitializeAsync("{}", CancellationToken.None);
var adapter1 = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(gen1, mux));
AwaitAssert(() =>
{
mux.Tell(Burst(("src/a", 10.0), ("src/b", 20.0)));
published1.Count.ShouldBeGreaterThan(0);
}, PresenceBudget);
published1.Last().Snapshot.Value.ShouldBe(30.0);
// Restart: the old instance goes away; a NEW driver + adapter come up. Nothing changes upstream.
Sys.Stop(adapter1);
await gen1.ShutdownAsync(CancellationToken.None);
var gen2 = new CalculationDriver(new CalculationDriverOptions { RawTags = [tag] }, "calc-drv");
var published2 = new ConcurrentQueue<DataChangeEventArgs>();
gen2.OnDataChange += (_, e) => published2.Enqueue(e);
Sys.ActorOf(DependencyConsumerMuxAdapter.Props(gen2, mux)); // registers with ReplayRetained
await gen2.InitializeAsync("{}", CancellationToken.None);
// The replayed inputs satisfied the change-gate; the first trigger after init is the replay itself
// (if it lands post-init) or the next change (here: none) — so publish happens from the replay.
AwaitAssert(() => published2.Count.ShouldBeGreaterThan(0), PresenceBudget);
published2.Last().Snapshot.Value.ShouldBe(30.0);
published2.Last().Snapshot.StatusCode.ShouldBe(OpcUaStatus.Good);
}
}
@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>R02:P6 — the per-failure cluster-wide <see cref="ScriptLogEntry"/> is rate-limited per tag
/// (first N per window, then one summary); the Bad publish and the metric are unaffected.</summary>
public sealed class VirtualTagActorFailureLogRateLimitTests : RuntimeActorTestBase
{
[Fact]
public void Repeated_failures_publish_at_most_the_limit_then_one_summary_when_the_window_rolls()
{
var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var limiter = new ScriptLogRateLimiter(limitPerWindow: 3, window: TimeSpan.FromMinutes(1), clock: () => now);
var capture = new ConcurrentQueue<ScriptLogEntry>();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-chatty", "broken",
evaluator: new FailingEvaluator("boom"),
publisherFactory: () => new DPSPublisher((_, p) => capture.Enqueue((ScriptLogEntry)p)),
failureLogLimiter: limiter));
for (var i = 0; i < 10; i++)
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", i, DateTime.UtcNow));
// Positive control: the FIRST failure still degrades the node (once per transition, as before).
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget).StatusCode.ShouldBe(Commons.OpcUa.OpcUaStatus.BadInternalError);
AwaitAssert(() => capture.Count.ShouldBe(3), PresenceBudget);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
capture.Count.ShouldBe(3, "7 of 10 failures were suppressed from the cluster-wide log");
now = now.AddMinutes(1);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 99, DateTime.UtcNow));
AwaitAssert(() => capture.Count.ShouldBe(5), PresenceBudget);
var entries = capture.ToArray();
entries[3].Message.ShouldContain("7");
entries[3].Message.ShouldContain("suppressed");
entries[3].Message.ShouldContain("limit 3/window", Case.Sensitive, "the summary names the CONFIGURED limit, not the default");
entries[3].VirtualTagId.ShouldBe("vt-chatty");
entries[4].Message.ShouldBe("boom");
}
private sealed class FailingEvaluator(string reason) : IVirtualTagEvaluator
{
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Failure(reason);
}
}
@@ -0,0 +1,116 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.OtOpcUa.Security.Audit;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit;
/// <summary>
/// R03:2.5 — inside a Blazor circuit there is no <c>HttpContext</c> after the first render, so an
/// HTTP-only accessor stamps every audit row from an interactive page <c>"system"</c>. The circuit's
/// <see cref="AuthenticationStateProvider"/> is consulted first; the HTTP context is the fallback.
/// </summary>
public sealed class AuthenticationStateAuditActorAccessorTests
{
[Fact]
public void Circuit_principal_wins_when_there_is_no_http_context()
{
var accessor = new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(null),
new FixedStateProvider(Authenticated(new Claim(ZbClaimTypes.Username, "alice"))));
accessor.CurrentActor.ShouldBe("alice");
}
[Fact]
public void Http_principal_is_the_fallback_outside_a_circuit()
{
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "bob")) };
var accessor = new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), authenticationState: null);
accessor.CurrentActor.ShouldBe("bob");
}
[Fact]
public void Unauthenticated_circuit_falls_through_to_http_then_null()
{
var anonymousCircuit = new FixedStateProvider(new ClaimsPrincipal(new ClaimsIdentity()));
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(null), anonymousCircuit)
.CurrentActor.ShouldBeNull();
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Name, "carol")) };
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), anonymousCircuit)
.CurrentActor.ShouldBe("carol");
}
/// <summary>An incomplete state task is "no circuit identity", never a block.</summary>
[Fact]
public void Pending_authentication_state_is_not_awaited()
{
var accessor = new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(null),
new PendingStateProvider());
accessor.CurrentActor.ShouldBeNull();
}
/// <summary>
/// Fix round 1 / I5 — the REAL server-side provider, resolved in a non-circuit scope (a minimal-API
/// request, a background service), throws <see cref="InvalidOperationException"/> from
/// <c>GetAuthenticationStateAsync</c> because nothing called <c>SetAuthenticationState</c>. That used
/// to escape <c>CurrentActor</c>; it must fall back to the HTTP principal instead.
/// </summary>
[Fact]
public void Real_server_provider_outside_a_circuit_falls_back_to_the_http_principal()
{
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "dave")) };
var accessor = new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(context),
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider());
accessor.CurrentActor.ShouldBe("dave");
// No HTTP principal either ⇒ null, still no throw.
new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(null),
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider())
.CurrentActor.ShouldBeNull();
}
/// <summary>Positive control on the real provider: once a circuit has set its state, that principal wins.</summary>
[Fact]
public void Real_server_provider_inside_a_circuit_serves_the_circuit_principal()
{
var provider = new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider();
provider.SetAuthenticationState(Task.FromResult(
new AuthenticationState(Authenticated(new Claim(ZbClaimTypes.Username, "erin")))));
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "http-user")) };
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), provider)
.CurrentActor.ShouldBe("erin");
}
private static ClaimsPrincipal Authenticated(params Claim[] claims) =>
new(new ClaimsIdentity(claims, "TestScheme", ZbClaimTypes.Name, ZbClaimTypes.Role));
private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor
{
public HttpContext? HttpContext { get; set; } = context;
}
private sealed class FixedStateProvider(ClaimsPrincipal user) : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(user));
}
private sealed class PendingStateProvider : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
new TaskCompletionSource<AuthenticationState>().Task;
}
}
@@ -11,6 +11,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.AspNetCore;
@@ -37,6 +39,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
private IHost _host = null!;
private TestServer _server = null!;
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
private readonly CapturingLoggerProvider _logs = new();
private static CancellationToken Ct => TestContext.Current.CancellationToken;
@@ -70,6 +73,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
}).Build();
services.AddOtOpcUaAuth(configuration);
services.AddLogging(l => l.AddProvider(_logs));
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
// The login handler now resolves the DB role-map service via DI to merge
// DB-backed grants on top of the appsettings baseline. Register the stub so
@@ -96,6 +100,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
.ToArray();
return Results.Ok(claims);
}).RequireAuthorization();
// A role-gated machine endpoint: proves a bearer principal's roles resolve through
// IsInRole (the validation parameters pair RoleClaimType with the minted key).
e.MapGet("/api/viewer-only", () => Results.Ok("viewer"))
.RequireAuthorization(p => p.RequireRole("Viewer"));
});
});
})
@@ -153,6 +161,158 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct);
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
// R03:2.7 — the outage is named in the server log, not inferred from the directory side.
_logs.Entries.Any(e =>
e.Level == LogLevel.Warning && e.Message.Contains("LDAP authentication failed") && e.Exception != null)
.ShouldBeTrue("a Warning naming the LDAP failure must be logged");
}
/// <summary>
/// R03:2.4 — a token from <c>/auth/token</c> authenticates a request carrying it as a bearer:
/// the fallback-protected root AND a role-gated endpoint (the bearer principal's "Role" claims
/// resolve through <c>IsInRole</c>). No cookie is sent.
/// </summary>
[Fact]
public async Task Bearer_token_authenticates_api_requests_without_a_cookie()
{
var token = await LoginAndIssueTokenAsync();
var client = NewClient();
var root = new HttpRequestMessage(HttpMethod.Get, "/");
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
var gated = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
gated.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var gatedResponse = await client.SendAsync(gated, Ct);
gatedResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
(await gatedResponse.Content.ReadAsStringAsync(Ct)).ShouldContain("viewer");
}
/// <summary>A tampered or garbage bearer fails closed (401), it never falls through to anonymous.</summary>
[Theory]
[InlineData("not-a-jwt")]
[InlineData("tampered")]
public async Task Bad_bearer_token_is_rejected(string kind)
{
var token = kind == "tampered"
? (await LoginAndIssueTokenAsync()) + "x"
: kind;
var client = NewClient();
var req = new HttpRequestMessage(HttpMethod.Get, "/");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
var response = await client.SendAsync(req, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
}
/// <summary>
/// Fix round 1 / I1 — the first-party JwtBearer handler pins HS256: an unsigned (<c>alg: none</c>)
/// token carrying the right issuer/audience/roles, and a token signed with a different HMAC
/// variant, both fail CLOSED with a 401 — never a 500, never a pass.
/// </summary>
[Theory]
[InlineData("none")]
[InlineData("HS384")]
public async Task Bearer_with_a_non_HS256_algorithm_is_rejected_with_401(string alg)
{
var token = alg == "none" ? UnsignedToken() : SignedToken(SecurityAlgorithms.HmacSha384);
var client = NewClient();
var req = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(req, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
// Positive control: the same claims signed HS256 with the configured key ARE accepted, so the
// 401 above measured the algorithm and not a broken token shape.
var good = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
good.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", SignedToken(SecurityAlgorithms.HmacSha256));
(await client.SendAsync(good, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Fix round 1 / I2 — <c>/auth/token</c> mints only for a COOKIE session. A bearer token used to
/// be able to mint a fresh token for itself, so a stolen token could renew itself forever and
/// outlive both its own expiry and the user's LDAP membership. Positive control: the cookie
/// session that minted the bearer still gets a token.
/// </summary>
[Fact]
public async Task Auth_token_refuses_a_bearer_only_caller_and_serves_a_cookie_session()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var cookieReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(cookieReq, loginResponse);
var cookieResp = await client.SendAsync(cookieReq, Ct);
cookieResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var bearer = (await cookieResp.Content.ReadFromJsonAsync<JsonElement>(Ct)).GetProperty("token").GetString()!;
// The bearer authenticates elsewhere (positive control on the token itself) …
var root = new HttpRequestMessage(HttpMethod.Get, "/");
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
// … but cannot renew itself.
var renew = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
renew.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
renew.Headers.Add("X-Requested-With", "XMLHttpRequest");
var renewResp = await client.SendAsync(renew, Ct);
renewResp.StatusCode.ShouldBeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
(await renewResp.Content.ReadAsStringAsync(Ct)).ShouldNotContain("token");
}
private const string TestSigningKey = "test-signing-key-with-at-least-32-bytes-of-utf8-content";
private static IEnumerable<System.Security.Claims.Claim> ViewerClaims() =>
[
new(JwtTokenService.UsernameClaimType, "mallory"),
new(JwtTokenService.RoleClaimType, "Viewer"),
];
/// <summary>A token signed with <paramref name="algorithm"/> using the configured key bytes.</summary>
private static string SignedToken(string algorithm)
{
var keyBytes = System.Text.Encoding.UTF8.GetBytes(TestSigningKey);
// The SAME key bytes for every algorithm, so a rejection measures the algorithm pin alone.
var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
issuer: "otopcua-test", audience: "otopcua-test", claims: ViewerClaims(),
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(keyBytes), algorithm));
return new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>An <c>alg: none</c> token with otherwise valid issuer/audience/lifetime/roles.</summary>
private static string UnsignedToken()
{
static string B64(string json) => Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(json);
var exp = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds();
return B64("{\"alg\":\"none\",\"typ\":\"JWT\"}") + "." +
B64($"{{\"zb:username\":\"mallory\",\"Role\":\"Viewer\",\"iss\":\"otopcua-test\",\"aud\":\"otopcua-test\",\"exp\":{exp}}}") +
".";
}
private async Task<string> LoginAndIssueTokenAsync()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var response = await client.SendAsync(tokenReq, Ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
return payload.GetProperty("token").GetString()!;
}
/// <summary>Tests that ping anonymous returns 401.</summary>
@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>Collects every log entry written through the host's logger factory, for assertions on what was logged.</summary>
internal sealed class CapturingLoggerProvider : ILoggerProvider
{
public ConcurrentQueue<Entry> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new Logger(categoryName, Entries);
public void Dispose() { }
internal sealed record Entry(string Category, LogLevel Level, string Message, Exception? Exception);
private sealed class Logger(string category, ConcurrentQueue<Entry> entries) : ILogger
{
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) =>
entries.Enqueue(new Entry(category, logLevel, formatter(state, exception), exception));
}
}
@@ -0,0 +1,127 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// R03:2.4 — JWT is opt-in by signing key. Unconfigured: <c>/auth/token</c> is not mapped (it used to
/// answer HTTP 500 from the service constructor) and no bearer scheme exists. Configured badly: the
/// host refuses to START rather than failing on the first token request.
/// </summary>
public sealed class JwtConfigurationGateTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Without_a_signing_key_auth_token_is_not_mapped_and_bearer_is_ignored()
{
using var host = await StartHostAsync(signingKey: null);
var server = host.GetTestServer();
var client = server.CreateClient();
// The login still works — the cookie path is untouched.
var login = await client.PostAsJsonAsync("/auth/login", new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
login.StatusCode.ShouldBe(HttpStatusCode.NoContent);
// /auth/token is simply absent (404), not a 500.
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
foreach (var c in login.Headers.GetValues("Set-Cookie")) tokenReq.Headers.Add("Cookie", c.Split(';')[0]);
(await client.SendAsync(tokenReq, Ct)).StatusCode.ShouldBe(HttpStatusCode.NotFound);
// No bearer scheme is registered, so a bearer header is ignored and the cookie session still wins.
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
(await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer)).ShouldBeNull();
var withBogusBearer = new HttpRequestMessage(HttpMethod.Get, "/");
foreach (var c in login.Headers.GetValues("Set-Cookie")) withBogusBearer.Headers.Add("Cookie", c.Split(';')[0]);
withBogusBearer.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "garbage");
(await client.SendAsync(withBogusBearer, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
await host.StopAsync(Ct);
}
/// <summary>Positive control for the gate: with a key, the bearer scheme IS registered.</summary>
[Fact]
public async Task With_a_signing_key_the_bearer_scheme_is_registered()
{
using var host = await StartHostAsync(signingKey: "test-signing-key-with-at-least-32-bytes-of-utf8-content");
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer);
bearer.ShouldNotBeNull();
bearer.HandlerType.ShouldBe(typeof(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler));
await host.StopAsync(Ct);
}
/// <summary>A configured-but-short key fails at host start (ValidateOnStart), not on the first /auth/token.</summary>
[Fact]
public async Task A_short_signing_key_fails_host_start()
{
var ex = await Should.ThrowAsync<OptionsValidationException>(async () =>
{
using var host = await StartHostAsync(signingKey: "too-short");
});
ex.Message.ShouldContain("SigningKey");
}
private static async Task<IHost> StartHostAsync(string? signingKey)
{
var dbName = $"jwt-gate-{Guid.NewGuid():N}";
var settings = new Dictionary<string, string?>();
if (signingKey is not null) settings["Security:Jwt:SigningKey"] = signingKey;
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var host = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
services.AddRouting();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, AlwaysAliceLdap>();
services.AddSingleton<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(e =>
{
e.MapOtOpcUaAuth();
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
});
});
})
.Build();
await host.StartAsync(Ct);
return host;
}
private sealed class AlwaysAliceLdap : ILdapAuthService
{
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
Task.FromResult(new LdapAuthResult(
Success: password == "valid-password", DisplayName: "Alice", Username: username,
Groups: [], Roles: [], Error: null));
}
}