Code-review 2026-05-20 sweep: re-review at 1cd51bb, resolve 72 findings across all 11 modules

Re-reviewed every module/client against the 10-category checklist
(REVIEW-PROCESS.md) at commit 1cd51bb, filed 72 new findings, and
fixed them in three priority waves (3 High, 17 Medium, 52 Low).

Highs
- Server-017: enumerate AcknowledgeAlarm / QueryActiveAlarms in
  GatewayGrpcScopeResolver so non-admin keys can use them; document
  the mapping in docs/Authorization.md; add interceptor tests.
- Client.Java-013: add the five missing bulk-method stubs to the
  CLI FakeSession so the test module compiles on a clean tree.
- Client.Rust-013: fix the clippy::doc_lazy_continuation regression
  in generated tonic code by reformatting the ReadBulkCommand proto
  comment and scoping a #![allow(...)] to the generated submodules.

Mediums (highlights)
- Server: unify GatewaySession state-lock discipline (-015) and
  make DisposeAsync race-safe against in-flight CloseAsync (-016);
  add constraint-enforcement test coverage for the bulk-plan path
  (-021).
- Worker: introduce StaRuntimeShutdownException so RunAlarmPollLoop
  can distinguish graceful shutdown from a real STA-affinity
  violation (-016); have the watchdog skip StaHung while
  CurrentCommandCorrelationId is non-empty so a legitimate slow
  ReadBulk no longer self-faults (-017).
- Tests: add per-method round-trip + cancellation coverage for the
  11 GatewaySession bulk methods (-013); replace the real TCP probe
  in GalaxyHierarchyCacheTests with an IGalaxyRepository fake
  (-016).
- IntegrationTests: drive the StreamEvents writer in the live Write
  test and assert OnWriteComplete (-012); add live tests for
  Unadvise/RemoveItem/Unregister ordering, WriteSecured, and
  abnormal worker exit (-014).
- Worker.Tests: replace MxAccessSession reflection with an internal
  CreateForTesting factory (-016); cover WorkerCancel and
  unexpected-body envelope branches (-017).
- Client.Java: cancel MxEventStream when close() races
  beforeStart() (-014); return a CancellingCompletableFuture that
  actually forwards cancellation through .thenApply chains (-015).
- Client.Python: drop the silent localhost-plaintext downgrade in
  the CLI; require explicit --plaintext (-013).
- Client.Rust: stop bench-read-bulk from polluting success-latency
  histograms with failed-call durations (-015); add coverage for
  the five MalformedReply paths, the bulk-write helpers, the
  Error::Unavailable mapping, and the unary-fault path (-016).
- Contracts: extend docs/Contracts.md with the bulk read/write
  command family (-009).

Lows (highlights)
- Server: cap GalaxyGlobMatcher.RegexCache; align
  WorkerAlarmRpcDispatcher missing-session handling; drop the
  duplicate dashboard @page routes; refresh IAlarmRpcDispatcher
  XML doc.
- Worker: surface SetXmlAlarmQuery COM failures; remove dead
  subscriptionExpression / ExecutingCommand arms; preserve
  factory-supplied runtime sessions; split MxAlarmSnapshot.cs into
  three files.
- Tests: dispose the WebApplication in seven test classes; rebuild
  FakeWorkerProcess.WaitForExitAsync against a real TaskCompletion
  source; switch the heartbeat-expires test to ManualTimeProvider;
  add InvariantCulture to the remaining DateTimeOffset.Parse sites;
  document GalaxyFilterInputSafetyTests in GatewayTesting.md.
- IntegrationTests: comment fixes, RecordingServerStreamWriter
  IDisposable, class-level [Trait], single-source ZB default
  connection string.
- Worker.Tests: replace silent-return gating with LiveMxAccessFact
  so absent env vars SKIP not pass; PascalCase rename of probe
  [Fact]s; deterministic deadline test; new frame-protocol error
  tests; ComputeTransitions diff-coverage; relocate dev-rig probes
  to Probes/.
- Contracts: add round-trip coverage and per-field redaction /
  Galaxy-identifier comments to the protos.
- Client.Dotnet: introduce clients/dotnet/Directory.Build.props so
  TreatWarningsAsErrors / analysers apply; document
  DiscoverHierarchyOptions and IMxGatewayCliClient; require typed
  bulk-read handles in CLI; surface AcknowledgeAlarm transport
  faults through Translate().
- Client.Go: kill dead code in alarms_test / fakeGalaxyServer /
  runWriteBulkVariant; document the six new subcommands in
  writeUsage; drain galaxy-watch events on limit; switch io.EOF
  comparisons to errors.Is.
- Client.Java: shared shutdown helpers + new shutdownTimeout
  option; regex-based credential redaction; Long.toUnsignedString
  for uint64 sequence; doc fixes.
- Client.Python: combine duplicate imports; add coverage for
  _percentile / bench-read-bulk / MAX_AGGREGATE_EVENTS /
  _api_key_from_env; populate pyproject metadata and ship py.typed.
- Client.Rust: expose next_correlation_id() so CLI ping/close
  stop hard-coding correlation IDs; resync RustClientDesign.md
  with the current Session / Error surface and CLI subcommand set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-20 09:46:47 -04:00
