From dfab7f614d2ae0e5ec2c0ea617696824d3509fd4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 10:00:52 -0400 Subject: [PATCH] test(integration): key expiry stops a live subscription over the wire, with no deploy --- .../plans/R3-05-acl-hot-path-expiry-plan.md | 20 + ...-05-acl-hot-path-expiry-plan.md.tasks.json | 2 +- .../AclKeyExpiryEnforcementTests.cs | 370 ++++++++++++++++++ ...tOpcUa.OpcUaServer.IntegrationTests.csproj | 4 + 4 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclKeyExpiryEnforcementTests.cs diff --git a/archreview/plans/R3-05-acl-hot-path-expiry-plan.md b/archreview/plans/R3-05-acl-hot-path-expiry-plan.md index 57f5c808..88487801 100644 --- a/archreview/plans/R3-05-acl-hot-path-expiry-plan.md +++ b/archreview/plans/R3-05-acl-hot-path-expiry-plan.md @@ -710,3 +710,23 @@ for the orchestrator. - The node manager is resolved **per tick** (`() => _server?.NodeManager?.Revalidate…`) rather than captured, so a restart cannot leave the tick walking a dead manager's item map. - Host.Tests: 132 green. + +### T9 — over-the-wire expiry test (key-carrying session) + +- **RED observed (build):** `error CS1503: Argument 2: cannot convert from 'string' to 'byte[]'` — the + 1.5.378 `UserNameIdentityToken.DecryptedPassword` is raw bytes, not a string (the host already decodes + UTF-8; the token had to be built the same way). +- **FALSIFICATION (the plan's step 2, run and recorded):** deleting the single `rig.TickRevalidation()` + call from `An_expired_key_stops_a_live_subscription_with_no_deploy` makes it **`[FAIL]`** — the expired + key goes on streaming. So the denial is produced by the T8 tick and by nothing else. Restored, both + cases green. +- **DEVIATION (fake choice):** the plan calls for "an `ApiKeyOpcUaUserAuthenticator` over a fake + `IApiKeyStore`". The rig instead uses a 6-line `KeyPresentingAuthenticator` returning + `OpcUaUserAuthResult.AllowKey(principal, [], keyId)`. The credential-verification path (parse, peppered + HMAC, store lookup, deny shapes) is already covered in the Host suite and changes nothing about what + this file asks — **whether the SDK hands a monitored item back an identity that still carries the key + id**. Grants come from the deployed snapshot, so the authenticator returns no roles. +- The tick is driven through the **real `AclRevalidationTimer`**, which required a new `ProjectReference` + from the integration test project to `ZB.MOM.WW.OtOpcUa.Host` (the timer lives there). Without it the + test would have called the node manager's pass directly and could not have falsified T8. +- OpcUaServer.IntegrationTests: 20 green (was 18). diff --git a/archreview/plans/R3-05-acl-hot-path-expiry-plan.md.tasks.json b/archreview/plans/R3-05-acl-hot-path-expiry-plan.md.tasks.json index bbc19b89..1b459d39 100644 --- a/archreview/plans/R3-05-acl-hot-path-expiry-plan.md.tasks.json +++ b/archreview/plans/R3-05-acl-hot-path-expiry-plan.md.tasks.json @@ -68,7 +68,7 @@ { "id": "T9", "subject": "Over-the-wire AclKeyExpiryEnforcementTests: key-carrying session, clock advanced past expiry, item goes BadUserAccessDenied with no deploy (+ negative control)", - "status": "pending", + "status": "done", "blockedBy": [ "T8" ] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclKeyExpiryEnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclKeyExpiryEnforcementTests.cs new file mode 100644 index 00000000..cb97261d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclKeyExpiryEnforcementTests.cs @@ -0,0 +1,370 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// arch-review 03/S19 over the wire — a key that expires by the CLOCK stops a live subscription, with +/// no deployment anywhere in the run. +/// +/// +/// +/// This cannot be reasoned into existence for the same reason +/// AclSubscriptionEnforcementTests gives: whether a monitored item can recover the identity +/// of the session that created it is an assumption about SDK behaviour. Here it must recover an +/// identity carrying an API key id — the field the per-call expiry re-check keys on — and +/// the existing rig has never exercised one, because it connects anonymously. +/// +/// +/// The tick is driven through the real rather than by calling +/// the node manager's pass directly, so what is asserted here is the whole T8 path: revert the +/// host's timer and the denial below cannot be produced by anything except a deployment. +/// +/// +public sealed class AclKeyExpiryEnforcementTests +{ + private const string Cluster = "SITE-A"; + private const string Device = "Plant/Modbus/dev1"; + private const string Tag = "Plant/Modbus/dev1/Speed"; + private const string Principal = "expiry-probe"; + private const string KeyId = "k-expiry"; + + private static readonly DateTime Start = new(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc); + + /// + /// THE test this file exists for: the key's ExpiresUtc passes, a revalidation tick runs, and + /// the client is told BadUserAccessDenied — with DataPlaneAuthorityState.Apply called + /// exactly once, in setup. + /// + [Fact] + public async Task An_expired_key_stops_a_live_subscription_with_no_deploy() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclKeyExpiry", ct); + try + { + rig.Enforce(expiresUtc: Start.AddMinutes(10)); + rig.Materialise(); + rig.Write(1); + + using var session = await rig.ConnectAsync(ct); + var (subscription, received) = await rig.SubscribeAsync(session, ct); + + // Baseline: the key-carrying session's feed is live. + await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10)); + received.Clear(); + + // Only the clock moves. No deployment, no new generation, no grant change. + rig.Advance(TimeSpan.FromMinutes(20)); + rig.TickRevalidation(); + + await WaitUntilAsync( + () => received.Any(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied), + TimeSpan.FromSeconds(10)); + + // And the data really has stopped. + received.Clear(); + rig.Write(2); + await Task.Delay(TimeSpan.FromSeconds(2), ct); + received.ShouldNotContain(v => Equals(v.Value, 2f)); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// The negative control: identical run, clock advanced but not past ExpiresUtc ⇒ values keep + /// arriving. + /// + /// + /// Without it the test above also passes on a build whose tick detaches every item it sees, which + /// would silence every subscription in the fleet once a minute. + /// + [Fact] + public async Task An_unexpired_key_keeps_streaming_across_a_tick() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclKeyLive", ct); + try + { + rig.Enforce(expiresUtc: Start.AddHours(4)); + rig.Materialise(); + rig.Write(1); + + using var session = await rig.ConnectAsync(ct); + var (subscription, received) = await rig.SubscribeAsync(session, ct); + await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10)); + + received.Clear(); + rig.Advance(TimeSpan.FromMinutes(20)); + rig.TickRevalidation(); + + rig.Write(2); + await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10)); + received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + // ───────────────────────────── helpers ───────────────────────────── + + private static readonly IReadOnlyDictionary> NoScopes = + new Dictionary>(); + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(50); + } + condition().ShouldBeTrue("condition not met within timeout"); + } + + /// A booted server, a key-carrying client session, and a hand-driven revalidation tick. + private sealed class Rig : IAsyncDisposable + { + private readonly string _pkiRoot; + private readonly FakeClock _clock = new(Start); + private OpcUaApplicationHost _host = null!; + private OtOpcUaSdkServer _server = null!; + private AclRevalidationTimer? _timer; + private int _port; + + private Rig(string pkiRoot) => _pkiRoot = pkiRoot; + + private OtOpcUaNodeManager Nm => _server.NodeManager!; + + public static async Task StartAsync(string name, CancellationToken ct) + { + var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}")); + rig._port = AllocateFreePort(); + rig._server = new OtOpcUaSdkServer(); + rig._host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = $"OtOpcUa.{name}", + ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}", + OpcUaPort = rig._port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = rig._pkiRoot, + EnabledSecurityProfiles = [OpcUaSecurityProfile.None], + AutoAcceptUntrustedClientCertificates = true, + }, + NullLogger.Instance, + new KeyPresentingAuthenticator()); + + await rig._host.StartAsync(rig._server, ct); + return rig; + } + + /// + /// Switches enforcement on with one cluster-wide grant and one key expiring at + /// , and arms the revalidation timer over the same clock. + /// + /// When the key stops being usable. + public void Enforce(DateTime expiresUtc) + { + var key = new ApiKey + { + ApiKeyId = KeyId, + Name = KeyId, + SecretHash = "hash", + Principal = Principal, + Enabled = true, + CreatedUtc = Start.AddDays(-1), + ExpiresUtc = expiresUtc, + }; + var grant = new NodeAcl + { + NodeAclId = "acl-cluster", + ClusterId = Cluster, + Principal = Principal, + ScopeKind = NodeAclScopeKind.Cluster, + PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe, + }; + + var state = new DataPlaneAuthorityState(); + + // The ONLY Apply in the whole test. Everything after this moves the clock. + state.Apply(Cluster, "rev-1", [key], [grant], NoScopes); + + Nm.DataPlaneAuthority = state; + Nm.Authorizer = new DataPlaneAuthorizer( + state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock); + + // A long interval: the tick is driven by hand, so a real timer firing underneath would only + // make the test non-deterministic. What matters is that the pass is reached through the same + // component the hosted service arms. + _timer = new AclRevalidationTimer( + () => _server.NodeManager?.RevalidateMonitoredItems(), + TimeSpan.FromHours(1), + TimeProvider.System, + NullLogger.Instance); + } + + /// Moves the authorizer's clock forward — the only thing that changes mid-test. + /// How far forward. + public void Advance(TimeSpan by) => _clock.Advance(by); + + /// Runs one revalidation tick through the host's own timer component. + public void TickRevalidation() => _timer!.Tick(); + + public void Materialise() + { + Nm.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw); + Nm.EnsureFolder("Plant/Modbus", "Plant", "Modbus", AddressSpaceRealm.Raw); + Nm.EnsureFolder(Device, "Plant/Modbus", "dev1", AddressSpaceRealm.Raw); + Nm.EnsureVariable(Tag, Device, "Speed", "Float", writable: false, AddressSpaceRealm.Raw); + } + + public void Write(float value) => + Nm.WriteValue(Tag, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + public ushort RawNamespace(ISession session) + { + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + ns.ShouldBeGreaterThan((ushort)0); + return ns; + } + + public NodeId TagNodeId(ISession session) => new(Tag, RawNamespace(session)); + + public async Task<(Subscription Subscription, List Received)> SubscribeAsync( + ISession session, CancellationToken ct) + { + var received = new List(); + var gate = new object(); + + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = TagNodeId(session), + AttributeId = Attributes.Value, + SamplingInterval = 50, + QueueSize = 10, + }; + item.Notification += (mi, _) => + { + foreach (var v in mi.DequeueValues()) lock (gate) received.Add(v); + }; + subscription.AddItem(item); + await subscription.ApplyChangesAsync(ct); + + item.Status.Error.ShouldBeNull(); + return (subscription, received); + } + + /// Connects presenting the fixed apikey username with an otua_ credential. + /// The cancellation token. + /// The connected session. + public async Task ConnectAsync(CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.AclKeyClient", + ApplicationUri = $"urn:OtOpcUa.AclKeyClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, + new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)), + updateBeforeConnect: false, + checkDomain: false, + sessionName: "AclKeyExpiryEnforcementTests", + sessionTimeout: 60_000, + identity: new UserIdentity(new UserNameIdentityToken + { + UserName = "apikey", + // 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8. + DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"), + }), + preferredLocales: null, + ct: ct); + } + + public async ValueTask DisposeAsync() + { + _timer?.Dispose(); + await _host.DisposeAsync(); + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch (IOException) { /* best effort */ } + } + } + + private static int AllocateFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// Admits the fixed apikey username as , carrying + /// . + /// + /// + /// Deliberately not the production ApiKeyOpcUaUserAuthenticator over a fake store: the + /// credential-verification path (parse, peppered HMAC, store lookup, deny shapes) is covered by + /// ApiKeyAuthenticationTests and BoundedApiKeyStoreTests in the Host suite, and + /// none of it changes what this file is about — whether the SDK hands a monitored item back an + /// identity that still carries the key id. Grants come from the deployed snapshot, so this + /// returns no roles. + /// + private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator + { + /// + public Task AuthenticateUserNameAsync( + string username, string password, CancellationToken ct) => + Task.FromResult( + string.Equals(username, "apikey", StringComparison.Ordinal) + && password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal) + ? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) + : OpcUaUserAuthResult.Deny("Invalid API key.")); + } + + /// A hand-advanced clock; the authorizer reads it on every decision. + private sealed class FakeClock(DateTime utcNow) : TimeProvider + { + private DateTimeOffset _now = new(utcNow, TimeSpan.Zero); + + /// + public override DateTimeOffset GetUtcNow() => _now; + + /// Moves the clock forward. + /// How far forward. + public void Advance(TimeSpan by) => _now = _now.Add(by); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj index 65b05bdd..1ea5dbac 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj @@ -25,6 +25,10 @@ + +