using Akka.Actor; using Akka.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; /// /// Pins the ACL revalidation timer's lifecycle on the hosted service (arch-review 03/S19) — that it is /// armed at the configured interval, that 0 arms nothing, and that stopping disposes it. /// /// /// The timer is armed through rather /// than by running StartAsync, because StartAsync boots the real OPC UA SDK (certificate /// store, listening socket) and none of that is what this is about. Everything the timer's lifecycle /// touches — the options, the clock, the disposal — is exercised exactly as production reaches it. /// public sealed class OtOpcUaServerHostedServiceRevalidationTests { /// The configured interval is what the timer is armed at. [Fact] public void A_configured_interval_arms_one_timer_at_that_period() { var clock = new RecordingTimeProvider(); var sut = Service(clock, intervalSeconds: 30); sut.StartRevalidationTimer(); clock.Periods.ShouldBe([TimeSpan.FromSeconds(30)]); } /// /// RevalidationIntervalSeconds = 0 arms nothing — the documented rollback to /// deploy-triggered-only revalidation. /// [Fact] public void A_zero_interval_arms_nothing() { var clock = new RecordingTimeProvider(); var sut = Service(clock, intervalSeconds: 0); sut.StartRevalidationTimer(); clock.Periods.ShouldBeEmpty(); } /// /// StopAsync disposes the timer, so no tick can land on a node manager that is being torn /// down. /// /// /// The disposal is the FIRST thing StopAsync does, ahead of /// SetDataPlaneAuthority(null) / SetDataPlaneAuthorizer(null) — a stopping node sheds /// the trigger before it sheds the thing the trigger drives. /// [Fact] public async Task StopAsync_disposes_the_timer() { var clock = new RecordingTimeProvider(); var sut = Service(clock, intervalSeconds: 30); sut.StartRevalidationTimer(); clock.Timers[0].Disposed.ShouldBeFalse("non-vacuity: the timer is live before the stop"); await sut.StopAsync(CancellationToken.None); clock.Timers[0].Disposed.ShouldBeTrue(); } /// Disposing the service disposes the timer too, for a host that never called StopAsync. [Fact] public async Task DisposeAsync_disposes_the_timer() { var clock = new RecordingTimeProvider(); var sut = Service(clock, intervalSeconds: 30); sut.StartRevalidationTimer(); await sut.DisposeAsync(); clock.Timers[0].Disposed.ShouldBeTrue(); } // ---------- helpers ---------- private static OtOpcUaServerHostedService Service(TimeProvider clock, int intervalSeconds) => new( Options.Create(new OpcUaApplicationHostOptions()), new DeferredAddressSpaceSink(), new DeferredServiceLevelPublisher(), NullOpcUaUserAuthenticator.Instance, NullHistorianDataSource.Instance, EmptyDataPlaneAuthoritySource.Instance, new ConfigurationBuilder().Build(), () => throw new NotSupportedException("no ActorSystem is needed for the timer lifecycle"), new ActorRegistry(), dataPlaneAuthorizer: null, Options.Create(new AclOptions { RevalidationIntervalSeconds = intervalSeconds }), clock, NullLoggerFactory.Instance); /// Records the timers asked of it instead of arming real ones. private sealed class RecordingTimeProvider : TimeProvider { /// Every timer this provider was asked to create, in order. public List Timers { get; } = []; /// The period each of those timers was armed at. public IReadOnlyList Periods => [.. Timers.Select(t => t.Period)]; /// public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { var timer = new FakeTimer(period); Timers.Add(timer); return timer; } /// A timer that remembers its period and whether it was disposed. internal sealed class FakeTimer(TimeSpan period) : ITimer { public TimeSpan Period { get; } = period; public bool Disposed { get; private set; } public bool Change(TimeSpan dueTime, TimeSpan p) => true; public void Dispose() => Disposed = true; public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; } } } }