parent 1cd51bbda3
commit a0203503a7
122 changed files with 8723 additions and 757 deletions
@@ -11,7 +11,18 @@ public sealed class GatewayContractInfoTests
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
}
/// <summary>Verifies that the gateway protocol version is bumped to three after the alarm proto extension.</summary>
/// <summary>
/// Pins the current <see cref="GatewayContractInfo.GatewayProtocolVersion"/>
/// constant at 3. Both the alarm proto extension (`AcknowledgeAlarm` /
/// `QueryActiveAlarms` RPCs, the `OnAlarmTransitionEvent` body, and the
/// alarm command/reply payload cases) and the bulk write/read command
/// family extension (`WriteBulk` / `Write2Bulk` / `WriteSecuredBulk` /
/// `WriteSecured2Bulk` / `ReadBulk` plus their `BulkWriteReply` and
/// `BulkReadReply` payloads) shipped under version 3 — both were strictly
/// additive contract changes, so neither required a further bump. A
/// future breaking contract change should bump this constant and update
/// this test in lock-step.
/// </summary>
[Fact]
public void GatewayProtocolVersion_IsVersionThree()
{
@@ -770,4 +770,463 @@ public sealed class ProtobufContractRoundTripTests
Assert.Equal(lastDeploy, GetLastDeployTimeReply.Parser.ParseFrom(lastDeploy.ToByteArray()));
Assert.Equal(testConnection, TestConnectionReply.Parser.ParseFrom(testConnection.ToByteArray()));
}
/// <summary>
/// Verifies that a <see cref="WriteBulkCommand"/> carrying multiple
/// <see cref="WriteBulkEntry"/> items round-trips, including the
/// per-entry <c>value</c> and <c>user_id</c> fields.
/// </summary>
[Fact]
public void WriteBulkCommand_RoundTripsEntries()
{
var original = new MxCommand
{
Kind = MxCommandKind.WriteBulk,
WriteBulk = new WriteBulkCommand
{
ServerHandle = 10,
Entries =
{
new WriteBulkEntry
{
ItemHandle = 21,
UserId = 7,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 1.25f,
VariantType = "VT_R4",
},
},
new WriteBulkEntry
{
ItemHandle = 22,
Value = new MxValue
{
DataType = MxDataType.Integer,
Int32Value = 42,
VariantType = "VT_I4",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.WriteBulk, parsed.PayloadCase);
Assert.Equal(2, parsed.WriteBulk.Entries.Count);
Assert.Equal(7, parsed.WriteBulk.Entries[0].UserId);
}
/// <summary>
/// Verifies that a <see cref="Write2BulkCommand"/> round-trips, including
/// the per-entry <c>timestamp_value</c> field that distinguishes Write2
/// from Write.
/// </summary>
[Fact]
public void Write2BulkCommand_RoundTripsEntriesWithTimestampValue()
{
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 0, 0, DateTimeKind.Utc));
var original = new MxCommand
{
Kind = MxCommandKind.Write2Bulk,
Write2Bulk = new Write2BulkCommand
{
ServerHandle = 10,
Entries =
{
new Write2BulkEntry
{
ItemHandle = 21,
UserId = 7,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 99.9f,
VariantType = "VT_R4",
},
TimestampValue = new MxValue
{
DataType = MxDataType.Time,
TimestampValue = timestamp,
VariantType = "VT_DATE",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.Write2Bulk, parsed.PayloadCase);
Assert.NotNull(parsed.Write2Bulk.Entries[0].TimestampValue);
}
/// <summary>
/// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips,
/// pinning the credential-bearing entry shape
/// (<c>current_user_id</c>, <c>verifier_user_id</c>, <c>value</c>).
/// See Contracts-011 for the credential-sensitivity comment on
/// <c>WriteSecuredBulkEntry.value</c>.
/// </summary>
[Fact]
public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries()
{
var original = new MxCommand
{
Kind = MxCommandKind.WriteSecuredBulk,
WriteSecuredBulk = new WriteSecuredBulkCommand
{
ServerHandle = 10,
Entries =
{
new WriteSecuredBulkEntry
{
ItemHandle = 21,
CurrentUserId = 100,
VerifierUserId = 200,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 75.0f,
VariantType = "VT_R4",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecuredBulk, parsed.PayloadCase);
Assert.Equal(100, parsed.WriteSecuredBulk.Entries[0].CurrentUserId);
Assert.Equal(200, parsed.WriteSecuredBulk.Entries[0].VerifierUserId);
}
/// <summary>
/// Verifies that a <see cref="WriteSecured2BulkCommand"/> round-trips,
/// including both the credential-sensitive <c>value</c> and the
/// <c>timestamp_value</c> per entry.
/// </summary>
[Fact]
public void WriteSecured2BulkCommand_RoundTripsCredentialBearingEntriesWithTimestamp()
{
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 30, 0, DateTimeKind.Utc));
var original = new MxCommand
{
Kind = MxCommandKind.WriteSecured2Bulk,
WriteSecured2Bulk = new WriteSecured2BulkCommand
{
ServerHandle = 10,
Entries =
{
new WriteSecured2BulkEntry
{
ItemHandle = 21,
CurrentUserId = 100,
VerifierUserId = 200,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 50.0f,
VariantType = "VT_R4",
},
TimestampValue = new MxValue
{
DataType = MxDataType.Time,
TimestampValue = timestamp,
VariantType = "VT_DATE",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecured2Bulk, parsed.PayloadCase);
Assert.NotNull(parsed.WriteSecured2Bulk.Entries[0].TimestampValue);
}
/// <summary>
/// Verifies that a <see cref="ReadBulkCommand"/> round-trips, including
/// the <c>tag_addresses</c> list and the <c>timeout_ms</c> field that
/// distinguishes the cached-vs-snapshot lifecycle.
/// </summary>
[Fact]
public void ReadBulkCommand_RoundTripsTagAddressesAndTimeout()
{
var original = new MxCommand
{
Kind = MxCommandKind.ReadBulk,
ReadBulk = new ReadBulkCommand
{
ServerHandle = 10,
TagAddresses = { "Provider!Tank01.Level", "Provider!Tank02.Level" },
TimeoutMs = 2500,
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
Assert.Equal(2, parsed.ReadBulk.TagAddresses.Count);
Assert.Equal(2500u, parsed.ReadBulk.TimeoutMs);
}
/// <summary>
/// Verifies that a <see cref="BulkWriteReply"/> carrying mixed-outcome
/// <see cref="BulkWriteResult"/> entries round-trips and that the
/// proto3 <c>optional int32 hresult</c> presence flag survives both the
/// "hresult set" and "hresult unset" cases.
/// </summary>
[Fact]
public void BulkWriteReply_RoundTripsResultsWithOptionalHresultPresence()
{
var original = new BulkWriteReply
{
Results =
{
new BulkWriteResult
{
ServerHandle = 10,
ItemHandle = 21,
WasSuccessful = true,
Hresult = 0,
Statuses =
{
new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingLmx,
},
},
},
new BulkWriteResult
{
ServerHandle = 10,
ItemHandle = 22,
WasSuccessful = false,
Hresult = unchecked((int)0x80004005),
ErrorMessage = "item not advised",
},
new BulkWriteResult
{
ServerHandle = 10,
ItemHandle = 23,
WasSuccessful = false,
// Hresult deliberately UNSET — exercises the proto3
// `optional int32` HasField() = false arm.
ErrorMessage = "tag rejected by allowlist",
},
},
};
var parsed = BulkWriteReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(3, parsed.Results.Count);
Assert.True(parsed.Results[0].HasHresult);
Assert.True(parsed.Results[1].HasHresult);
Assert.False(parsed.Results[2].HasHresult);
Assert.True(parsed.Results[0].WasSuccessful);
Assert.False(parsed.Results[2].WasSuccessful);
Assert.Single(parsed.Results[0].Statuses);
}
/// <summary>
/// Verifies that a <see cref="BulkReadReply"/> carrying both cached
/// (<c>was_cached = true</c>) and uncached (<c>was_cached = false</c>)
/// <see cref="BulkReadResult"/> entries round-trips. Pins the
/// deliberate absence of <c>hresult</c> on <see cref="BulkReadResult"/>
/// — failures are carried as <c>was_successful = false</c> plus
/// <c>error_message</c> only.
/// </summary>
[Fact]
public void BulkReadReply_RoundTripsCachedAndSnapshotResults()
{
var sourceTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc));
var original = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 10,
TagAddress = "Provider!Tank01.Level",
ItemHandle = 21,
WasSuccessful = true,
WasCached = true,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 42.5f,
VariantType = "VT_R4",
},
Quality = 192,
SourceTimestamp = sourceTimestamp,
Statuses =
{
new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingNmx,
},
},
},
new BulkReadResult
{
ServerHandle = 10,
TagAddress = "Provider!Tank02.Level",
ItemHandle = 22,
WasSuccessful = true,
WasCached = false,
Value = new MxValue
{
DataType = MxDataType.Integer,
Int32Value = 0,
VariantType = "VT_I4",
},
SourceTimestamp = sourceTimestamp,
},
new BulkReadResult
{
ServerHandle = 10,
TagAddress = "Provider!Bad.Tag",
WasSuccessful = false,
WasCached = false,
ErrorMessage = "snapshot timed out before first OnDataChange",
},
},
};
var parsed = BulkReadReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(3, parsed.Results.Count);
Assert.True(parsed.Results[0].WasCached);
Assert.False(parsed.Results[1].WasCached);
Assert.False(parsed.Results[2].WasSuccessful);
Assert.Equal("snapshot timed out before first OnDataChange", parsed.Results[2].ErrorMessage);
// BulkReadResult has no `hresult` field — pin that contract.
Assert.DoesNotContain(
BulkReadResult.Descriptor.Fields.InDeclarationOrder(),
field => field.Name == "hresult");
}
/// <summary>
/// Verifies that an <see cref="MxCommandReply"/> with each of the new
/// bulk write/read payload oneof cases round-trips and that
/// <see cref="MxCommandReply.PayloadOneofCase"/> resolves to the
/// expected value. Pins every new oneof case added by the bulk
/// write/read extension.
/// </summary>
[Theory]
[InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)]
[InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)]
[InlineData(MxCommandKind.WriteSecuredBulk, MxCommandReply.PayloadOneofCase.WriteSecuredBulk)]
[InlineData(MxCommandKind.WriteSecured2Bulk, MxCommandReply.PayloadOneofCase.WriteSecured2Bulk)]
public void MxCommandReply_RoundTripsBulkWritePayloadCases(
MxCommandKind kind,
MxCommandReply.PayloadOneofCase expectedPayloadCase)
{
var reply = new BulkWriteReply
{
Results =
{
new BulkWriteResult
{
ServerHandle = 5,
ItemHandle = 7,
WasSuccessful = true,
Hresult = 0,
},
},
};
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-bulk-write",
Kind = kind,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
};
switch (expectedPayloadCase)
{
case MxCommandReply.PayloadOneofCase.WriteBulk:
original.WriteBulk = reply;
break;
case MxCommandReply.PayloadOneofCase.Write2Bulk:
original.Write2Bulk = reply;
break;
case MxCommandReply.PayloadOneofCase.WriteSecuredBulk:
original.WriteSecuredBulk = reply;
break;
case MxCommandReply.PayloadOneofCase.WriteSecured2Bulk:
original.WriteSecured2Bulk = reply;
break;
default:
throw new System.ArgumentOutOfRangeException(nameof(expectedPayloadCase));
}
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(expectedPayloadCase, parsed.PayloadCase);
Assert.Equal(kind, parsed.Kind);
}
/// <summary>
/// Verifies that an <see cref="MxCommandReply"/> with kind
/// <see cref="MxCommandKind.ReadBulk"/> and a populated
/// <see cref="BulkReadReply"/> payload round-trips and resolves to
/// <see cref="MxCommandReply.PayloadOneofCase.ReadBulk"/>.
/// </summary>
[Fact]
public void MxCommandReply_RoundTripsReadBulkPayload()
{
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-read-bulk",
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 5,
TagAddress = "Provider!Tank01.Level",
ItemHandle = 7,
WasSuccessful = true,
WasCached = true,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 12.5f,
VariantType = "VT_R4",
},
},
},
},
};
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommandReply.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
Assert.Single(parsed.ReadBulk.Results);
Assert.True(parsed.ReadBulk.Results[0].WasCached);
}
}
@@ -110,6 +110,29 @@ public sealed class GalaxyFilterInputSafetyTests
}
}
/// <summary>
/// Regression guard for finding Server-018: <see cref="GalaxyGlobMatcher"/>'s
/// internal compiled-regex cache must stay bounded so a client cannot grow it
/// without limit by submitting unique <c>TagNameGlob</c> values over the
/// process lifetime. Feeding the matcher far more distinct globs than the cap
/// must leave <c>CurrentCacheSize</c> at or below <c>RegexCacheCapacity</c>.
/// </summary>
[Fact]
public void GlobMatcher_WithManyDistinctPatterns_CacheStaysBounded()
{
// Submit well past the cap from a single thread to exercise the eviction path
// deterministically. The cap is internal; assert on it directly so the test
// tracks the source of truth.
int submissions = GalaxyGlobMatcher.RegexCacheCapacity * 4;
for (int i = 0; i < submissions; i++)
{
string uniqueGlob = $"client_supplied_{i}_*";
GalaxyGlobMatcher.IsMatch($"client_supplied_{i}_thing", uniqueGlob);
}
Assert.InRange(GalaxyGlobMatcher.CurrentCacheSize, 0, GalaxyGlobMatcher.RegexCacheCapacity);
}
/// <summary>
/// Verifies a pathological glob does not cause catastrophic regex backtracking —
/// <see cref="GalaxyGlobMatcher"/> escapes every literal character and applies a
@@ -12,7 +12,8 @@ public sealed class GalaxyHierarchyCacheTests
public void Current_BeforeAnyRefresh_ReturnsEmpty()
{
GalaxyDeployNotifier notifier = new();
GalaxyHierarchyCache cache = CreateCache(notifier, new ManualTimeProvider());
ThrowingGalaxyRepository repository = new(new InvalidOperationException("not invoked"));
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider());
GalaxyHierarchyCacheEntry entry = cache.Current;
@@ -23,21 +24,28 @@ public sealed class GalaxyHierarchyCacheTests
}
/// <summary>
/// Verifies cache marks unavailable and does not publish when SQL is unreachable.
/// Verifies cache marks unavailable and does not publish when the repository
/// surface throws — the production trigger for this code path is a SQL
/// connection failure, but it is fully covered by the cache's exception
/// branch and does not require a real TCP probe from a unit test.
/// </summary>
[Fact]
public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish()
public async Task RefreshAsync_WhenRepositoryThrows_MarksUnavailableAndDoesNotPublish()
{
GalaxyDeployNotifier notifier = new();
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z"));
GalaxyHierarchyCache cache = CreateCache(notifier, clock);
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
GalaxyHierarchyCache cache = new(repository, notifier, clock);
await cache.RefreshAsync(CancellationToken.None);
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
Assert.False(string.IsNullOrWhiteSpace(cache.Current.LastError));
Assert.Equal("Galaxy repository unreachable", cache.Current.LastError);
Assert.Null(notifier.Latest);
Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
Assert.Equal(1, repository.GetLastDeployTimeCount);
Assert.Equal(0, repository.GetHierarchyCount);
Assert.Equal(0, repository.GetAttributesCount);
}
/// <summary>
@@ -112,15 +120,40 @@ public sealed class GalaxyHierarchyCacheTests
Assert.Same(root, index.ObjectViewsById[1].Object);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
{
GalaxyRepositoryOptions options = new()
/// <summary>Gets the number of times <see cref="GetLastDeployTimeAsync"/> was called.</summary>
public int GetLastDeployTimeCount { get; private set; }
/// <summary>Gets the number of times <see cref="GetHierarchyAsync"/> was called.</summary>
public int GetHierarchyCount { get; private set; }
/// <summary>Gets the number of times <see cref="GetAttributesAsync"/> was called.</summary>
public int GetAttributesCount { get; private set; }
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1,
};
MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock);
GetLastDeployTimeCount++;
throw toThrow;
}
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
GetHierarchyCount++;
throw toThrow;
}
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
GetAttributesCount++;
throw toThrow;
}
}
private sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider
@@ -12,9 +12,9 @@ public sealed class DashboardCookieOptionsTests
{
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
[Fact]
public void Build_ConfiguresSecureDashboardCookie()
public async Task Build_ConfiguresSecureDashboardCookie()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
@@ -42,19 +43,19 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession activeSession = CreateSession(
"session-active",
"client-one",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready));
activeSession.MarkReady();
GatewaySession faultedSession = CreateSession(
"session-faulted",
"client-two",
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted));
faultedSession.MarkFaulted("worker pipe disconnected");
GatewaySession closedSession = CreateSession(
"session-closed",
"client-three",
DateTimeOffset.Parse("2026-04-26T09:59:00Z"));
DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture));
closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed));
closedSession.TransitionTo(SessionState.Closed);
registry.TryAdd(activeSession);
@@ -102,7 +103,7 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession session = CreateSession(
"session-redacted",
"Bearer mxgw_admin_super-secret",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"),
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture),
clientSessionName: "password=hunter2",
clientCorrelationId: "token=abc123");
session.MarkFaulted("secret=credential-value");
@@ -131,7 +132,7 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession session = CreateSession(
"session-active",
"client-one",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready);
session.AttachWorkerClient(workerClient);
session.MarkReady();
@@ -160,11 +161,11 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession olderSession = CreateSession(
"session-older",
"client-one",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
GatewaySession newerSession = CreateSession(
"session-newer",
"client-two",
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
olderSession.MarkFaulted("older fault");
newerSession.MarkFaulted("newer fault");
registry.TryAdd(olderSession);
@@ -199,14 +200,14 @@ public sealed class DashboardSnapshotServiceTests
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
DashboardSummary = new DashboardGalaxySummary(
DashboardGalaxyStatus.Healthy,
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
LastError: null,
ObjectCount: 3,
AreaCount: 1,
@@ -281,7 +282,7 @@ public sealed class DashboardSnapshotServiceTests
{
BrowseSubtrees = ["Area1/*"],
},
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
@@ -314,7 +315,7 @@ public sealed class DashboardSnapshotServiceTests
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
@@ -520,7 +521,7 @@ public sealed class DashboardSnapshotServiceTests
/// <summary>
/// Gets the timestamp of the last heartbeat.
/// </summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z");
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture);
/// <summary>
/// Gets the count of start invocations.
@@ -13,9 +13,9 @@ public sealed class GatewayApplicationTests
{
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
[Fact]
public void Build_MapsLiveHealthEndpoint()
public async Task Build_MapsLiveHealthEndpoint()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
RouteEndpoint endpoint = Assert.Single(
((IEndpointRouteBuilder)app).DataSources
@@ -28,9 +28,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
[Fact]
public void Build_RegistersGatewayMetrics()
public async Task Build_RegistersGatewayMetrics()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
GatewayMetrics metrics = app.Services.GetRequiredService<GatewayMetrics>();
@@ -39,9 +39,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
[Fact]
public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
@@ -57,9 +57,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that the dashboard login, logout, and denied endpoints allow anonymous access.</summary>
[Fact]
public void Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] anonymousEndpointNames =
@@ -76,9 +76,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that dashboard Razor component routes require the dashboard authorization policy.</summary>
[Fact]
public void Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] componentRoutes =
@@ -99,10 +99,42 @@ public sealed class GatewayApplicationTests
}
}
/// <summary>
/// Server-020 regression: every dashboard Razor page used to declare both
/// <c>@page "/X"</c> and <c>@page "/dashboard/X"</c>, which — once
/// <c>MapGroup("/dashboard")</c> prepended the path base — produced both
/// <c>/dashboard/X</c> AND <c>/dashboard/dashboard/X</c> routes. The second
/// shape was almost certainly accidental and is no longer registered. The
/// check covers every dashboard page so a future page that brings back the
/// duplicated <c>@page</c> directive fails CI.
/// </summary>
[Fact]
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
public async Task Build_WhenDashboardEnabled_DoesNotRegisterDoubledDashboardPrefixRoutes()
{
WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] doubledRoutes =
[
"/dashboard/dashboard/",
"/dashboard/dashboard/sessions",
"/dashboard/dashboard/workers",
"/dashboard/dashboard/events",
"/dashboard/dashboard/settings",
"/dashboard/dashboard/galaxy",
"/dashboard/dashboard/apikeys",
"/dashboard/dashboard/sessions/{SessionId}",
];
foreach (string doubled in doubledRoutes)
{
Assert.DoesNotContain(endpoints, endpoint => endpoint.RoutePattern.RawText == doubled);
}
}
[Fact]
public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{
await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.DoesNotContain(endpoints, endpoint =>
@@ -85,6 +85,10 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
Assert.Equal(ProtocolStatusCode.Ok, closeReply.ProtocolStatus.Code);
Assert.Equal(SessionState.Closed, closeReply.FinalState);
Assert.True(launcher.Process.HasExited);
// MarkExited(0) is reached only after the scripted worker observed a WorkerShutdown
// envelope and emitted its WorkerShutdownAck — anything else (a kill, a fault) would
// have produced a non-zero exit code, so this pins the shutdown-ack handshake.
Assert.Equal(0, launcher.Process.ExitCode);
Assert.Equal(
[MxCommandKind.Register, MxCommandKind.AddItem, MxCommandKind.Advise],
launcher.CommandKinds);
@@ -351,6 +355,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>
/// Gets the process identifier.
/// </summary>
@@ -367,15 +373,15 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
public int? ExitCode { get; private set; }
/// <summary>
/// Waits for the process to exit asynchronously.
/// Waits for the process to exit asynchronously. Completes only when <see cref="Kill"/>
/// or <see cref="MarkExited"/> has been called, so callers that observe completion can
/// trust that exit actually happened (e.g., via the worker shutdown-ack path).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
/// <returns>A task that completes when the process has actually exited.</returns>
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
HasExited = true;
ExitCode ??= 0;
return ValueTask.CompletedTask;
return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
}
/// <summary>
@@ -402,6 +408,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
{
HasExited = true;
ExitCode = exitCode;
_exited.TrySetResult();
}
}
@@ -0,0 +1,757 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
using MxGateway.Tests.TestSupport;
namespace MxGateway.Tests.Gateway.Grpc;
/// <summary>
/// Tests for Server-021. <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and
/// the <c>BulkConstraintPlan</c> / <c>ReadBulkConstraintPlan</c> /
/// <c>WriteBulkConstraintPlan</c> / <c>SubscribeBulkConstraintPlan</c> 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 <see cref="PredicateConstraintEnforcer"/> 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.
/// </summary>
public sealed class MxAccessGatewayServiceConstraintTests
{
private const string SessionId = "session-constraint";
// === SubscribeBulk family: AddItemBulk / SubscribeBulk / AdviseItemBulk ===
/// <summary>
/// <c>AddItemBulk</c> 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.
/// </summary>
[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);
}
/// <summary>
/// <c>SubscribeBulk</c> when every tag is denied must short-circuit
/// <see cref="BulkConstraintPlan.HasAllowedItems"/> false, return the
/// denied-only reply, and never call the session manager.
/// </summary>
[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);
}
/// <summary>
/// <c>AdviseItemBulk</c> takes handle inputs (not tags) and routes through
/// <c>FilterHandleBulkAsync</c> against <c>CheckReadHandleAsync</c>. Partial
/// denial must still produce a merged-by-index <c>BulkSubscribeReply</c>.
/// </summary>
[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);
}
/// <summary>
/// <c>SubscribeBulk</c> 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.
/// </summary>
[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 ===
/// <summary>
/// <c>ReadBulk</c> with a mix of allowed and denied tags merges denied entries
/// into the <c>BulkReadReply</c> in original-index order, distinguishable from
/// the SubscribeBulk family because the reply slot is
/// <c>BulkReadReply</c>, not <c>BulkSubscribeReply</c>.
/// </summary>
[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);
}
/// <summary>
/// <c>ReadBulk</c> with all tags denied must short-circuit and produce a
/// denied-only <c>BulkReadReply</c> — verifying
/// <see cref="MxAccessGatewayService"/>'s <c>ReadBulkConstraintPlan</c>
/// <c>CreateDeniedReply</c> path.
/// </summary>
[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 ===
/// <summary>
/// <c>WriteBulk</c> with one denied handle must drop that entry from the
/// forwarded command and splice a denied <c>BulkWriteResult</c> back in at
/// the original index.
/// </summary>
[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);
}
/// <summary>
/// <c>WriteSecuredBulk</c> exercises a different <c>ReplaceWriteBulkEntries</c>
/// switch arm than plain <c>WriteBulk</c>. The merge logic is shared, so a
/// full denial here is enough to prove the secured-bulk routing.
/// </summary>
[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));
}
// === Unary write-handle enforcement (EnforceWriteHandleAsync) ===
/// <summary>
/// Unary <c>Write</c> against a denied (server, item) handle must surface
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceWriteHandleAsync</c>
/// and never reach the session manager.
/// </summary>
[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<RpcException>(
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);
}
/// <summary>
/// Unary <c>WriteSecured</c> against a denied handle takes the same enforce path
/// and rejects identically — proving the four-arm switch in
/// <c>ApplyConstraintsAsync</c> (Write/Write2/WriteSecured/WriteSecured2) is
/// reachable for at least one of the secured kinds.
/// </summary>
[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<RpcException>(
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) ===
/// <summary>
/// Unary <c>AddItem</c> against a denied tag must surface
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceReadTagAsync</c>
/// and never reach the session manager.
/// </summary>
[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<RpcException>(
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<MxAccessGatewayService>.Instance);
}
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<string> 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<string> 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<int> 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<string> 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<int> 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<int> 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 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<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
public bool ResolveOnlySeededSessions { get; init; }
public WorkerCommand? LastWorkerCommand { get; private set; }
public int InvokeCount { get; private set; }
public WorkerCommandReply InvokeReply { get; set; } = new()
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.Ping,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
},
};
public List<WorkerEvent> Events { get; } = [];
public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session;
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken) =>
Task.FromResult(seededSessions.Values.First());
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;
}
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
InvokeCount++;
LastWorkerCommand = command;
return Task.FromResult(InvokeReply);
}
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (WorkerEvent ev in Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return ev;
}
}
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
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
{
public async IAsyncEnumerable<MxEvent> 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
{
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
public void Kill(string reason)
{
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -422,12 +422,13 @@ public sealed class MxAccessGatewayServiceTests
private static MxAccessGatewayService CreateService(
FakeSessionManager sessionManager,
IGatewayRequestIdentityAccessor? identityAccessor = null,
GatewayMetrics? metrics = null)
GatewayMetrics? metrics = null,
IConstraintEnforcer? constraintEnforcer = null)
{
return new MxAccessGatewayService(
sessionManager,
identityAccessor ?? new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
@@ -0,0 +1,247 @@
using System.Runtime.CompilerServices;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
/// Server-015 and Server-016 audited the split lock discipline between
/// <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
/// and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
/// </summary>
public sealed class GatewaySessionTests
{
/// <summary>
/// Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
/// <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
/// must not flip the session back to <see cref="SessionState.Ready"/>. The
/// blocking worker shutdown keeps <c>CloseAsync</c> parked between the
/// <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
/// window the audit identified.
/// </summary>
[Fact]
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Close has set _state = Closing under _syncRoot and is parked inside
// worker.ShutdownAsync. A concurrent transition (e.g. a late
// SessionWorkerClientFactory lifecycle callback) must not revive the session.
Assert.Equal(SessionState.Closing, session.State);
session.TransitionTo(SessionState.Ready);
Assert.Equal(SessionState.Closing, session.State);
workerClient.ReleaseShutdown();
SessionCloseResult result = await closeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
/// <summary>
/// Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
/// <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
/// session out of <see cref="SessionState.Closed"/> either — the close path's
/// terminal write goes through the same <c>_syncRoot</c> the rest of the state
/// machine uses, so the existing "Closed is terminal" invariant holds.
/// </summary>
[Fact]
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
Assert.Equal(SessionState.Closed, session.State);
session.MarkFaulted("late-fault");
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
/// <summary>
/// Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
/// for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
/// its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
/// </summary>
[Fact]
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Start disposing while close is still parked inside worker.ShutdownAsync.
ValueTask disposeTask = session.DisposeAsync();
// Now release the worker shutdown so close can complete.
workerClient.ReleaseShutdown();
// Both must complete cleanly — the close's Release() must run before the
// dispose actually tears the semaphore down.
SessionCloseResult result = await closeTask;
await disposeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(1, workerClient.ShutdownCount);
// Worker dispose ran exactly once even with the close/dispose interleave.
Assert.Equal(1, workerClient.DisposeCount);
}
/// <summary>
/// Double-dispose is tolerated: the second call must swallow
/// <see cref="ObjectDisposedException"/> from the already-disposed semaphore
/// rather than propagating it.
/// </summary>
[Fact]
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
await session.DisposeAsync();
// No second exception — the dispose's defensive ObjectDisposedException catch
// covers the doubled call path that SessionManager.ShutdownAsync could trigger
// if it re-removed a session.
await session.DisposeAsync();
}
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
{
GatewaySession session = new(
sessionId: "session-test",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-test",
nonce: "nonce",
clientIdentity: "client-1",
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow);
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
/// <summary>
/// Minimal worker client that parks <see cref="ShutdownAsync"/> until the test
/// explicitly releases it. Used to keep <see cref="GatewaySession.CloseAsync"/>
/// stuck between its <c>Closing</c> and <c>Closed</c> writes so the test can
/// observe and act on the intermediate state.
/// </summary>
private sealed class BlockingShutdownWorkerClient : IWorkerClient
{
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
public string SessionId { get; } = "session-test";
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public int ShutdownCount { get; private set; }
public int DisposeCount { get; private set; }
public Task WaitForShutdownStartAsync()
{
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void ReleaseShutdown()
{
_shutdownReleased.TrySetResult();
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ShutdownCount++;
_shutdownStarted.TrySetResult();
await _shutdownReleased.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
State = WorkerClientState.Closed;
}
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
public string SessionId { get; } = "session-test";
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public int DisposeCount { get; private set; }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
public void Kill(string reason)
{
}
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,711 @@
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Tests-013: per-method gateway-side coverage for every
/// <c>GatewaySession.*BulkAsync</c> entry point. Each method gets a
/// round-trip test that pins the <see cref="MxCommandKind"/> sent to the
/// worker, the per-entry payload shape, a failure-mode (per-entry failure
/// surfaced or protocol-status failure) check, and a cancellation-propagation
/// check. The secured-write variants additionally pin that the credential
/// payload (<c>current_user_id</c>, <c>verifier_user_id</c>) is preserved
/// end-to-end and not flattened/redacted by the gateway's command shape.
/// </summary>
public sealed class SessionManagerBulkTests
{
[Fact]
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.AddItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Ok", ItemHandle = 511, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "invalid tag" },
},
}, MxCommandKind.AddItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.AddItemBulkAsync(
12,
["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"],
CancellationToken.None);
Assert.Equal(1, workerClient.InvokeCount);
Assert.Equal(MxCommandKind.AddItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.AddItemBulk.ServerHandle);
Assert.Equal(["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"], workerClient.LastCommand?.Command.AddItemBulk.TagAddresses);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
Assert.Equal("invalid tag", results[1].ErrorMessage);
}
[Fact]
public async Task AddItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token));
}
[Fact]
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.AdviseItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "invalid item handle" },
},
}, MxCommandKind.AdviseItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.AdviseItemBulkAsync(
12,
[901, 902],
CancellationToken.None);
Assert.Equal(MxCommandKind.AdviseItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.AdviseItemBulk.ServerHandle);
Assert.Equal([901, 902], workerClient.LastCommand?.Command.AdviseItemBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task AdviseItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.AdviseItemBulkAsync(12, [101], cts.Token));
}
[Fact]
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.RemoveItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 11, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 12, WasSuccessful = false, ErrorMessage = "unknown handle" },
},
}, MxCommandKind.RemoveItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.RemoveItemBulkAsync(
12,
[11, 12],
CancellationToken.None);
Assert.Equal(MxCommandKind.RemoveItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal([11, 12], workerClient.LastCommand?.Command.RemoveItemBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task RemoveItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.RemoveItemBulkAsync(12, [11], cts.Token));
}
[Fact]
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnAdviseItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 21, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 22, WasSuccessful = false, ErrorMessage = "not advised" },
},
}, MxCommandKind.UnAdviseItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.UnAdviseItemBulkAsync(
12,
[21, 22],
CancellationToken.None);
Assert.Equal(MxCommandKind.UnAdviseItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal([21, 22], workerClient.LastCommand?.Command.UnAdviseItemBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.False(results[1].WasSuccessful);
Assert.Equal("not advised", results[1].ErrorMessage);
}
[Fact]
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token));
}
[Fact]
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
{
// SubscribeBulkAsync already has a happy-path test in SessionManagerTests
// (GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults);
// this complementary test pins the per-entry failure-surface behaviour.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Good", ItemHandle = 501, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "MXAccess subscribe failed" },
},
}, MxCommandKind.SubscribeBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.SubscribeBulkAsync(
12,
["Galaxy.Good", "Galaxy.Bad"],
CancellationToken.None);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage);
}
[Fact]
public async Task SubscribeBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token));
}
[Fact]
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnsubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 31, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 32, WasSuccessful = false, ErrorMessage = "unknown handle" },
},
}, MxCommandKind.UnsubscribeBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.UnsubscribeBulkAsync(
12,
[31, 32],
CancellationToken.None);
Assert.Equal(MxCommandKind.UnsubscribeBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal([31, 32], workerClient.LastCommand?.Command.UnsubscribeBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token));
}
[Fact]
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
{
// Complement the existing happy-path WriteBulk test in SessionManagerTests
// with an explicit per-entry failure assertion plus payload-shape pinning.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "MXAccess invalid handle" },
},
}, MxCommandKind.WriteBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
12,
new[]
{
new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } },
new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } },
},
CancellationToken.None);
Assert.Equal(MxCommandKind.WriteBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count);
Assert.Equal(901, workerClient.LastCommand?.Command.WriteBulk.Entries[0].ItemHandle);
Assert.Equal(11, workerClient.LastCommand?.Command.WriteBulk.Entries[0].Value.Int32Value);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage);
}
[Fact]
public async Task WriteBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.WriteBulkAsync(
12,
new[] { new WriteBulkEntry { ItemHandle = 1, UserId = 1, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 } } },
cts.Token));
}
[Fact]
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.Write2Bulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 701, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 702, WasSuccessful = false, ErrorMessage = "MXAccess Write2 failed" },
},
}, MxCommandKind.Write2Bulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.Write2BulkAsync(
12,
new[]
{
new Write2BulkEntry
{
ItemHandle = 701,
UserId = 5,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567890L },
},
new Write2BulkEntry
{
ItemHandle = 702,
UserId = 5,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567891L },
},
},
CancellationToken.None);
Assert.Equal(MxCommandKind.Write2Bulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.Write2Bulk.ServerHandle);
Assert.Equal(2, workerClient.LastCommand?.Command.Write2Bulk.Entries.Count);
Assert.Equal(701, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].ItemHandle);
Assert.Equal(1234567890L, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].TimestampValue.Int64Value);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task Write2BulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.Write2BulkAsync(
12,
new[]
{
new Write2BulkEntry
{
ItemHandle = 1,
UserId = 1,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L },
},
},
cts.Token));
}
[Fact]
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
{
// The secured variants carry caller credential identifiers (CurrentUserId /
// VerifierUserId). Pin that those survive the gateway round-trip end-to-end —
// the over-the-wire command shape must NOT redact or flatten them, only the
// *log surface* (see GatewaySession's redaction rules) is allowed to drop them.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecuredBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 601, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 602, WasSuccessful = false, ErrorMessage = "MXAccess secured-write rejected" },
},
}, MxCommandKind.WriteSecuredBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.WriteSecuredBulkAsync(
12,
new[]
{
new WriteSecuredBulkEntry
{
ItemHandle = 601,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 },
},
new WriteSecuredBulkEntry
{
ItemHandle = 602,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 },
},
},
CancellationToken.None);
Assert.Equal(MxCommandKind.WriteSecuredBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.WriteSecuredBulk.ServerHandle);
Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecuredBulk.Entries.Count);
WriteSecuredBulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecuredBulk.Entries[0];
Assert.Equal(601, firstEntry.ItemHandle);
Assert.Equal(7, firstEntry.CurrentUserId);
Assert.Equal(8, firstEntry.VerifierUserId);
Assert.Equal(1, firstEntry.Value.Int32Value);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage);
}
[Fact]
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.WriteSecuredBulkAsync(
12,
new[]
{
new WriteSecuredBulkEntry
{
ItemHandle = 1,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
},
},
cts.Token));
}
[Fact]
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecured2Bulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 801, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 802, WasSuccessful = false, ErrorMessage = "MXAccess secured2-write rejected" },
},
}, MxCommandKind.WriteSecured2Bulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.WriteSecured2BulkAsync(
12,
new[]
{
new WriteSecured2BulkEntry
{
ItemHandle = 801,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000000L },
},
new WriteSecured2BulkEntry
{
ItemHandle = 802,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000001L },
},
},
CancellationToken.None);
Assert.Equal(MxCommandKind.WriteSecured2Bulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecured2Bulk.Entries.Count);
WriteSecured2BulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecured2Bulk.Entries[0];
Assert.Equal(801, firstEntry.ItemHandle);
Assert.Equal(7, firstEntry.CurrentUserId);
Assert.Equal(8, firstEntry.VerifierUserId);
Assert.Equal(1, firstEntry.Value.Int32Value);
Assert.Equal(1700000000L, firstEntry.TimestampValue.Int64Value);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.WriteSecured2BulkAsync(
12,
new[]
{
new WriteSecured2BulkEntry
{
ItemHandle = 1,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L },
},
},
cts.Token));
}
[Fact]
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
{
// Complement the existing happy-path ReadBulk test in SessionManagerTests
// with the failure-mode case where one tag failed to read.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 12,
TagAddress = "Galaxy.Good",
ItemHandle = 511,
WasSuccessful = true,
WasCached = false,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 42 },
},
new BulkReadResult
{
ServerHandle = 12,
TagAddress = "Galaxy.Bad",
ItemHandle = 0,
WasSuccessful = false,
ErrorMessage = "MXAccess read timed out",
},
},
}, MxCommandKind.ReadBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
12,
["Galaxy.Good", "Galaxy.Bad"],
TimeSpan.FromMilliseconds(750),
CancellationToken.None);
Assert.Equal(MxCommandKind.ReadBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(750u, workerClient.LastCommand?.Command.ReadBulk.TimeoutMs);
Assert.Equal(["Galaxy.Good", "Galaxy.Bad"], workerClient.LastCommand?.Command.ReadBulk.TagAddresses);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess read timed out", results[1].ErrorMessage);
}
[Fact]
public async Task ReadBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.ReadBulkAsync(
12,
["Galaxy.Tag"],
TimeSpan.FromMilliseconds(500),
cts.Token));
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
private static FakeBulkWorkerClient WithReply(Action<MxCommandReply> populate, MxCommandKind kind)
{
MxCommandReply reply = new()
{
SessionId = "session-1",
CorrelationId = "correlation-1",
Kind = kind,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
populate(reply);
return new FakeBulkWorkerClient
{
InvokeReply = new WorkerCommandReply { Reply = reply },
};
}
private static async Task<GatewaySession> OpenSessionAsync(FakeBulkWorkerClient workerClient)
{
SessionManager manager = CreateManager(workerClient);
return await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
}
private static SessionManager CreateManager(FakeBulkWorkerClient workerClient)
{
return new SessionManager(
new SessionRegistry(),
new FakeBulkSessionWorkerClientFactory(workerClient),
Options.Create(new GatewayOptions
{
Sessions = new SessionOptions
{
DefaultCommandTimeoutSeconds = 30,
MaxSessions = 16,
DefaultLeaseSeconds = 1800,
},
Worker = new WorkerOptions
{
StartupTimeoutSeconds = 30,
ShutdownTimeoutSeconds = 10,
},
}),
new GatewayMetrics());
}
private static SessionOpenRequest CreateOpenRequest()
{
return new SessionOpenRequest(
RequestedBackend: null,
ClientSessionName: "test-session",
ClientCorrelationId: "client-correlation-1",
CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5)));
}
private sealed class FakeBulkSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
{
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
return Task.FromResult(workerClient);
}
}
private sealed class FakeBulkWorkerClient : IWorkerClient
{
/// <inheritdoc />
public string SessionId { get; init; } = "session-1";
/// <inheritdoc />
public int? ProcessId { get; init; } = 1234;
/// <inheritdoc />
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Gets the number of times Invoke was called on the fake worker client.</summary>
public int InvokeCount { get; private set; }
/// <summary>Gets the last command invoked on the fake worker client.</summary>
public WorkerCommand? LastCommand { get; private set; }
/// <summary>Gets the reply to return for invoke calls on the fake worker client.</summary>
public WorkerCommandReply? InvokeReply { get; init; }
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
InvokeCount++;
LastCommand = command;
if (InvokeReply is not null)
{
return Task.FromResult(InvokeReply);
}
MxCommandKind kind = command.Command?.Kind ?? MxCommandKind.Unspecified;
return Task.FromResult(new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
CorrelationId = "correlation-1",
Kind = kind,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
},
});
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
State = WorkerClientState.Closed;
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason) => State = WorkerClientState.Faulted;
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -37,7 +37,7 @@ public sealed class SessionManagerTests
[Fact]
public async Task OpenSessionAsync_SetsInitialDefaultLease()
{
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z"));
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
GatewayOptions options = CreateOptions(defaultLeaseSeconds: 1800);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(new FakeWorkerClient()),
@@ -267,21 +267,45 @@ public sealed class WorkerAlarmRpcDispatcherTests
Assert.Equal("Galaxy!A.T2", collected[1].AlarmFullReference);
}
/// <summary>
/// Server-019 regression: <c>QueryActiveAlarmsAsync</c> used to silently
/// <c>yield break</c> when the session id was not in the registry, while the
/// peer <c>AcknowledgeAsync</c> returned <c>SessionNotFound</c>. Both methods
/// now signal a missing session — <c>QueryActiveAlarms</c> throws a
/// <see cref="SessionManagerException"/> with
/// <see cref="SessionManagerErrorCode.SessionNotFound"/> (the gateway gRPC
/// layer maps it to gRPC <c>NotFound</c>), aligning the dispatcher's
/// missing-session contract across the two RPCs.
/// </summary>
[Fact]
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_YieldsEmpty()
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_ThrowsSessionNotFound()
{
SessionRegistry registry = new();
WorkerAlarmRpcDispatcher dispatcher = new(registry);
List<ActiveAlarmSnapshot> collected = new();
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "missing" },
CancellationToken.None))
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(async () =>
{
collected.Add(snap);
}
await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "missing" },
CancellationToken.None))
{
// No yield expected — the throw happens before the first iteration.
}
});
Assert.Empty(collected);
Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode);
// Peer-method parity: AcknowledgeAsync still signals SessionNotFound (as an
// in-band ProtocolStatus, since it's a unary RPC). The two methods now agree
// that a missing session is an error, not an empty success.
AcknowledgeAlarmReply ackReply = await dispatcher.AcknowledgeAsync(
new AcknowledgeAlarmRequest
{
SessionId = "missing",
AlarmFullReference = Guid.NewGuid().ToString(),
},
CancellationToken.None);
Assert.Equal(ProtocolStatusCode.SessionNotFound, ackReply.ProtocolStatus.Code);
}
[Fact]
@@ -341,10 +341,17 @@ public sealed class WorkerClientTests
Assert.Equal(previousHeartbeat + TimeSpan.FromSeconds(1), client.LastHeartbeatAt);
}
/// <summary>Verifies that the heartbeat monitor faults the client when the heartbeat expires.</summary>
/// <summary>
/// Verifies that the heartbeat monitor faults the client when the heartbeat expires.
/// Uses an injected <see cref="ManualTimeProvider"/> so the grace comparison is deterministic
/// instead of depending on real wall-clock advance; the monitor's
/// <see cref="WorkerClientOptions.HeartbeatCheckInterval"/> timer stays on the real clock and
/// observes the manually-advanced grace on its next tick.
/// </summary>
[Fact]
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
{
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
await using PipePair pipePair = await PipePair.CreateAsync();
await using WorkerClient client = CreateClient(
pipePair,
@@ -353,9 +360,12 @@ public sealed class WorkerClientTests
HeartbeatGrace = TimeSpan.FromMilliseconds(80),
HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20),
EventChannelCapacity = 8,
});
},
timeProvider: clock);
await CompleteHandshakeAsync(client, pipePair);
clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(
() => client.State == WorkerClientState.Faulted,
TestTimeout);
@@ -252,6 +252,94 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Equal(0, sessionManager.InvokeCount);
}
/// <summary>
/// Verifies the interceptor denies <c>AcknowledgeAlarm</c> calls that lack
/// <see cref="GatewayScopes.InvokeWrite"/>. Ack is a write-shaped mutation against
/// alarm state, so it carries the same scope as <c>MxCommandKind.Write</c>.
/// </summary>
[Fact]
public async Task UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => interceptor.UnaryServerHandler(
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(_, _) => Task.FromResult(new AcknowledgeAlarmReply())));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that an API key holding <c>invoke:write</c> may call <c>AcknowledgeAlarm</c>.</summary>
[Fact]
public async Task UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeWrite)),
new GatewayRequestIdentityAccessor());
bool handlerRan = false;
AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler(
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(_, _) =>
{
handlerRan = true;
return Task.FromResult(new AcknowledgeAlarmReply());
});
Assert.NotNull(reply);
Assert.True(handlerRan);
}
/// <summary>
/// Verifies the interceptor denies <c>QueryActiveAlarms</c> server-streaming calls that
/// lack <see cref="GatewayScopes.EventsRead"/>. Active-alarm snapshots are part of the
/// alarm/event surface and share the same scope as <c>StreamEvents</c>.
/// </summary>
[Fact]
public async Task ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => interceptor.ServerStreamingServerHandler(
new QueryActiveAlarmsRequest { SessionId = "session-1" },
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(_, _, _) => Task.CompletedTask));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that an API key holding <c>events:read</c> may call <c>QueryActiveAlarms</c>.</summary>
[Fact]
public async Task ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
new GatewayRequestIdentityAccessor());
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
await interceptor.ServerStreamingServerHandler(
new QueryActiveAlarmsRequest { SessionId = "session-1" },
streamWriter,
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
async (_, writer, _) =>
{
await writer.WriteAsync(new ActiveAlarmSnapshot());
});
Assert.Single(streamWriter.Messages);
}
private static MxAccessGatewayService CreateService(
ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor)
@@ -13,6 +13,8 @@ public sealed class GatewayGrpcScopeResolverTests
[InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)]
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
[InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)]
[InlineData(typeof(AcknowledgeAlarmRequest), GatewayScopes.InvokeWrite)]
[InlineData(typeof(QueryActiveAlarmsRequest), GatewayScopes.EventsRead)]
[InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
@@ -0,0 +1,89 @@
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
namespace MxGateway.Tests.TestSupport;
/// <summary>
/// <see cref="IConstraintEnforcer"/> for tests that exercise the constraint
/// filtering and reply-merging code paths in
/// <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and the
/// <c>BulkConstraintPlan</c> family. Callers supply predicates that decide
/// whether a given tag address or (server, item) handle is denied; recorded
/// denials are exposed for assertions.
/// </summary>
public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
{
/// <summary>Deny predicate keyed on tag address (returns true to deny).</summary>
public Func<string, bool> DenyTag { get; init; } = _ => false;
/// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary>
public Func<int, int, bool> DenyReadHandle { get; init; } = (_, _) => false;
/// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary>
public Func<int, int, bool> DenyWriteHandle { get; init; } = (_, _) => false;
/// <summary>Recorded denial messages — (commandKind, target) tuples.</summary>
public List<(string CommandKind, string Target)> RecordedDenials { get; } = [];
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken)
{
if (DenyTag(tagAddress))
{
return Task.FromResult<ConstraintFailure?>(
new ConstraintFailure("read-tag", $"Read denied for tag '{tagAddress}'."));
}
return Task.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken)
{
if (DenyReadHandle(serverHandle, itemHandle))
{
return Task.FromResult<ConstraintFailure?>(
new ConstraintFailure("read-handle", $"Read denied for handle {itemHandle}."));
}
return Task.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken)
{
if (DenyWriteHandle(serverHandle, itemHandle))
{
return Task.FromResult<ConstraintFailure?>(
new ConstraintFailure("write-handle", $"Write denied for handle {itemHandle}."));
}
return Task.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
public Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
string target,
ConstraintFailure failure,
CancellationToken cancellationToken)
{
RecordedDenials.Add((commandKind, target));
return Task.CompletedTask;
}
}