using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Grpc; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Workers; using ZB.MOM.WW.MxGateway.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; /// /// Tests for Server-021. MxAccessGatewayService.ApplyConstraintsAsync and /// the BulkConstraintPlan / ReadBulkConstraintPlan / /// WriteBulkConstraintPlan / SubscribeBulkConstraintPlan reply-merge /// logic was previously exercised only with an allow-all enforcer, so denial /// filtering, the no-allowed-items short-circuit, and the index-ordered /// denied/allowed interleave were dead code at test time. The fixtures below /// inject a that denies a subset of /// tags or handles, and assert the post-merge reply contents and that the /// session manager is (or is not) invoked. /// public sealed class MxAccessGatewayServiceConstraintTests { private const string SessionId = "session-constraint"; // === SubscribeBulk family: AddItemBulk / SubscribeBulk / AdviseItemBulk === /// /// AddItemBulk with a mix of allowed and denied tags must invoke the /// worker once with only the allowed tags, then splice the denied entries /// back into the reply at their original indices. /// [Fact] public async Task Invoke_AddItemBulk_WithMixedDenials_InterleavesDeniedAndAllowedInOriginalIndexOrder() { PredicateConstraintEnforcer enforcer = new() { DenyTag = tag => tag == "Tank01.Locked" || tag == "Tank03.Secret", }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); sessionManager.InvokeReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.AddItemBulk, ProtocolStatus = MxAccessGrpcMapper.Ok(), AddItemBulk = new BulkSubscribeReply { Results = { // Worker only sees the two allowed tags — Tank02.Open at original // index 1 and Tank04.Public at original index 3. new SubscribeResult { ServerHandle = 7, TagAddress = "Tank02.Open", ItemHandle = 102, WasSuccessful = true }, new SubscribeResult { ServerHandle = 7, TagAddress = "Tank04.Public", ItemHandle = 104, WasSuccessful = true }, }, }, }, }; MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateAddItemBulkRequest(7, ["Tank01.Locked", "Tank02.Open", "Tank03.Secret", "Tank04.Public"]), new TestServerCallContext()); Assert.Equal(1, sessionManager.InvokeCount); // Worker saw only the allowed subset, in original order, with denied entries dropped. AddItemBulkCommand forwardedCommand = sessionManager.LastWorkerCommand!.Command.AddItemBulk; Assert.Equal(["Tank02.Open", "Tank04.Public"], forwardedCommand.TagAddresses); // Final reply preserves the original 4-entry index order, with denied entries // at index 0 and 2 and worker-allowed entries at index 1 and 3. BulkSubscribeReply merged = reply.AddItemBulk; Assert.Equal(4, merged.Results.Count); Assert.False(merged.Results[0].WasSuccessful); Assert.Equal("Tank01.Locked", merged.Results[0].TagAddress); Assert.Contains("Tank01.Locked", merged.Results[0].ErrorMessage, StringComparison.Ordinal); Assert.True(merged.Results[1].WasSuccessful); Assert.Equal("Tank02.Open", merged.Results[1].TagAddress); Assert.Equal(102, merged.Results[1].ItemHandle); Assert.False(merged.Results[2].WasSuccessful); Assert.Equal("Tank03.Secret", merged.Results[2].TagAddress); Assert.True(merged.Results[3].WasSuccessful); Assert.Equal("Tank04.Public", merged.Results[3].TagAddress); Assert.Equal(104, merged.Results[3].ItemHandle); // Both denied tags recorded. Assert.Equal(2, enforcer.RecordedDenials.Count); } /// /// SubscribeBulk when every tag is denied must short-circuit /// false, return the /// denied-only reply, and never call the session manager. /// [Fact] public async Task Invoke_SubscribeBulk_WhenAllTagsDenied_DoesNotCallWorkerAndReturnsDeniedReply() { PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateSubscribeBulkRequest(7, ["A", "B", "C"]), new TestServerCallContext()); Assert.Equal(0, sessionManager.InvokeCount); Assert.Equal(3, reply.SubscribeBulk.Results.Count); Assert.All(reply.SubscribeBulk.Results, r => Assert.False(r.WasSuccessful)); Assert.Equal(["A", "B", "C"], reply.SubscribeBulk.Results.Select(r => r.TagAddress)); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); } /// /// AdviseItemBulk takes handle inputs (not tags) and routes through /// FilterHandleBulkAsync against CheckReadHandleAsync. Partial /// denial must still produce a merged-by-index BulkSubscribeReply. /// [Fact] public async Task Invoke_AdviseItemBulk_WithMixedHandleDenials_MergesDeniedIntoReply() { PredicateConstraintEnforcer enforcer = new() { DenyReadHandle = (_, itemHandle) => itemHandle == 502, }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); sessionManager.InvokeReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.AdviseItemBulk, ProtocolStatus = MxAccessGrpcMapper.Ok(), AdviseItemBulk = new BulkSubscribeReply { Results = { new SubscribeResult { ServerHandle = 7, ItemHandle = 501, WasSuccessful = true }, new SubscribeResult { ServerHandle = 7, ItemHandle = 503, WasSuccessful = true }, }, }, }, }; MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateAdviseItemBulkRequest(7, [501, 502, 503]), new TestServerCallContext()); Assert.Equal(1, sessionManager.InvokeCount); Assert.Equal([501, 503], sessionManager.LastWorkerCommand!.Command.AdviseItemBulk.ItemHandles); BulkSubscribeReply merged = reply.AdviseItemBulk; Assert.Equal(3, merged.Results.Count); Assert.True(merged.Results[0].WasSuccessful); Assert.Equal(501, merged.Results[0].ItemHandle); Assert.False(merged.Results[1].WasSuccessful); Assert.Equal(502, merged.Results[1].ItemHandle); Assert.True(merged.Results[2].WasSuccessful); Assert.Equal(503, merged.Results[2].ItemHandle); } /// /// SubscribeBulk with an allow-all enforcer must leave the worker reply /// unchanged — the constraint plan is null and no merge occurs. Regression /// guard against accidentally engaging the merge path for the common case. /// [Fact] public async Task Invoke_SubscribeBulk_WithAllowAllEnforcer_PassesThroughUnchanged() { FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); sessionManager.InvokeReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.SubscribeBulk, ProtocolStatus = MxAccessGrpcMapper.Ok(), SubscribeBulk = new BulkSubscribeReply { Results = { new SubscribeResult { ServerHandle = 7, TagAddress = "A", ItemHandle = 1, WasSuccessful = true }, new SubscribeResult { ServerHandle = 7, TagAddress = "B", ItemHandle = 2, WasSuccessful = true }, }, }, }, }; MxAccessGatewayService service = CreateService(sessionManager); MxCommandReply reply = await service.Invoke( CreateSubscribeBulkRequest(7, ["A", "B"]), new TestServerCallContext()); Assert.Equal(1, sessionManager.InvokeCount); Assert.Equal(["A", "B"], sessionManager.LastWorkerCommand!.Command.SubscribeBulk.TagAddresses); // Reply identical to worker reply — no synthetic denial rows added. Assert.Equal(2, reply.SubscribeBulk.Results.Count); Assert.All(reply.SubscribeBulk.Results, r => Assert.True(r.WasSuccessful)); } // === ReadBulk family === /// /// ReadBulk with a mix of allowed and denied tags merges denied entries /// into the BulkReadReply in original-index order, distinguishable from /// the SubscribeBulk family because the reply slot is /// BulkReadReply, not BulkSubscribeReply. /// [Fact] public async Task Invoke_ReadBulk_WithMixedDenials_MergesDeniedBulkReadResults() { PredicateConstraintEnforcer enforcer = new() { DenyTag = tag => tag == "Secret.Tag", }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); sessionManager.InvokeReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.ReadBulk, ProtocolStatus = MxAccessGrpcMapper.Ok(), ReadBulk = new BulkReadReply { Results = { new BulkReadResult { ServerHandle = 7, TagAddress = "Public.A", WasSuccessful = true }, new BulkReadResult { ServerHandle = 7, TagAddress = "Public.B", WasSuccessful = true }, }, }, }, }; MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateReadBulkRequest(7, ["Public.A", "Secret.Tag", "Public.B"]), new TestServerCallContext()); Assert.Equal(1, sessionManager.InvokeCount); Assert.Equal(["Public.A", "Public.B"], sessionManager.LastWorkerCommand!.Command.ReadBulk.TagAddresses); BulkReadReply merged = reply.ReadBulk; Assert.Equal(3, merged.Results.Count); Assert.True(merged.Results[0].WasSuccessful); Assert.False(merged.Results[1].WasSuccessful); Assert.Equal("Secret.Tag", merged.Results[1].TagAddress); Assert.True(merged.Results[2].WasSuccessful); } /// /// ReadBulk with all tags denied must short-circuit and produce a /// denied-only BulkReadReply — verifying /// 's ReadBulkConstraintPlan /// CreateDeniedReply path. /// [Fact] public async Task Invoke_ReadBulk_WhenAllTagsDenied_ShortCircuitsWithDeniedOnlyReply() { PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateReadBulkRequest(7, ["X", "Y"]), new TestServerCallContext()); Assert.Equal(0, sessionManager.InvokeCount); Assert.Equal(2, reply.ReadBulk.Results.Count); Assert.All(reply.ReadBulk.Results, r => Assert.False(r.WasSuccessful)); Assert.Equal(MxCommandKind.ReadBulk, reply.Kind); } // === WriteBulk family: WriteBulk / Write2Bulk / WriteSecuredBulk / WriteSecured2Bulk === /// /// WriteBulk with one denied handle must drop that entry from the /// forwarded command and splice a denied BulkWriteResult back in at /// the original index. /// [Fact] public async Task Invoke_WriteBulk_WithDeniedHandle_DropsEntryFromWorkerCallAndMergesDenialIntoReply() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, itemHandle) => itemHandle == 902, }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); sessionManager.InvokeReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.WriteBulk, ProtocolStatus = MxAccessGrpcMapper.Ok(), WriteBulk = new BulkWriteReply { Results = { new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true }, new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true }, }, }, }, }; MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateWriteBulkRequest(7, [901, 902, 903]), new TestServerCallContext()); Assert.Equal(1, sessionManager.InvokeCount); // 902 dropped from forwarded entries; only 901 and 903 reach the worker. WriteBulkCommand forwarded = sessionManager.LastWorkerCommand!.Command.WriteBulk; Assert.Equal([901, 903], forwarded.Entries.Select(e => e.ItemHandle)); BulkWriteReply merged = reply.WriteBulk; Assert.Equal(3, merged.Results.Count); Assert.True(merged.Results[0].WasSuccessful); Assert.Equal(901, merged.Results[0].ItemHandle); Assert.False(merged.Results[1].WasSuccessful); Assert.Equal(902, merged.Results[1].ItemHandle); Assert.True(merged.Results[2].WasSuccessful); Assert.Equal(903, merged.Results[2].ItemHandle); } /// /// WriteSecuredBulk exercises a different ReplaceWriteBulkEntries /// switch arm than plain WriteBulk. The merge logic is shared, so a /// full denial here is enough to prove the secured-bulk routing. /// [Fact] public async Task Invoke_WriteSecuredBulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateWriteSecuredBulkRequest(7, [10, 11]), new TestServerCallContext()); Assert.Equal(0, sessionManager.InvokeCount); Assert.Equal(MxCommandKind.WriteSecuredBulk, reply.Kind); Assert.Equal(2, reply.WriteSecuredBulk.Results.Count); Assert.All(reply.WriteSecuredBulk.Results, r => Assert.False(r.WasSuccessful)); } /// /// Tests-020: Write2Bulk takes the third GetPayload/SetPayload /// switch arm in WriteBulkConstraintPlan. The merge logic is shared with /// WriteBulk, but a full denial through the CreateDeniedReply path /// proves the Write2Bulk arm of the per-kind SetPayload switch fires /// (and not, say, WriteBulk by mistake) — guarding against a refactor that /// drops or misroutes the Write2Bulk case. /// [Fact] public async Task Invoke_Write2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateWrite2BulkRequest(7, [10, 11]), new TestServerCallContext()); Assert.Equal(0, sessionManager.InvokeCount); Assert.Equal(MxCommandKind.Write2Bulk, reply.Kind); Assert.Equal(2, reply.Write2Bulk.Results.Count); Assert.All(reply.Write2Bulk.Results, r => Assert.False(r.WasSuccessful)); // Sibling reply slots must remain empty — pin the SetPayload arm fired // for Write2Bulk and not for one of the other three Write*Bulk kinds. Assert.Empty(reply.WriteBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); Assert.Empty(reply.WriteSecuredBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); Assert.Empty(reply.WriteSecured2Bulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); } /// /// Tests-020: WriteSecured2Bulk takes the fourth GetPayload/SetPayload /// switch arm in WriteBulkConstraintPlan. Same reasoning as /// Write2Bulk — assert the WriteSecured2Bulk reply slot is populated /// to prove that arm of the switch fires. /// [Fact] public async Task Invoke_WriteSecured2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateWriteSecured2BulkRequest(7, [10, 11]), new TestServerCallContext()); Assert.Equal(0, sessionManager.InvokeCount); Assert.Equal(MxCommandKind.WriteSecured2Bulk, reply.Kind); Assert.Equal(2, reply.WriteSecured2Bulk.Results.Count); Assert.All(reply.WriteSecured2Bulk.Results, r => Assert.False(r.WasSuccessful)); // Sibling reply slots must remain empty — pin the SetPayload arm fired // for WriteSecured2Bulk and not for one of the other three Write*Bulk kinds. Assert.Empty(reply.WriteBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); Assert.Empty(reply.Write2Bulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); Assert.Empty(reply.WriteSecuredBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField()); } // === Worker reply-count divergence (Tests-024) === /// /// Tests-024: WriteBulkConstraintPlan.MergeDeniedInto dequeues from /// allowedResults per non-denied slot via Queue.TryDequeue, /// which silently returns false when the queue is empty. Pin the /// observable behaviour when the worker returns FEWER allowed results than /// the gateway forwarded: the merged reply is truncated — denied entries /// keep their slots, but the trailing allowed slot for which no worker /// result arrived is dropped (no synthetic failure result is fabricated). /// This fixture makes that "silent truncate" behaviour explicit so a future /// change either fills the gap with a synthetic failure or fails this test. /// [Fact] public async Task Invoke_WriteBulk_WhenWorkerReturnsFewerResultsThanAllowed_MergedReplyIsTruncated() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, itemHandle) => itemHandle == 902, }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); // Gateway forwards 2 allowed handles (901, 903) but the worker returns only // 1 result. The merge logic should keep denied entry 902 at index 1, place // the single worker result at index 0, and leave index 2 empty (truncate). sessionManager.InvokeReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.WriteBulk, ProtocolStatus = MxAccessGrpcMapper.Ok(), WriteBulk = new BulkWriteReply { Results = { new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true }, }, }, }, }; MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateWriteBulkRequest(7, [901, 902, 903]), new TestServerCallContext()); Assert.Equal(1, sessionManager.InvokeCount); BulkWriteReply merged = reply.WriteBulk; // Current behaviour: the merged reply is shorter than OriginalCount when // the worker under-supplies. Two slots survive — the worker result at // index 0 and the denied entry at index 1 — and the trailing slot is // silently dropped via Queue.TryDequeue returning false. Assert.Equal(2, merged.Results.Count); Assert.True(merged.Results[0].WasSuccessful); Assert.Equal(901, merged.Results[0].ItemHandle); Assert.False(merged.Results[1].WasSuccessful); Assert.Equal(902, merged.Results[1].ItemHandle); } /// /// Tests-024: when the worker returns MORE allowed results than the /// gateway forwarded, the extras must be silently ignored — the merged /// reply length stays at OriginalCount. This pins the /// for index < OriginalCount loop bound so a regression that /// accidentally surfaces extras as trailing results is caught. /// [Fact] public async Task Invoke_WriteBulk_WhenWorkerReturnsExtraResults_IgnoresExtras() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, itemHandle) => itemHandle == 902, }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); // Gateway forwards 2 allowed handles (901, 903) but the worker returns 4. sessionManager.InvokeReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.WriteBulk, ProtocolStatus = MxAccessGrpcMapper.Ok(), WriteBulk = new BulkWriteReply { Results = { new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true }, new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true }, new BulkWriteResult { ServerHandle = 7, ItemHandle = 999, WasSuccessful = true }, new BulkWriteResult { ServerHandle = 7, ItemHandle = 1000, WasSuccessful = true }, }, }, }, }; MxAccessGatewayService service = CreateService(sessionManager, enforcer); MxCommandReply reply = await service.Invoke( CreateWriteBulkRequest(7, [901, 902, 903]), new TestServerCallContext()); Assert.Equal(1, sessionManager.InvokeCount); BulkWriteReply merged = reply.WriteBulk; // Merged reply length stays at OriginalCount (3); the two extra worker // results (item handles 999, 1000) are silently discarded by the // OriginalCount-bounded loop. Assert.Equal(3, merged.Results.Count); Assert.Equal(901, merged.Results[0].ItemHandle); Assert.True(merged.Results[0].WasSuccessful); Assert.Equal(902, merged.Results[1].ItemHandle); Assert.False(merged.Results[1].WasSuccessful); Assert.Equal(903, merged.Results[2].ItemHandle); Assert.True(merged.Results[2].WasSuccessful); Assert.DoesNotContain(merged.Results, r => r.ItemHandle == 999); Assert.DoesNotContain(merged.Results, r => r.ItemHandle == 1000); } // === Unary write-handle enforcement (EnforceWriteHandleAsync) === /// /// Unary Write against a denied (server, item) handle must surface /// via EnforceWriteHandleAsync /// and never reach the session manager. /// [Fact] public async Task Invoke_Write_WithDeniedHandle_ThrowsPermissionDeniedAndDoesNotCallWorker() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (serverHandle, itemHandle) => serverHandle == 7 && itemHandle == 42, }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); RpcException exception = await Assert.ThrowsAsync( async () => await service.Invoke( CreateWriteRequest(serverHandle: 7, itemHandle: 42), new TestServerCallContext())); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Equal(0, sessionManager.InvokeCount); Assert.Single(enforcer.RecordedDenials); Assert.Equal("42", enforcer.RecordedDenials[0].Target); } /// /// Unary WriteSecured against a denied handle takes the same enforce path /// and rejects identically — proving the four-arm switch in /// ApplyConstraintsAsync (Write/Write2/WriteSecured/WriteSecured2) is /// reachable for at least one of the secured kinds. /// [Fact] public async Task Invoke_WriteSecured_WithDeniedHandle_ThrowsPermissionDenied() { PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); RpcException exception = await Assert.ThrowsAsync( async () => await service.Invoke( CreateWriteSecuredRequest(serverHandle: 7, itemHandle: 42), new TestServerCallContext())); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Equal(0, sessionManager.InvokeCount); } // === Unary read-tag enforcement (EnforceReadTagAsync via AddItem) === /// /// Unary AddItem against a denied tag must surface /// via EnforceReadTagAsync /// and never reach the session manager. /// [Fact] public async Task Invoke_AddItem_WithDeniedTag_ThrowsPermissionDeniedAndDoesNotCallWorker() { PredicateConstraintEnforcer enforcer = new() { DenyTag = tag => tag == "Secret.Tag", }; FakeSessionManager sessionManager = CreateSessionManagerWithSeed(); MxAccessGatewayService service = CreateService(sessionManager, enforcer); RpcException exception = await Assert.ThrowsAsync( async () => await service.Invoke( CreateAddItemRequest(serverHandle: 7, tagAddress: "Secret.Tag"), new TestServerCallContext())); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Equal(0, sessionManager.InvokeCount); Assert.Single(enforcer.RecordedDenials); Assert.Equal("Secret.Tag", enforcer.RecordedDenials[0].Target); } // === Helpers === private static MxAccessGatewayService CreateService( FakeSessionManager sessionManager, IConstraintEnforcer? constraintEnforcer = null) { return new MxAccessGatewayService( sessionManager, new GatewayRequestIdentityAccessor(), constraintEnforcer ?? new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), new MxAccessGrpcMapper(), new FakeEventStreamService(sessionManager), new GatewayMetrics(), NullLogger.Instance, new FakeGatewayAlarmService()); } private static FakeSessionManager CreateSessionManagerWithSeed() { FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true }; sessionManager.SeedSession(CreateSession(SessionId)); return sessionManager; } private static GatewaySession CreateSession(string sessionId) { GatewaySession session = new( sessionId, GatewayContractInfo.DefaultBackendName, "pipe", "nonce", "Operator Key", "operator-session", "client-correlation", TimeSpan.FromSeconds(7), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10), DateTimeOffset.UtcNow); session.AttachWorkerClient(new FakeWorkerClient()); session.MarkReady(); return session; } private static MxCommandRequest CreateAddItemBulkRequest(int serverHandle, IReadOnlyList tags) { AddItemBulkCommand cmd = new() { ServerHandle = serverHandle }; cmd.TagAddresses.Add(tags); return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.AddItemBulk, AddItemBulk = cmd }, }; } private static MxCommandRequest CreateSubscribeBulkRequest(int serverHandle, IReadOnlyList tags) { SubscribeBulkCommand cmd = new() { ServerHandle = serverHandle }; cmd.TagAddresses.Add(tags); return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.SubscribeBulk, SubscribeBulk = cmd }, }; } private static MxCommandRequest CreateAdviseItemBulkRequest(int serverHandle, IReadOnlyList itemHandles) { AdviseItemBulkCommand cmd = new() { ServerHandle = serverHandle }; cmd.ItemHandles.Add(itemHandles); return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.AdviseItemBulk, AdviseItemBulk = cmd }, }; } private static MxCommandRequest CreateReadBulkRequest(int serverHandle, IReadOnlyList tags) { ReadBulkCommand cmd = new() { ServerHandle = serverHandle, TimeoutMs = 1000 }; cmd.TagAddresses.Add(tags); return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.ReadBulk, ReadBulk = cmd }, }; } private static MxCommandRequest CreateWriteBulkRequest(int serverHandle, IReadOnlyList itemHandles) { WriteBulkCommand cmd = new() { ServerHandle = serverHandle }; foreach (int handle in itemHandles) { cmd.Entries.Add(new WriteBulkEntry { ItemHandle = handle, Value = new MxValue { StringValue = "v" } }); } return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = cmd }, }; } private static MxCommandRequest CreateWriteSecuredBulkRequest(int serverHandle, IReadOnlyList itemHandles) { WriteSecuredBulkCommand cmd = new() { ServerHandle = serverHandle }; foreach (int handle in itemHandles) { cmd.Entries.Add(new WriteSecuredBulkEntry { ItemHandle = handle, CurrentUserId = 1, VerifierUserId = 2, Value = new MxValue { StringValue = "v" }, }); } return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.WriteSecuredBulk, WriteSecuredBulk = cmd }, }; } private static MxCommandRequest CreateWrite2BulkRequest(int serverHandle, IReadOnlyList itemHandles) { Write2BulkCommand cmd = new() { ServerHandle = serverHandle }; foreach (int handle in itemHandles) { cmd.Entries.Add(new Write2BulkEntry { ItemHandle = handle, Value = new MxValue { StringValue = "v" }, TimestampValue = new MxValue { Int64Value = 1234567890L }, }); } return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.Write2Bulk, Write2Bulk = cmd }, }; } private static MxCommandRequest CreateWriteSecured2BulkRequest(int serverHandle, IReadOnlyList itemHandles) { WriteSecured2BulkCommand cmd = new() { ServerHandle = serverHandle }; foreach (int handle in itemHandles) { cmd.Entries.Add(new WriteSecured2BulkEntry { ItemHandle = handle, CurrentUserId = 1, VerifierUserId = 2, Value = new MxValue { StringValue = "v" }, TimestampValue = new MxValue { Int64Value = 1234567890L }, }); } return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.WriteSecured2Bulk, WriteSecured2Bulk = cmd }, }; } private static MxCommandRequest CreateWriteRequest(int serverHandle, int itemHandle) { return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.Write, Write = new WriteCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, Value = new MxValue { StringValue = "v" }, }, }, }; } private static MxCommandRequest CreateWriteSecuredRequest(int serverHandle, int itemHandle) { return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.WriteSecured, WriteSecured = new WriteSecuredCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, CurrentUserId = 1, VerifierUserId = 2, Value = new MxValue { StringValue = "v" }, }, }, }; } private static MxCommandRequest CreateAddItemRequest(int serverHandle, string tagAddress) { return new MxCommandRequest { SessionId = SessionId, Command = new MxCommand { Kind = MxCommandKind.AddItem, AddItem = new AddItemCommand { ServerHandle = serverHandle, ItemDefinition = tagAddress, }, }, }; } // FakeSessionManager / FakeEventStreamService / FakeWorkerClient mirror the // implementations in MxAccessGatewayServiceTests; the duplication is intentional // so the constraint tests are self-contained and changes to the existing fakes // don't accidentally couple the two suites. private sealed class FakeSessionManager : ISessionManager { private readonly Dictionary seededSessions = new(StringComparer.Ordinal); /// Gets a value indicating whether only seeded sessions should be resolved. public bool ResolveOnlySeededSessions { get; init; } /// Gets the last worker command that was invoked. public WorkerCommand? LastWorkerCommand { get; private set; } /// Gets the count of invoke calls made. public int InvokeCount { get; private set; } /// Gets or sets the default invoke reply to return. public WorkerCommandReply InvokeReply { get; set; } = new() { Reply = new MxCommandReply { SessionId = SessionId, Kind = MxCommandKind.Ping, ProtocolStatus = MxAccessGrpcMapper.Ok(), }, }; /// Gets the collection of events to stream. public List Events { get; } = []; /// Seeds a test session into the fake manager. /// The session to seed. public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session; /// Opens a test session asynchronously. /// The session open request. /// The client identity, if any. /// Token to observe for cancellation. public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, CancellationToken cancellationToken) => Task.FromResult(seededSessions.Values.First()); /// Tries to get a test session by identifier. /// The session identifier. /// The session, if found. public bool TryGetSession(string sessionId, out GatewaySession session) { if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded)) { session = seeded; return true; } if (ResolveOnlySeededSessions) { session = null!; return false; } session = CreateFallbackSession(sessionId); return true; } /// Invokes a worker command and returns the reply asynchronously. /// The session identifier. /// The worker command. /// Token to observe for cancellation. public Task InvokeAsync( string sessionId, WorkerCommand command, CancellationToken cancellationToken) { InvokeCount++; LastWorkerCommand = command; return Task.FromResult(InvokeReply); } /// Reads events from the session asynchronously. /// The session identifier. /// Token to observe for cancellation. public async IAsyncEnumerable ReadEventsAsync( string sessionId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { foreach (WorkerEvent ev in Events) { cancellationToken.ThrowIfCancellationRequested(); await Task.Yield(); yield return ev; } } /// Closes a test session asynchronously. /// The session identifier. /// Token to observe for cancellation. public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); /// Kills a worker process asynchronously. /// The session identifier. /// The reason for killing the worker. /// Token to observe for cancellation. public Task KillWorkerAsync( string sessionId, string reason, CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); /// Closes expired session leases asynchronously. /// The current time to check against. /// Token to observe for cancellation. public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(0); /// Shuts down the test session manager asynchronously. /// Token to observe for cancellation. public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; private static GatewaySession CreateFallbackSession(string sessionId) { GatewaySession session = new( sessionId, GatewayContractInfo.DefaultBackendName, "pipe", "nonce", "Operator Key", "operator-session", "client-correlation", TimeSpan.FromSeconds(7), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10), DateTimeOffset.UtcNow); session.AttachWorkerClient(new FakeWorkerClient()); session.MarkReady(); return session; } } private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService { /// Streams events for the test session asynchronously. /// The stream events request. /// Token to observe for cancellation. public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { foreach (WorkerEvent ev in sessionManager.Events) { cancellationToken.ThrowIfCancellationRequested(); await Task.Yield(); yield return ev.Event; } } } private sealed class FakeWorkerClient : IWorkerClient { /// Gets the test session identifier. public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId; /// Gets the test worker process identifier. public int? ProcessId { get; } = 1234; /// Gets the test worker client state. public WorkerClientState State { get; } = WorkerClientState.Ready; /// Gets the last recorded heartbeat time. public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; /// Starts the test worker client asynchronously. /// Token to observe for cancellation. public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// Invokes a command on the test worker asynchronously. /// The worker command. /// Maximum time to wait for completion. /// Token to observe for cancellation. public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); /// Reads events from the test worker asynchronously. /// Token to observe for cancellation. public async IAsyncEnumerable ReadEventsAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } /// Shuts down the test worker client asynchronously. /// Maximum time to wait for completion. /// Token to observe for cancellation. public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; /// Kills the test worker process. /// The reason for killing the worker. public void Kill(string reason) { } /// public ValueTask DisposeAsync() => ValueTask.CompletedTask; } }