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;
}
}