Add XML documentation across gateway, worker, and .NET client

This commit is contained in:
Joseph Doherty
2026-04-30 11:49:58 -04:00
parent 4731ab535c
commit eed1e88a37
269 changed files with 4555 additions and 13 deletions
@@ -7,6 +7,7 @@ namespace MxGateway.Tests.Configuration;
public sealed class GatewayOptionsTests
{
/// <summary>Verifies that options binding uses design defaults when no configuration is provided.</summary>
[Fact]
public void OptionsBinding_UsesDesignDefaults()
{
@@ -47,6 +48,7 @@ public sealed class GatewayOptionsTests
Assert.Equal(1u, options.Protocol.WorkerProtocolVersion);
}
/// <summary>Verifies that options binding applies configuration overrides.</summary>
[Fact]
public void OptionsBinding_AppliesConfigurationOverrides()
{
@@ -67,6 +69,10 @@ public sealed class GatewayOptionsTests
Assert.False(options.Dashboard.Enabled);
}
/// <summary>Verifies that invalid configuration values fail with expected error messages.</summary>
/// <param name="key">Configuration key being validated.</param>
/// <param name="value">Configuration value being tested.</param>
/// <param name="expectedFailure">Expected validation error message.</param>
[Theory]
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
@@ -82,6 +88,7 @@ public sealed class GatewayOptionsTests
Assert.Contains(exception.Failures, failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
}
/// <summary>Verifies that pepper secret names are redacted in the effective configuration.</summary>
[Fact]
public void EffectiveConfiguration_RedactsPepperSecretName()
{
@@ -5,10 +5,16 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Tests.Contracts;
/// <summary>
/// Tests for behavior fixture manifests and contract conformance.
/// </summary>
public sealed class ClientBehaviorFixtureTests
{
private static readonly JsonParser ProtobufJsonParser = new(JsonParser.Settings.Default);
/// <summary>
/// Verifies the behavior manifest declares correct protocol versions and fixture references.
/// </summary>
[Fact]
public void BehaviorManifest_DeclaresCurrentProtocolVersionsAndExistingFixtures()
{
@@ -39,6 +45,9 @@ public sealed class ClientBehaviorFixtureTests
}
}
/// <summary>
/// Verifies proto inputs manifest references the expected behavior fixture root.
/// </summary>
[Fact]
public void ProtoInputManifest_ReferencesBehaviorFixtureRoot()
{
@@ -52,6 +61,9 @@ public sealed class ClientBehaviorFixtureTests
Assert.True(Directory.Exists(Path.Combine(repositoryRoot.FullName, fixtureRoot)));
}
/// <summary>
/// Verifies command reply fixtures parse and preserve MXAccess details.
/// </summary>
[Fact]
public void CommandReplyFixtures_ParseWithCurrentContractAndPreserveMxAccessDetails()
{
@@ -84,6 +96,9 @@ public sealed class ClientBehaviorFixtureTests
Assert.All(failedWrite.Statuses, status => Assert.Equal(0, status.Success));
}
/// <summary>
/// Verifies event stream fixtures have monotonic sequences and expected event families.
/// </summary>
[Fact]
public void EventStreamFixtures_ParseWithMonotonicSequencesAndExpectedFamilies()
{
@@ -116,6 +131,9 @@ public sealed class ClientBehaviorFixtureTests
}
}
/// <summary>
/// Verifies value conversion fixtures parse typed values and raw fallbacks.
/// </summary>
[Fact]
public void ValueConversionFixtures_ParseTypedValuesAndRawFallbacks()
{
@@ -149,6 +167,9 @@ public sealed class ClientBehaviorFixtureTests
Assert.True(sawTypedArray, "Expected at least one typed array case.");
}
/// <summary>
/// Verifies status conversion fixtures parse status arrays and raw fields.
/// </summary>
[Fact]
public void StatusConversionFixtures_ParseStatusArraysAndRawFields()
{
@@ -172,6 +193,9 @@ public sealed class ClientBehaviorFixtureTests
Assert.True(sawRawUnknown, "Expected a status case with unknown raw native fields.");
}
/// <summary>
/// Verifies auth error fixtures map authentication/authorization and redact credentials.
/// </summary>
[Fact]
public void AuthErrorFixtures_MapAuthenticationAuthorizationAndRedactCredentials()
{
@@ -203,6 +227,7 @@ public sealed class ClientBehaviorFixtureTests
Assert.Contains("PERMISSION_DENIED", statusCodes);
}
/// <summary>Verifies timeout and cancellation test fixtures document client and worker behavior.</summary>
[Fact]
public void TimeoutCancelFixtures_DocumentClientWaitAndWorkerCommandBehavior()
{
@@ -7,6 +7,7 @@ namespace MxGateway.Tests.Contracts;
public sealed class ClientProtoInputTests
{
/// <summary>Verifies that the proto inputs manifest declares current protocol versions and existing source files.</summary>
[Fact]
public void Manifest_DeclaresCurrentProtocolVersionsAndExistingInputs()
{
@@ -34,6 +35,7 @@ public sealed class ClientProtoInputTests
}
}
/// <summary>Verifies that the OpenSessionReply fixture parses with the current contract version.</summary>
[Fact]
public void OpenSessionReplyFixture_ParsesWithCurrentContract()
{
@@ -46,6 +48,7 @@ public sealed class ClientProtoInputTests
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that the RegisterCommand fixture parses with the current contract version.</summary>
[Fact]
public void RegisterCommandRequestFixture_ParsesWithCurrentContract()
{
@@ -57,6 +60,7 @@ public sealed class ClientProtoInputTests
Assert.Equal("fixture-client", request.Command.Register.ClientName);
}
/// <summary>Verifies that the OnDataChange event fixture parses with the current contract version.</summary>
[Fact]
public void OnDataChangeEventFixture_ParsesWithCurrentContract()
{
@@ -4,6 +4,7 @@ namespace MxGateway.Tests.Contracts;
public sealed class CrossLanguageSmokeMatrixTests
{
/// <summary>Verifies that the smoke matrix declares the integration gate and JSON comparison shape.</summary>
[Fact]
public void Matrix_DeclaresIntegrationGateAndComparisonShape()
{
@@ -44,6 +45,7 @@ public sealed class CrossLanguageSmokeMatrixTests
AssertForbiddenLiterals(authContext.GetProperty("forbiddenLiterals"));
}
/// <summary>Verifies that the smoke matrix covers every supported client with equivalent smoke steps.</summary>
[Fact]
public void Matrix_CoversEverySupportedClientWithEquivalentSmokeSteps()
{
@@ -70,6 +72,7 @@ public sealed class CrossLanguageSmokeMatrixTests
Assert.Equal(ExpectedLanguages.OrderBy(language => language, StringComparer.Ordinal), clientsByLanguage.Keys.OrderBy(language => language, StringComparer.Ordinal));
}
/// <summary>Verifies that the smoke matrix keeps live smoke opt-in and secrets out of commands.</summary>
[Fact]
public void Matrix_KeepsLiveSmokeOptInAndSecretsOutOfCommands()
{
@@ -4,18 +4,21 @@ namespace MxGateway.Tests.Contracts;
public sealed class GatewayContractInfoTests
{
/// <summary>Verifies that the default backend name is "mxaccess-worker".</summary>
[Fact]
public void DefaultBackendName_IsMxAccessWorker()
{
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
}
/// <summary>Verifies that the gateway protocol version starts at version one.</summary>
[Fact]
public void GatewayProtocolVersion_StartsAtVersionOne()
{
Assert.Equal(1u, GatewayContractInfo.GatewayProtocolVersion);
}
/// <summary>Verifies that the worker protocol version starts at version one.</summary>
[Fact]
public void WorkerProtocolVersion_StartsAtVersionOne()
{
@@ -5,6 +5,7 @@ namespace MxGateway.Tests.Contracts;
public sealed class ParityFixtureMatrixTests
{
/// <summary>Verifies that the parity matrix declares current protocol versions and comparison fields.</summary>
[Fact]
public void Matrix_DeclaresCurrentProtocolVersionsAndComparisonFields()
{
@@ -52,6 +53,7 @@ public sealed class ParityFixtureMatrixTests
"rawFallbackMetadata");
}
/// <summary>Verifies that the parity matrix covers every public MXAccess method.</summary>
[Fact]
public void Matrix_CoversEveryPublicMxAccessMethod()
{
@@ -89,6 +91,7 @@ public sealed class ParityFixtureMatrixTests
}
}
/// <summary>Verifies that the parity matrix covers required scenario groups.</summary>
[Fact]
public void Matrix_CoversRequiredParityScenarioGroups()
{
@@ -124,6 +127,7 @@ public sealed class ParityFixtureMatrixTests
AssertScenarioCovers(groupsById["buffered_registration"], "method.add-buffered-item.context", "event.on-buffered-data-change.batch-gap");
}
/// <summary>Verifies that the parity matrix covers every public MXAccess event family.</summary>
[Fact]
public void Matrix_CoversEveryPublicMxAccessEventFamily()
{
@@ -7,6 +7,7 @@ namespace MxGateway.Tests.Contracts;
public sealed class ProtobufContractRoundTripTests
{
/// <summary>Verifies that gateway descriptor contains expected public service methods.</summary>
[Fact]
public void GatewayDescriptor_ContainsInitialPublicServiceMethods()
{
@@ -20,6 +21,7 @@ public sealed class ProtobufContractRoundTripTests
Assert.Contains(service.Methods, method => method.Name == "StreamEvents");
}
/// <summary>Verifies that worker envelope descriptor contains required correlation fields.</summary>
[Fact]
public void WorkerEnvelopeDescriptor_ContainsRequiredCorrelationFields()
{
@@ -31,6 +33,7 @@ public sealed class ProtobufContractRoundTripTests
Assert.Contains(fields, field => field.Name == "correlation_id");
}
/// <summary>Verifies that command request round-trips through serialization.</summary>
[Fact]
public void CommandRequest_RoundTripsMethodSpecificPayload()
{
@@ -54,6 +57,7 @@ public sealed class ProtobufContractRoundTripTests
Assert.Equal(MxCommand.PayloadOneofCase.Register, parsed.Command.PayloadCase);
}
/// <summary>Verifies that command reply round-trips with return values and statuses.</summary>
[Fact]
public void CommandReply_RoundTripsHResultReturnValueOutParamsAndStatuses()
{
@@ -94,6 +98,7 @@ public sealed class ProtobufContractRoundTripTests
Assert.Single(parsed.Statuses);
}
/// <summary>Verifies that event round-trips with value, status, and sequence.</summary>
[Fact]
public void Event_RoundTripsValueStatusSequenceAndBufferedBody()
{
@@ -161,6 +166,7 @@ public sealed class ProtobufContractRoundTripTests
Assert.Single(parsed.Statuses);
}
/// <summary>Verifies that worker envelope round-trips through serialization preserving protocol and command fields.</summary>
[Fact]
public void WorkerEnvelope_RoundTripsProtocolFieldsAndCommandBody()
{
@@ -4,6 +4,7 @@ namespace MxGateway.Tests.Diagnostics;
public sealed class GatewayLogRedactorTests
{
/// <summary>Verifies that RedactApiKey preserves the key ID and removes the secret.</summary>
[Fact]
public void RedactApiKey_PreservesKeyIdAndRemovesSecret()
{
@@ -13,6 +14,7 @@ public sealed class GatewayLogRedactorTests
Assert.DoesNotContain("super-secret", redacted);
}
/// <summary>Verifies that RedactApiKey removes secrets containing underscores.</summary>
[Fact]
public void RedactApiKey_RemovesSecretContainingUnderscores()
{
@@ -22,6 +24,8 @@ public sealed class GatewayLogRedactorTests
Assert.DoesNotContain("super_secret_value", redacted);
}
/// <summary>Verifies that IsCredentialBearingCommand identifies credential-bearing MXAccess commands.</summary>
/// <param name="commandMethod">Name of the MXAccess command method.</param>
[Theory]
[InlineData("AuthenticateUser")]
[InlineData("WriteSecured")]
@@ -31,6 +35,7 @@ public sealed class GatewayLogRedactorTests
Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod));
}
/// <summary>Verifies that RedactCommandValue does not log raw values by default.</summary>
[Fact]
public void RedactCommandValue_DoesNotLogRawValuesByDefault()
{
@@ -39,6 +44,7 @@ public sealed class GatewayLogRedactorTests
Assert.Equal("[redacted]", redacted);
}
/// <summary>Verifies that RedactCommandValue redacts secured writes even when value logging is enabled.</summary>
[Fact]
public void RedactCommandValue_RedactsSecuredWriteEvenWhenValueLoggingIsEnabled()
{
@@ -50,6 +56,7 @@ public sealed class GatewayLogRedactorTests
Assert.Equal("[redacted]", redacted);
}
/// <summary>Verifies that RedactCommandValue allows non-sensitive values only when value logging is enabled.</summary>
[Fact]
public void RedactCommandValue_AllowsNonSensitiveValueOnlyWhenValueLoggingIsEnabled()
{
@@ -61,6 +68,7 @@ public sealed class GatewayLogRedactorTests
Assert.Equal("diagnostic-value", redacted);
}
/// <summary>Verifies that LogScope redacts client identity before scope state is created.</summary>
[Fact]
public void LogScope_RedactsClientIdentityBeforeScopeStateIsCreated()
{
@@ -4,6 +4,7 @@ namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyDeployNotifierTests
{
/// <summary>Verifies that a subscriber blocks until a deploy event is published.</summary>
[Fact]
public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish()
{
@@ -32,6 +33,7 @@ public sealed class GalaxyDeployNotifierTests
await enumerator.DisposeAsync();
}
/// <summary>Verifies that a subscriber immediately receives a cached latest deploy event.</summary>
[Fact]
public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately()
{
@@ -50,6 +52,7 @@ public sealed class GalaxyDeployNotifierTests
await cts.CancelAsync();
}
/// <summary>Verifies that published events fan out to all active subscribers.</summary>
[Fact]
public async Task Publish_FansOutToAllSubscribers()
{
@@ -74,6 +77,7 @@ public sealed class GalaxyDeployNotifierTests
await cts.CancelAsync();
}
/// <summary>Verifies that the Latest property tracks the most recently published event.</summary>
[Fact]
public void Latest_TracksMostRecentPublish()
{
@@ -4,6 +4,9 @@ namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyHierarchyCacheTests
{
/// <summary>
/// Verifies cache returns empty entry before any refresh occurs.
/// </summary>
[Fact]
public void Current_BeforeAnyRefresh_ReturnsEmpty()
{
@@ -18,6 +21,9 @@ public sealed class GalaxyHierarchyCacheTests
Assert.Null(entry.Reply);
}
/// <summary>
/// Verifies cache marks unavailable and does not publish when SQL is unreachable.
/// </summary>
[Fact]
public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish()
{
@@ -33,6 +39,9 @@ public sealed class GalaxyHierarchyCacheTests
Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
}
/// <summary>
/// Verifies HasData returns true for healthy cache entries.
/// </summary>
[Fact]
public void HasData_OnHealthyEntry_IsTrue()
{
@@ -46,6 +55,9 @@ public sealed class GalaxyHierarchyCacheTests
Assert.True(entry.HasData);
}
/// <summary>
/// Verifies HasData returns false for unknown cache entries.
/// </summary>
[Fact]
public void HasData_OnUnknownEntry_IsFalse()
{
@@ -67,8 +79,13 @@ public sealed class GalaxyHierarchyCacheTests
{
private DateTimeOffset _now = start == default ? DateTimeOffset.UtcNow : start;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>
/// Advances the current time by the specified duration.
/// </summary>
/// <param name="duration">Time duration to advance.</param>
public void Advance(TimeSpan duration) => _now += duration;
}
}
@@ -6,6 +6,7 @@ namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyProtoMapperTests
{
/// <summary>Verifies that mapping a galaxy attribute row preserves all scalar fields.</summary>
[Fact]
public void MapAttribute_PreservesAllScalarFields()
{
@@ -40,6 +41,7 @@ public sealed class GalaxyProtoMapperTests
Assert.False(proto.IsAlarm);
}
/// <summary>Verifies that the array dimension present flag distinguishes null from zero.</summary>
[Fact]
public void MapAttribute_ArrayDimensionPresentFlag_DistinguishesNullFromZero()
{
@@ -53,6 +55,7 @@ public sealed class GalaxyProtoMapperTests
Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimension);
}
/// <summary>Verifies that null data type name becomes an empty string.</summary>
[Fact]
public void MapAttribute_NullDataTypeName_BecomesEmptyString()
{
@@ -63,6 +66,7 @@ public sealed class GalaxyProtoMapperTests
Assert.Equal(string.Empty, proto.DataTypeName);
}
/// <summary>Verifies that MapHierarchy groups attributes by GobjectId.</summary>
[Fact]
public void MapHierarchy_GroupsAttributesByGobjectId()
{
@@ -88,6 +92,7 @@ public sealed class GalaxyProtoMapperTests
Assert.Empty(result[2].Attributes);
}
/// <summary>Verifies that MapObject copies the template chain.</summary>
[Fact]
public void MapObject_CopiesTemplateChain()
{
@@ -7,8 +7,14 @@ using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Tests for dashboard authentication using API keys.
/// </summary>
public sealed class DashboardAuthenticatorTests
{
/// <summary>
/// Verifies an admin-scoped key produces a valid cookie principal.
/// </summary>
[Fact]
public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal()
{
@@ -29,6 +35,9 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader);
}
/// <summary>
/// Verifies a non-admin key fails authentication without exposing the API key.
/// </summary>
[Fact]
public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey()
{
@@ -44,6 +53,9 @@ public sealed class DashboardAuthenticatorTests
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that when admin scope is not required, any authenticated key is accepted.
/// </summary>
[Fact]
public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey()
{
@@ -59,6 +71,9 @@ public sealed class DashboardAuthenticatorTests
Assert.NotNull(result.Principal);
}
/// <summary>
/// Verifies an invalid key returns a generic failure message.
/// </summary>
[Fact]
public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure()
{
@@ -97,10 +112,17 @@ public sealed class DashboardAuthenticatorTests
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
}
/// <summary>
/// Test implementation that records the authorization header for verification.
/// </summary>
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
{
/// <summary>
/// The authorization header that was last verified.
/// </summary>
public string? LastAuthorizationHeader { get; private set; }
/// <inheritdoc />
public Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
@@ -11,6 +11,7 @@ namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthorizationHandlerTests
{
/// <summary>Verifies that unauthenticated remote requests fail authorization.</summary>
[Fact]
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
{
@@ -22,6 +23,7 @@ public sealed class DashboardAuthorizationHandlerTests
Assert.False(context.HasSucceeded);
}
/// <summary>Verifies that anonymous localhost access succeeds when allowed.</summary>
[Fact]
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
{
@@ -33,6 +35,7 @@ public sealed class DashboardAuthorizationHandlerTests
Assert.True(context.HasSucceeded);
}
/// <summary>Verifies that authenticated users without admin scope fail authorization.</summary>
[Fact]
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
{
@@ -44,6 +47,7 @@ public sealed class DashboardAuthorizationHandlerTests
Assert.False(context.HasSucceeded);
}
/// <summary>Verifies that authenticated users with admin scope succeed.</summary>
[Fact]
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
{
@@ -10,6 +10,7 @@ namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardCookieOptionsTests
{
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
[Fact]
public void Build_ConfiguresSecureDashboardCookie()
{
@@ -11,6 +11,9 @@ namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSnapshotServiceTests
{
/// <summary>
/// Verifies snapshot returns empty collections and healthy status when registry is empty.
/// </summary>
[Fact]
public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState()
{
@@ -27,6 +30,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.NotNull(snapshot.Configuration);
}
/// <summary>
/// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data.
/// </summary>
[Fact]
public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults()
{
@@ -84,6 +90,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("worker pipe disconnected", fault.Message);
}
/// <summary>
/// Verifies snapshot redacts sensitive values from client identity, session name, and fault messages.
/// </summary>
[Fact]
public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields()
{
@@ -110,6 +119,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName);
}
/// <summary>
/// Verifies snapshot generation does not mutate session or worker client state.
/// </summary>
[Fact]
public void GetSnapshot_DoesNotMutateSessionOrWorkerState()
{
@@ -136,6 +148,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(0, workerClient.KillCount);
}
/// <summary>
/// Verifies snapshot respects configured limits for recent sessions and faults.
/// </summary>
[Fact]
public void GetSnapshot_AppliesRecentSessionAndFaultLimits()
{
@@ -172,6 +187,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
}
/// <summary>
/// Verifies snapshot projects Galaxy hierarchy cache data including templates and categories.
/// </summary>
[Fact]
public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache()
{
@@ -217,6 +235,9 @@ public sealed class DashboardSnapshotServiceTests
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
}
/// <summary>
/// Verifies snapshot watcher cancels cleanly when subscriber cancels.
/// </summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -268,10 +289,23 @@ public sealed class DashboardSnapshotServiceTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <summary>
/// Gets the current Galaxy hierarchy cache entry.
/// </summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <summary>
/// Refreshes the cache asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Waits for the first cache load asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -301,26 +335,59 @@ public sealed class DashboardSnapshotServiceTests
int? processId,
WorkerClientState state) : IWorkerClient
{
/// <summary>
/// Gets the session identifier.
/// </summary>
public string SessionId { get; } = sessionId;
/// <summary>
/// Gets the process identifier.
/// </summary>
public int? ProcessId { get; } = processId;
/// <summary>
/// Gets the current worker client state.
/// </summary>
public WorkerClientState State { get; private set; } = state;
/// <summary>
/// Gets the timestamp of the last heartbeat.
/// </summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z");
/// <summary>
/// Gets the count of start invocations.
/// </summary>
public int StartCount { get; private set; }
/// <summary>
/// Gets the count of shutdown invocations.
/// </summary>
public int ShutdownCount { get; private set; }
/// <summary>
/// Gets the count of kill invocations.
/// </summary>
public int KillCount { get; private set; }
/// <summary>
/// Starts the worker client asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
StartCount++;
return Task.CompletedTask;
}
/// <summary>
/// Invokes a worker command asynchronously.
/// </summary>
/// <param name="command">The command to invoke.</param>
/// <param name="timeout">Command timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Command reply.</returns>
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -329,6 +396,11 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <summary>
/// Reads events from the worker asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -336,6 +408,12 @@ public sealed class DashboardSnapshotServiceTests
yield break;
}
/// <summary>
/// Shuts down the worker client asynchronously.
/// </summary>
/// <param name="timeout">Shutdown timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -345,12 +423,20 @@ public sealed class DashboardSnapshotServiceTests
return Task.CompletedTask;
}
/// <summary>
/// Terminates the worker client.
/// </summary>
/// <param name="reason">Reason for termination.</param>
public void Kill(string reason)
{
KillCount++;
State = WorkerClientState.Faulted;
}
/// <summary>
/// Releases resources used by this worker client.
/// </summary>
/// <returns>Completed value task.</returns>
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
@@ -9,6 +9,7 @@ namespace MxGateway.Tests.Gateway;
public sealed class GatewayApplicationTests
{
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
[Fact]
public void Build_MapsLiveHealthEndpoint()
{
@@ -23,6 +24,7 @@ public sealed class GatewayApplicationTests
Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName);
}
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
[Fact]
public void Build_RegistersGatewayMetrics()
{
@@ -33,6 +35,7 @@ public sealed class GatewayApplicationTests
Assert.NotNull(metrics);
}
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
[Fact]
public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
{
@@ -50,6 +53,7 @@ public sealed class GatewayApplicationTests
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
}
/// <summary>Verifies that Build does not map dashboard routes when the dashboard is disabled.</summary>
[Fact]
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{
@@ -64,6 +68,10 @@ public sealed class GatewayApplicationTests
StringComparison.Ordinal) == true);
}
/// <summary>Verifies that StartAsync fails when gateway configuration is invalid.</summary>
/// <param name="key">Configuration key to override.</param>
/// <param name="value">Invalid configuration value.</param>
/// <param name="expectedFailure">Expected validation error message.</param>
[Theory]
[InlineData(
"MxGateway:Worker:ExecutablePath",
@@ -21,6 +21,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private const int ServerHandle = 1001;
private const int ItemHandle = 2002;
/// <summary>
/// Verifies gateway session lifecycle with a scripted fake worker: open, command, event, close.
/// </summary>
[Fact]
public async Task GatewayService_WithFakeWorker_CompletesSessionCommandEventAndClosePath()
{
@@ -146,6 +149,10 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new();
/// <summary>
/// Initializes a new instance of <see cref="GatewayServiceFixture"/>.
/// </summary>
/// <param name="launcher">Worker process launcher for the fixture.</param>
public GatewayServiceFixture(IWorkerProcessLauncher launcher)
{
IOptions<GatewayOptions> options = Options.Create(CreateOptions());
@@ -178,8 +185,14 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
NullLogger<MxAccessGatewayService>.Instance);
}
/// <summary>
/// Gets the configured gateway service instance.
/// </summary>
public MxAccessGatewayService Service { get; }
/// <summary>
/// Disposes all active sessions and metrics.
/// </summary>
public async ValueTask DisposeAsync()
{
foreach (GatewaySession session in _registry.Snapshot())
@@ -220,12 +233,27 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
public const int ProcessId = 4680;
private readonly ConcurrentQueue<MxCommandKind> _commandKinds = new();
/// <summary>
/// Gets the fake worker process instance.
/// </summary>
public FakeWorkerProcess Process { get; } = new(ProcessId);
/// <summary>
/// Gets the collection of command kinds processed by the worker.
/// </summary>
public IReadOnlyCollection<MxCommandKind> CommandKinds => _commandKinds.ToArray();
/// <summary>
/// Gets the worker's asynchronous task.
/// </summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <summary>
/// Launches a new worker process and returns a handle to manage it.
/// </summary>
/// <param name="request">Worker process launch request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Worker process handle.</returns>
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -321,12 +349,26 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
/// <summary>
/// Gets the process identifier.
/// </summary>
public int Id { get; } = processId;
/// <summary>
/// Gets a value indicating whether the process has exited.
/// </summary>
public bool HasExited { get; private set; }
/// <summary>
/// Gets the exit code of the process.
/// </summary>
public int? ExitCode { get; private set; }
/// <summary>
/// Waits for the process to exit asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
HasExited = true;
@@ -334,15 +376,26 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
return ValueTask.CompletedTask;
}
/// <summary>
/// Terminates the process.
/// </summary>
/// <param name="entireProcessTree">Whether to kill the entire process tree.</param>
public void Kill(bool entireProcessTree)
{
MarkExited(-1);
}
/// <summary>
/// Releases resources used by this process.
/// </summary>
public void Dispose()
{
}
/// <summary>
/// Marks the process as exited with the specified exit code.
/// </summary>
/// <param name="exitCode">The process exit code.</param>
public void MarkExited(int exitCode)
{
HasExited = true;
@@ -356,6 +409,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<T> _messages = [];
/// <summary>
/// Gets the recorded messages written to this stream.
/// </summary>
public IReadOnlyList<T> Messages
{
get
@@ -367,8 +423,16 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
}
}
/// <summary>
/// Gets or sets options for writing messages to the stream.
/// </summary>
public WriteOptions? WriteOptions { get; set; }
/// <summary>
/// Writes a message to the stream asynchronously.
/// </summary>
/// <param name="message">The message to write.</param>
/// <returns>Completed task.</returns>
public Task WriteAsync(T message)
{
lock (_syncRoot)
@@ -380,6 +444,11 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
return Task.CompletedTask;
}
/// <summary>
/// Waits for the first message to be written within the specified timeout.
/// </summary>
/// <param name="timeout">Maximum time to wait for the first message.</param>
/// <returns>The first message written to this stream.</returns>
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
{
return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
@@ -394,43 +463,66 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private Status _status;
private WriteOptions? _writeOptions;
/// <inheritdoc />
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:5000";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => _requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => cancellationToken;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore => _responseTrailers;
/// <inheritdoc />
protected override Status StatusCore
{
get => _status;
set => _status = value;
}
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore
{
get => _writeOptions;
set => _writeOptions = value;
}
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
/// <inheritdoc />
protected override IDictionary<object, object> UserStateCore => _userState;
/// <summary>
/// Writes response headers asynchronously.
/// </summary>
/// <param name="responseHeaders">Headers to write.</param>
/// <returns>Completed task.</returns>
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{
return Task.CompletedTask;
}
/// <summary>
/// Creates a context propagation token with the specified options.
/// </summary>
/// <param name="options">Propagation options.</param>
/// <returns>Propagation token.</returns>
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
{
@@ -16,6 +16,7 @@ public sealed class EventStreamServiceTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that events from the worker stream maintain their original sequence order.</summary>
[Fact]
public async Task StreamEventsAsync_YieldsEventsInWorkerOrder()
{
@@ -36,6 +37,7 @@ public sealed class EventStreamServiceTests
Assert.Equal(1, metrics.GetSnapshot().StreamDisconnects);
}
/// <summary>Verifies that a second event subscriber is rejected when one is already active.</summary>
[Fact]
public async Task StreamEventsAsync_WhenSecondSubscriberStarts_RejectsClearly()
{
@@ -64,6 +66,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
}
/// <summary>Verifies that canceling an event stream detaches the subscriber cleanly.</summary>
[Fact]
public async Task StreamEventsAsync_WhenCanceled_DetachesSubscriber()
{
@@ -85,6 +88,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
}
/// <summary>Verifies that disposing an event stream with buffered events resets the queue depth metric.</summary>
[Fact]
public async Task StreamEventsAsync_WhenDisposedWithBufferedEvents_ResetsStreamQueueDepth()
{
@@ -111,6 +115,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0);
}
/// <summary>Verifies that queue depth metrics correctly track concurrent event streams across multiple sessions.</summary>
[Fact]
public async Task StreamEventsAsync_WithConcurrentStreams_TracksAggregateQueueDepth()
{
@@ -151,6 +156,7 @@ public sealed class EventStreamServiceTests
await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0);
}
/// <summary>Verifies that event queue overflow faults the session and reports the overflow metric.</summary>
[Fact]
public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow()
{
@@ -180,6 +186,7 @@ public sealed class EventStreamServiceTests
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
/// <summary>Verifies that the disconnect backpressure policy disconnects the subscriber without faulting the session.</summary>
[Fact]
public async Task StreamEventsAsync_WhenStreamQueueOverflowsWithDisconnectPolicy_LeavesSessionReady()
{
@@ -211,6 +218,7 @@ public sealed class EventStreamServiceTests
Assert.Equal(1, snapshot.StreamDisconnects);
}
/// <summary>Verifies that the event stream does not synthesize OperationComplete events from write completions.</summary>
[Fact]
public async Task StreamEventsAsync_DoesNotSynthesizeOperationComplete()
{
@@ -227,6 +235,7 @@ public sealed class EventStreamServiceTests
Assert.DoesNotContain(events, candidate => candidate.Family == MxEventFamily.OperationComplete);
}
/// <summary>Verifies that a terminal fault from the worker event stream propagates and faults the session.</summary>
[Fact]
public async Task StreamEventsAsync_WhenWorkerEventStreamFaults_PropagatesTerminalFault()
{
@@ -359,15 +368,19 @@ public sealed class EventStreamServiceTests
}
}
/// <summary>Fake session manager for testing event streams.</summary>
private sealed class FakeSessionManager : ISessionManager
{
private readonly IReadOnlyDictionary<string, GatewaySession> _sessions;
/// <summary>Initializes a new instance of the FakeSessionManager.</summary>
/// <param name="sessions">Sessions to manage.</param>
public FakeSessionManager(params GatewaySession[] sessions)
{
_sessions = sessions.ToDictionary(session => session.SessionId, StringComparer.Ordinal);
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -376,6 +389,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(_sessions.Values.First());
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
out GatewaySession gatewaySession)
@@ -383,6 +397,7 @@ public sealed class EventStreamServiceTests
return _sessions.TryGetValue(sessionId, out gatewaySession!);
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -391,6 +406,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -398,6 +414,7 @@ public sealed class EventStreamServiceTests
return _sessions[sessionId].ReadEventsAsync(cancellationToken);
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -405,6 +422,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -412,33 +430,44 @@ public sealed class EventStreamServiceTests
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
/// <summary>Fake worker client for testing event streams.</summary>
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the list of queued worker events.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Gets or sets whether to complete the event stream after configured events are yielded.</summary>
public bool CompleteAfterConfiguredEvents { get; set; }
/// <summary>Gets or sets an optional exception to throw as a terminal event stream fault.</summary>
public Exception? TerminalException { get; init; }
/// <inheritdoc />
public string SessionId { get; } = "session-events";
/// <inheritdoc />
public int? ProcessId { get; } = 4321;
/// <inheritdoc />
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -447,6 +476,7 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -469,6 +499,7 @@ public sealed class EventStreamServiceTests
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
/// <inheritdoc />
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -477,11 +508,13 @@ public sealed class EventStreamServiceTests
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
@@ -16,6 +16,7 @@ namespace MxGateway.Tests.Gateway.Grpc;
public sealed class MxAccessGatewayServiceTests
{
/// <summary>Verifies that OpenSession returns correct session details for a valid request.</summary>
[Fact]
public async Task OpenSession_WithValidRequest_ReturnsSessionDetails()
{
@@ -46,6 +47,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
}
/// <summary>Verifies that Invoke throws NotFound when the session does not exist.</summary>
[Fact]
public async Task Invoke_WhenSessionMissing_ThrowsNotFound()
{
@@ -66,6 +68,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched.</summary>
[Fact]
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
{
@@ -88,6 +91,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal(0, sessionManager.InvokeCount);
}
/// <summary>Verifies that Invoke returns HResult status and method payload from worker reply.</summary>
[Fact]
public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload()
{
@@ -142,6 +146,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("mxaccess diagnostic", reply.DiagnosticMessage);
}
/// <summary>Verifies that StreamEvents writes only events after the specified worker sequence.</summary>
[Fact]
public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents()
{
@@ -165,6 +170,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
}
/// <summary>Verifies that StreamEvents records send duration metrics when an event is written.</summary>
[Fact]
public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration()
{
@@ -209,6 +215,7 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal([MxEventFamily.OnDataChange.ToString()], families);
}
/// <summary>Verifies that CloseSession throws InvalidArgument when session ID is blank.</summary>
[Fact]
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
{
@@ -299,16 +306,22 @@ public sealed class MxAccessGatewayServiceTests
private sealed class FakeSessionManager : ISessionManager
{
/// <summary>The session to return from OpenSessionAsync.</summary>
public GatewaySession? OpenSessionResult { get; init; }
/// <summary>The last OpenSessionAsync request captured.</summary>
public SessionOpenRequest? LastOpenRequest { get; private set; }
/// <summary>The last client identity passed to OpenSessionAsync.</summary>
public string? LastClientIdentity { get; private set; }
/// <summary>The last session ID passed to ReadEventsAsync.</summary>
public string? LastReadEventsSessionId { get; private set; }
/// <summary>The last worker command passed to InvokeAsync.</summary>
public WorkerCommand? LastWorkerCommand { get; private set; }
/// <summary>The reply to return from InvokeAsync.</summary>
public WorkerCommandReply InvokeReply { get; init; } = new()
{
Reply = new MxCommandReply
@@ -319,17 +332,23 @@ public sealed class MxAccessGatewayServiceTests
},
};
/// <summary>The exception to throw from InvokeAsync.</summary>
public Exception? InvokeException { get; init; }
/// <summary>The number of times InvokeAsync was called.</summary>
public int InvokeCount { get; private set; }
/// <summary>The events to return from ReadEventsAsync.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Records the session ID passed to ReadEventsAsync.</summary>
/// <param name="sessionId">Identifier of the session.</param>
public void RecordReadEventsSessionId(string sessionId)
{
LastReadEventsSessionId = sessionId;
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -341,6 +360,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
out GatewaySession session)
@@ -349,6 +369,7 @@ public sealed class MxAccessGatewayServiceTests
return true;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -365,6 +386,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(InvokeReply);
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -378,6 +400,7 @@ public sealed class MxAccessGatewayServiceTests
}
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -385,6 +408,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -392,6 +416,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -400,6 +425,7 @@ public sealed class MxAccessGatewayServiceTests
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
{
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -421,19 +447,25 @@ public sealed class MxAccessGatewayServiceTests
private sealed class FakeWorkerClient(int processId) : IWorkerClient
{
/// <inheritdoc />
public string SessionId { get; } = "session-1";
/// <inheritdoc />
public int? ProcessId { get; } = processId;
/// <inheritdoc />
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -442,6 +474,7 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -449,6 +482,7 @@ public sealed class MxAccessGatewayServiceTests
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -456,10 +490,12 @@ public sealed class MxAccessGatewayServiceTests
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
@@ -468,10 +504,13 @@ public sealed class MxAccessGatewayServiceTests
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
{
/// <inheritdoc />
public List<T> Messages { get; } = [];
/// <inheritdoc />
public WriteOptions? WriteOptions { get; set; }
/// <inheritdoc />
public Task WriteAsync(T message)
{
Messages.Add(message);
@@ -488,43 +527,56 @@ public sealed class MxAccessGatewayServiceTests
private Status status;
private WriteOptions? writeOptions;
/// <inheritdoc />
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:5000";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => cancellationToken;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore => responseTrailers;
/// <inheritdoc />
protected override Status StatusCore
{
get => status;
set => status = value;
}
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore
{
get => writeOptions;
set => writeOptions = value;
}
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
/// <inheritdoc />
protected override IDictionary<object, object> UserStateCore => userState;
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{
return Task.CompletedTask;
}
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
{
@@ -5,6 +5,7 @@ namespace MxGateway.Tests.Gateway.Grpc;
public sealed class MxAccessGrpcMapperTests
{
/// <summary>Verifies that command mapping clones payloads to isolate them across process boundaries.</summary>
[Fact]
public void MapCommand_ClonesMethodSpecificPayloadForWorkerBoundary()
{
@@ -37,6 +38,7 @@ public sealed class MxAccessGrpcMapperTests
Assert.NotNull(workerCommand.EnqueueTimestamp);
}
/// <summary>Verifies that command reply mapping preserves HRESULT and status information.</summary>
[Fact]
public void MapCommandReply_PreservesHresultStatusesAndPayload()
{
@@ -66,6 +68,7 @@ public sealed class MxAccessGrpcMapperTests
Assert.Equal("denied", Assert.Single(publicReply.Statuses).DiagnosticText);
}
/// <summary>Verifies that a missing worker reply returns a protocol violation status.</summary>
[Fact]
public void MapCommandReply_WhenWorkerReplyMissing_ReturnsProtocolViolationReply()
{
@@ -10,6 +10,7 @@ namespace MxGateway.Tests.Gateway.Sessions;
public sealed class SessionManagerTests
{
/// <summary>Verifies that opening a session with a ready worker registers the session in ready state.</summary>
[Fact]
public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession()
{
@@ -32,6 +33,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, metrics.GetSnapshot().SessionsOpened);
}
/// <summary>Verifies that opening a session generates a correlation ID from the client name and session ID.</summary>
[Fact]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{
@@ -47,6 +49,7 @@ public sealed class SessionManagerTests
Assert.Equal($"rust-load-client-{session.SessionId}", session.ClientCorrelationId);
}
/// <summary>Verifies that opening a session without a client session name uses the client correlation prefix.</summary>
[Fact]
public async Task OpenSessionAsync_WhenClientSessionNameMissing_UsesClientCorrelationPrefix()
{
@@ -61,6 +64,7 @@ public sealed class SessionManagerTests
Assert.Equal($"client-{session.SessionId}", session.ClientCorrelationId);
}
/// <summary>Verifies that invoking a command on a ready session forwards the command to the worker.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker()
{
@@ -77,6 +81,7 @@ public sealed class SessionManagerTests
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
}
/// <summary>Verifies that bulk subscribe forwards the command and returns subscription results.</summary>
[Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -121,6 +126,7 @@ public sealed class SessionManagerTests
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses);
}
/// <summary>Verifies that invoking a command on a faulted session rejects the command.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
{
@@ -139,6 +145,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, workerClient.InvokeCount);
}
/// <summary>Verifies that closing a session removes it from the registry.</summary>
[Fact]
public async Task CloseSessionAsync_RemovesClosedSession()
{
@@ -159,6 +166,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Verifies that closing a session kills the worker when shutdown fails.</summary>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
{
@@ -179,6 +187,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, workerClient.KillCount);
}
/// <summary>Verifies that when worker shutdown fails, the session is removed and the slot is released.</summary>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_RemovesSessionAndReleasesSlot()
{
@@ -221,6 +230,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, snapshot.OpenSessions);
}
/// <summary>Verifies that when the second close is canceled, the session is not removed if owned by the first close.</summary>
[Fact]
public async Task CloseSessionAsync_WhenSecondCloseIsCanceled_DoesNotRemoveSessionOwnedByFirstClose()
{
@@ -268,6 +278,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Verifies that when worker creation fails, the session is removed from the registry.</summary>
[Fact]
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
{
@@ -287,6 +298,7 @@ public sealed class SessionManagerTests
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
/// <summary>Verifies that closing expired leases only closes expired sessions.</summary>
[Fact]
public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly()
{
@@ -309,6 +321,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, activeClient.ShutdownCount);
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
[Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
{
@@ -330,6 +343,12 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Creates a session manager for testing.</summary>
/// <param name="factory">Worker client factory.</param>
/// <param name="registry">Session registry; defaults to a new registry.</param>
/// <param name="metrics">Metrics collector; defaults to a new instance.</param>
/// <param name="options">Gateway options; defaults to test defaults.</param>
/// <returns>Configured session manager.</returns>
private static SessionManager CreateManager(
ISessionWorkerClientFactory factory,
ISessionRegistry? registry = null,
@@ -382,10 +401,13 @@ public sealed class SessionManagerTests
private sealed class FakeSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
{
/// <summary>Gets the list of observed session states during worker creation.</summary>
public List<string> ObservedStates { get; } = [];
/// <summary>Gets or sets a value indicating whether to apply lifecycle transitions during worker creation.</summary>
public bool ApplyLifecycleTransitions { get; init; }
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -409,11 +431,14 @@ public sealed class SessionManagerTests
{
private readonly Queue<IWorkerClient> _workerClients;
/// <summary>Initializes a new instance of the <see cref="QueueingSessionWorkerClientFactory"/> class.</summary>
/// <param name="workerClients">Array of worker clients to queue.</param>
public QueueingSessionWorkerClientFactory(params IWorkerClient[] workerClients)
{
_workerClients = new Queue<IWorkerClient>(workerClients);
}
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -424,6 +449,7 @@ public sealed class SessionManagerTests
private sealed class FailingSessionWorkerClientFactory : ISessionWorkerClientFactory
{
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -434,39 +460,53 @@ public sealed class SessionManagerTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the session ID for the fake worker client.</summary>
public string SessionId { get; init; } = "session-1";
/// <summary>Gets the process ID for the fake worker client.</summary>
public int? ProcessId { get; init; } = 1234;
/// <summary>Gets or sets the state of the fake worker client.</summary>
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
/// <summary>Gets the last heartbeat timestamp for the fake worker client.</summary>
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 number of times shutdown was called on the fake worker client.</summary>
public int ShutdownCount { get; private set; }
/// <summary>Gets the number of times kill was called on the fake worker client.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the number of times dispose was called on the fake worker client.</summary>
public int DisposeCount { get; private set; }
/// <summary>Gets the exception to throw when shutdown is called, if any.</summary>
public Exception? ShutdownException { get; init; }
/// <summary>Gets a value indicating whether to block shutdown on the fake worker client.</summary>
public bool BlockShutdown { get; init; }
/// <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; }
private TaskCompletionSource ShutdownStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource ShutdownReleased { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -492,6 +532,7 @@ public sealed class SessionManagerTests
});
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -499,6 +540,7 @@ public sealed class SessionManagerTests
yield break;
}
/// <inheritdoc />
public async Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -518,23 +560,27 @@ public sealed class SessionManagerTests
State = WorkerClientState.Closed;
}
/// <inheritdoc />
public void Kill(string reason)
{
KillCount++;
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
/// <summary>Waits for shutdown to start on the fake worker client.</summary>
public Task WaitForShutdownStartAsync()
{
return ShutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Releases the shutdown block on the fake worker client.</summary>
public void ReleaseShutdown()
{
ShutdownReleased.TrySetResult();
@@ -14,6 +14,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that the factory creates a ready worker client with a scripted fake worker process.</summary>
[Fact]
public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient()
{
@@ -46,6 +47,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
}
/// <summary>Verifies that a failed fake worker startup throws a worker client exception.</summary>
[Fact]
public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException()
{
@@ -65,6 +67,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
Assert.True(launcher.Process.IsDisposed);
}
/// <summary>Verifies that a worker that never sends ready times out and is killed.</summary>
[Fact]
public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker()
{
@@ -131,13 +134,17 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
};
}
/// <summary>Fake worker launcher that connects a scripted fake worker harness.</summary>
private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher
{
/// <summary>The fake process ID used by the scripted launcher.</summary>
public const int ProcessId = 2468;
private readonly FakeWorkerProcess _process = new(ProcessId);
/// <summary>Gets the connected fake worker harness.</summary>
public FakeWorkerHarness? Harness { get; private set; }
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -161,10 +168,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
}
}
/// <summary>Fake worker launcher that fails during startup with protocol version mismatch.</summary>
private sealed class FailingStartupWorkerProcessLauncher : IWorkerProcessLauncher
{
/// <summary>Gets the fake worker process.</summary>
public FakeWorkerProcess Process { get; } = new(processId: 3579);
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -192,10 +202,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
}
}
/// <summary>Fake worker launcher that never completes startup, simulating a hung worker.</summary>
private sealed class NeverReadyWorkerProcessLauncher : IWorkerProcessLauncher
{
/// <summary>Gets the fake worker process.</summary>
public FakeWorkerProcess Process { get; } = new(processId: 4680);
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -232,18 +245,24 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
DateTimeOffset.UtcNow);
}
/// <summary>Fake worker process for testing process lifecycle.</summary>
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
private bool _disposed;
/// <inheritdoc />
public int Id { get; } = processId;
/// <summary>Gets or sets a value indicating whether the process has exited.</summary>
public bool HasExited { get; private set; }
/// <summary>Gets or sets the process exit code.</summary>
public int? ExitCode { get; private set; }
/// <summary>Gets the number of times the Kill method was called.</summary>
public int KillCount { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
HasExited = true;
@@ -251,6 +270,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
KillCount++;
@@ -258,11 +278,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
ExitCode = -1;
}
/// <inheritdoc />
public void Dispose()
{
_disposed = true;
}
/// <summary>Gets a value indicating whether this process has been disposed.</summary>
public bool IsDisposed => _disposed;
}
}
@@ -9,6 +9,7 @@ public sealed class FakeWorkerHarnessTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that completing startup with hello and ready transitions the client to ready state.</summary>
[Fact]
public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady()
{
@@ -25,6 +26,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, client.ProcessId);
}
/// <summary>Verifies that a protocol version mismatch during startup fails the client.</summary>
[Fact]
public async Task StartAsync_WithProtocolMismatch_FailsStartup()
{
@@ -43,6 +45,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode);
}
/// <summary>Verifies that a scripted reply completes a pending command invocation.</summary>
[Fact]
public async Task InvokeAsync_WithScriptedReply_CompletesCommand()
{
@@ -64,6 +67,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
}
/// <summary>Verifies that scripted events are yielded in order through the event stream.</summary>
[Fact]
public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents()
{
@@ -87,6 +91,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
}
/// <summary>Verifies that a scripted fault from the worker faults the client.</summary>
[Fact]
public async Task ReadLoop_WithScriptedFault_FaultsClient()
{
@@ -105,6 +110,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that sending a heartbeat updates the client heartbeat state.</summary>
[Fact]
public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState()
{
@@ -124,6 +130,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Ready, client.State);
}
/// <summary>Verifies that a hung worker times out pending command invocations.</summary>
[Fact]
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
{
@@ -144,6 +151,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode);
}
/// <summary>Verifies that a malformed frame in the read loop faults the client.</summary>
[Fact]
public async Task ReadLoop_WithMalformedFrame_FaultsClient()
{
@@ -160,6 +168,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that a shutdown acknowledgment from the worker closes the client.</summary>
[Fact]
public async Task ShutdownAsync_WithShutdownAck_ClosesClient()
{
@@ -37,12 +37,21 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
_writer = new WorkerFrameWriter(_workerStream, frameOptions);
}
/// <summary>Gets the session ID for the fake worker harness.</summary>
public string SessionId { get; }
/// <summary>Gets the nonce for the fake worker harness.</summary>
public string Nonce { get; }
/// <summary>Gets or sets the next worker sequence number.</summary>
public ulong NextWorkerSequence { get; private set; }
/// <summary>Creates a connected pair of fake worker harness with gateway and worker pipes.</summary>
/// <param name="sessionId">Identifier for the fake session.</param>
/// <param name="nonce">Nonce for session validation.</param>
/// <param name="protocolVersion">Protocol version for frame communication.</param>
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public static async Task<FakeWorkerHarness> CreateConnectedPairAsync(
string sessionId = DefaultSessionId,
string nonce = DefaultNonce,
@@ -71,6 +80,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes));
}
/// <summary>Connects to an existing gateway pipe as a fake worker harness.</summary>
/// <param name="sessionId">Identifier for the fake session.</param>
/// <param name="nonce">Nonce for session validation.</param>
/// <param name="pipeName">Name of the named pipe to connect to.</param>
/// <param name="protocolVersion">Protocol version for frame communication.</param>
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public static async Task<FakeWorkerHarness> ConnectToGatewayPipeAsync(
string sessionId,
string nonce,
@@ -90,6 +106,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes));
}
/// <summary>Creates a worker client connected to the fake worker harness.</summary>
/// <param name="options">Configuration options for the worker client.</param>
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <returns>A configured worker client connected to this harness.</returns>
public WorkerClient CreateClient(
WorkerClientOptions? options = null,
GatewayMetrics? metrics = null,
@@ -109,6 +130,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return new WorkerClient(connection, options, metrics, timeProvider);
}
/// <summary>Completes the worker startup handshake by reading the gateway hello and sending worker hello and ready.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="workerVersion">Version string of the fake worker.</param>
/// <param name="mxaccessProgid">MXAccess COM ProgID.</param>
/// <param name="mxaccessClsid">MXAccess COM CLSID.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The gateway hello envelope received during startup.</returns>
public async Task<WorkerEnvelope> CompleteStartupAsync(
int workerProcessId = DefaultWorkerProcessId,
string workerVersion = "fake-worker",
@@ -135,11 +163,17 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return gatewayHello;
}
/// <summary>Reads the next gateway envelope from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The gateway envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadGatewayEnvelopeAsync(CancellationToken cancellationToken = default)
{
return await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Reads the next command from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The command envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadCommandAsync(CancellationToken cancellationToken = default)
{
WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
@@ -151,6 +185,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return envelope;
}
/// <summary>Reads the next shutdown request from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The shutdown envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadShutdownAsync(CancellationToken cancellationToken = default)
{
WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
@@ -162,6 +199,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return envelope;
}
/// <summary>Sends a worker hello message to the gateway.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="workerVersion">Version string of the fake worker.</param>
/// <param name="workerProtocolVersion">Protocol version override.</param>
/// <param name="nonce">Nonce override.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendWorkerHelloAsync(
int workerProcessId = DefaultWorkerProcessId,
string workerVersion = "fake-worker",
@@ -182,6 +225,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a worker ready message to the gateway.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="mxaccessProgid">MXAccess COM ProgID.</param>
/// <param name="mxaccessClsid">MXAccess COM CLSID.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendWorkerReadyAsync(
int workerProcessId = DefaultWorkerProcessId,
string mxaccessProgid = "LMXProxy.LMXProxyServer.1",
@@ -201,6 +249,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a reply to a command received from the gateway.</summary>
/// <param name="commandEnvelope">The command envelope to reply to.</param>
/// <param name="statusCode">Protocol status code for the reply.</param>
/// <param name="statusMessage">Human-readable status message.</param>
/// <param name="configureReply">Optional callback to customize the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task ReplyToCommandAsync(
WorkerEnvelope commandEnvelope,
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
@@ -238,6 +292,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Emits an event to the gateway.</summary>
/// <param name="family">Family of the event to emit.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <param name="configureEvent">Optional callback to customize the event.</param>
public async Task EmitEventAsync(
MxEventFamily family,
CancellationToken cancellationToken = default,
@@ -263,6 +321,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Emits a fault message to the gateway.</summary>
/// <param name="category">Category of the fault.</param>
/// <param name="diagnosticMessage">Diagnostic message describing the fault.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task EmitFaultAsync(
WorkerFaultCategory category,
string diagnosticMessage,
@@ -284,6 +346,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a heartbeat message to the gateway.</summary>
/// <param name="state">Current worker state.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <param name="configureHeartbeat">Optional callback to customize the heartbeat.</param>
public async Task SendHeartbeatAsync(
WorkerState state = WorkerState.Ready,
CancellationToken cancellationToken = default,
@@ -304,6 +370,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a shutdown acknowledgment message to the gateway.</summary>
/// <param name="statusCode">Protocol status code for the acknowledgment.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendShutdownAckAsync(
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
CancellationToken cancellationToken = default)
@@ -322,6 +391,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Writes a malformed payload directly to the worker stream.</summary>
/// <param name="payload">Malformed payload bytes to write.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task WriteMalformedPayloadAsync(
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken = default)
@@ -337,6 +409,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
await _workerStream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
}
/// <summary>Writes an oversized frame header to the worker stream for testing frame size limits.</summary>
/// <param name="payloadLength">Length of the oversized payload in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task WriteOversizedFrameHeaderAsync(
uint payloadLength,
CancellationToken cancellationToken = default)
@@ -354,6 +429,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false);
}
/// <summary>Disposes the worker-side stream.</summary>
public async ValueTask DisposeWorkerSideAsync()
{
if (_workerSideDisposed)
@@ -365,6 +441,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
_workerSideDisposed = true;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await DisposeWorkerSideAsync().ConfigureAwait(false);
@@ -14,6 +14,7 @@ public sealed class WorkerClientTests
private const int WorkerProcessId = 4321;
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that StartAsync enters ready state after receiving worker hello and ready messages.</summary>
[Fact]
public async Task StartAsync_WithWorkerHelloAndReady_EntersReadyState()
{
@@ -26,6 +27,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerProcessId, client.ProcessId);
}
/// <summary>Verifies that InvokeAsync completes a pending command when a matching reply arrives.</summary>
[Fact]
public async Task InvokeAsync_WithMatchingReply_CompletesPendingCommand()
{
@@ -51,6 +53,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
}
/// <summary>Verifies that InvokeAsync ignores late replies and keeps the client ready.</summary>
[Fact]
public async Task InvokeAsync_WithLateReply_IgnoresLateReplyAndKeepsClientReady()
{
@@ -86,6 +89,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind);
}
/// <summary>Verifies that ReadEventsAsync yields events in pipe order from the worker.</summary>
[Fact]
public async Task ReadEventsAsync_WithWorkerEvents_YieldsEventsInPipeOrder()
{
@@ -111,6 +115,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
}
/// <summary>Verifies that the read loop faults the client when the event queue overflows.</summary>
[Fact]
public async Task ReadLoop_WhenEventQueueOverflows_FaultsClient()
{
@@ -137,6 +142,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that the read loop faults the client when the pipe disconnects.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
{
@@ -153,6 +159,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that the read loop stops the running worker metric when the pipe disconnects.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_StopsRunningWorkerMetric()
{
@@ -175,6 +182,7 @@ public sealed class WorkerClientTests
Assert.Equal(1, snapshot.WorkerExits);
}
/// <summary>Verifies that DisposeAsync returns within a bounded timeout when the pipe read is blocked.</summary>
[Fact]
public async Task DisposeAsync_WhenPipeReadIsBlocked_ReturnsWithinBoundedTimeout()
{
@@ -191,6 +199,7 @@ public sealed class WorkerClientTests
$"DisposeAsync took {elapsed.TotalMilliseconds:N0}ms.");
}
/// <summary>Verifies that the read loop updates the last heartbeat and worker process when a heartbeat arrives.</summary>
[Fact]
public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess()
{
@@ -209,6 +218,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Ready, client.State);
}
/// <summary>Verifies that the heartbeat monitor faults the client when the heartbeat expires.</summary>
[Fact]
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
{
@@ -393,12 +403,16 @@ public sealed class WorkerClientTests
WorkerWriter = new WorkerFrameWriter(_workerStream, new WorkerFrameProtocolOptions(SessionId));
}
/// <summary>The gateway side of the named pipe connection.</summary>
public NamedPipeServerStream GatewayStream { get; }
/// <summary>Frame reader for worker messages.</summary>
public WorkerFrameReader WorkerReader { get; }
/// <summary>Frame writer for worker messages.</summary>
public WorkerFrameWriter WorkerWriter { get; }
/// <summary>Creates a connected pipe pair for testing.</summary>
public static async Task<PipePair> CreateAsync()
{
string pipeName = $"mxaccessgw-workerclient-tests-{Guid.NewGuid():N}";
@@ -421,6 +435,7 @@ public sealed class WorkerClientTests
return new PipePair(gatewayStream, workerStream);
}
/// <summary>Disposes the worker side of the pipe.</summary>
public async ValueTask DisposeWorkerSideAsync()
{
if (_workerSideDisposed)
@@ -432,6 +447,7 @@ public sealed class WorkerClientTests
_workerSideDisposed = true;
}
/// <summary>Disposes the duplex stream.</summary>
public async ValueTask DisposeAsync()
{
await DisposeWorkerSideAsync();
@@ -10,6 +10,7 @@ public sealed class WorkerFrameProtocolTests
{
private const string SessionId = "session-1";
/// <summary>Verifies that writing and reading a valid envelope round-trips the frame correctly.</summary>
[Fact]
public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame()
{
@@ -27,6 +28,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(original, parsed);
}
/// <summary>Verifies that reading a frame with partial reads reassembles the frame correctly.</summary>
[Fact]
public async Task ReadAsync_WithPartialReads_ReassemblesFrame()
{
@@ -42,6 +44,7 @@ public sealed class WorkerFrameProtocolTests
Assert.True(stream.ReadCallCount > 2);
}
/// <summary>Verifies that reading a frame with zero length throws a malformed length exception.</summary>
[Fact]
public async Task ReadAsync_WithZeroLengthFrame_ThrowsMalformedLength()
{
@@ -56,6 +59,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with oversized length throws before allocating the payload.</summary>
[Fact]
public async Task ReadAsync_WithOversizedLength_ThrowsBeforePayloadAllocation()
{
@@ -72,6 +76,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with wrong protocol version throws a protocol version mismatch exception.</summary>
[Fact]
public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch()
{
@@ -88,6 +93,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with wrong session ID throws a session mismatch exception.</summary>
[Fact]
public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch()
{
@@ -104,6 +110,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with malformed payload throws an invalid envelope exception.</summary>
[Fact]
public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope()
{
@@ -119,6 +126,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with missing envelope body throws an invalid envelope exception.</summary>
[Fact]
public async Task ReadAsync_WithMissingEnvelopeBody_ThrowsInvalidEnvelope()
{
@@ -135,6 +143,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
}
/// <summary>Verifies that writing an oversized envelope throws a message too large exception.</summary>
[Fact]
public async Task WriteAsync_WithOversizedEnvelope_ThrowsMessageTooLarge()
{
@@ -186,6 +195,9 @@ public sealed class WorkerFrameProtocolTests
{
private readonly int _chunkSize;
/// <summary>Initializes a new instance of the <see cref="ChunkedReadStream"/> class with chunked reads.</summary>
/// <param name="buffer">The buffer containing data to read.</param>
/// <param name="chunkSize">The maximum number of bytes to read per operation.</param>
public ChunkedReadStream(
byte[] buffer,
int chunkSize)
@@ -194,8 +206,10 @@ public sealed class WorkerFrameProtocolTests
_chunkSize = chunkSize;
}
/// <summary>Gets the number of read calls made to the stream.</summary>
public int ReadCallCount { get; private set; }
/// <inheritdoc />
public override ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
@@ -13,6 +13,7 @@ public sealed class WorkerProcessLauncherTests
private const string PipeName = "mxaccess-gateway-123-session-1";
private const string Nonce = "super-secret-nonce";
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
[Fact]
public async Task LaunchAsync_WithValidWorker_StartsProcessWithBootstrapArgumentsAndNonceEnvironment()
{
@@ -46,6 +47,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
}
/// <summary>Verifies that a failed startup probe kills and disposes the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupProbeFails_KillsAndDisposesWorker()
{
@@ -71,6 +73,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
}
/// <summary>Verifies that transient startup probe failures are retried without respawning the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupProbeFailsTransiently_RetriesWithoutRespawningWorker()
{
@@ -97,6 +100,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, snapshot.RetryAttemptsByArea["worker_startup"]);
}
/// <summary>Verifies that a startup probe timeout kills and disposes the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupTimesOut_KillsAndDisposesWorker()
{
@@ -121,6 +125,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
}
/// <summary>Verifies that a missing worker executable fails before attempting to start the process.</summary>
[Fact]
public async Task LaunchAsync_WhenExecutableDoesNotExist_FailsBeforeStartingProcess()
{
@@ -137,6 +142,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Null(processFactory.LastStartInfo);
}
/// <summary>Verifies that a worker executable with mismatched architecture fails before attempting to start.</summary>
[Fact]
public async Task LaunchAsync_WhenExecutableArchitectureDoesNotMatch_FailsBeforeStartingProcess()
{
@@ -153,6 +159,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Null(processFactory.LastStartInfo);
}
/// <summary>Verifies that a worker that has already exited fails and disposes without additional killing.</summary>
[Fact]
public async Task LaunchAsync_WhenWorkerAlreadyExited_FailsAndDisposesWorkerWithoutKill()
{
@@ -215,12 +222,16 @@ public sealed class WorkerProcessLauncherTests
pipeReservation);
}
/// <summary>Fake worker process factory for testing process launch logic.</summary>
private sealed class FakeWorkerProcessFactory(IWorkerProcess process) : IWorkerProcessFactory
{
/// <summary>Gets the most recent process start information.</summary>
public ProcessStartInfo? LastStartInfo { get; private set; }
/// <summary>Gets the number of times the process factory has started a process.</summary>
public int StartCount { get; private set; }
/// <inheritdoc />
public IWorkerProcess Start(ProcessStartInfo startInfo)
{
StartCount++;
@@ -229,23 +240,31 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake worker process for testing process lifecycle and exit behavior.</summary>
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
/// <inheritdoc />
public int Id { get; } = processId;
/// <summary>Gets or sets a value indicating whether the process has exited.</summary>
public bool HasExited { get; set; }
/// <summary>Gets or sets the process exit code.</summary>
public int? ExitCode { get; set; }
/// <summary>Gets a value indicating whether the Dispose method was called.</summary>
public bool DisposeCalled { get; private set; }
/// <summary>Gets a value indicating whether the Kill method was called.</summary>
public bool KillCalled { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
Assert.True(entireProcessTree);
@@ -253,14 +272,17 @@ public sealed class WorkerProcessLauncherTests
HasExited = true;
}
/// <inheritdoc />
public void Dispose()
{
DisposeCalled = true;
}
}
/// <summary>Fake startup probe that immediately succeeds.</summary>
private sealed class SucceedingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -270,8 +292,10 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that always fails.</summary>
private sealed class FailingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -281,8 +305,10 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that waits indefinitely to simulate a startup timeout.</summary>
private sealed class WaitingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public async Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -292,10 +318,12 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that fails a configurable number of times before succeeding.</summary>
private sealed class TransientStartupProbe(int failuresBeforeSuccess) : IWorkerStartupProbe
{
private int _attempts;
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -310,16 +338,20 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake pipe reservation for testing pipe lifecycle.</summary>
private sealed class FakePipeReservation : IDisposable
{
/// <summary>Gets a value indicating whether the Dispose method was called.</summary>
public bool DisposeCalled { get; private set; }
/// <inheritdoc />
public void Dispose()
{
DisposeCalled = true;
}
}
/// <summary>Test helper that creates and cleans up a temporary directory for worker executable tests.</summary>
private sealed class TestDirectory : IDisposable
{
private TestDirectory(string path)
@@ -327,8 +359,10 @@ public sealed class WorkerProcessLauncherTests
Path = path;
}
/// <summary>Gets the path to the temporary test directory.</summary>
public string Path { get; }
/// <summary>Creates a new temporary directory for testing.</summary>
public static TestDirectory Create()
{
string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"mxgateway-tests-{Guid.NewGuid():N}");
@@ -337,6 +371,9 @@ public sealed class WorkerProcessLauncherTests
return new TestDirectory(path);
}
/// <summary>Creates a fake PE executable with the specified machine architecture for testing.</summary>
/// <param name="machine">PE machine type constant (0x014c for x86, 0x8664 for x64).</param>
/// <returns>Full path to the created executable file.</returns>
public string CreateWorkerExecutable(ushort machine)
{
string path = System.IO.Path.Combine(Path, "MxGateway.Worker.exe");
@@ -354,6 +391,7 @@ public sealed class WorkerProcessLauncherTests
return path;
}
/// <inheritdoc />
public void Dispose()
{
Directory.Delete(Path, recursive: true);
@@ -4,6 +4,7 @@ namespace MxGateway.Tests.Metrics;
public sealed class GatewayMetricsTests
{
/// <summary>Verifies that snapshot reflects all metric updates.</summary>
[Fact]
public void GetSnapshot_ReflectsSessionWorkerCommandEventAndFaultUpdates()
{
@@ -50,6 +51,7 @@ public sealed class GatewayMetricsTests
Assert.Equal(2, snapshot.EventsBySession["session-1"]);
}
/// <summary>Verifies that negative queue depth is rejected.</summary>
[Fact]
public void SetEventQueueDepth_RejectsNegativeDepth()
{
@@ -61,6 +63,7 @@ public sealed class GatewayMetricsTests
Assert.Equal("depth", exception.ParamName);
}
/// <summary>Verifies that removing session events only affects that session.</summary>
[Fact]
public void RemoveSessionEvents_RemovesOnlyThatSession()
{
@@ -4,6 +4,7 @@ namespace MxGateway.Tests.ProjectStructure;
public sealed class GatewayProjectReferenceTests
{
/// <summary>Verifies that the gateway project targets .NET 10.0.</summary>
[Fact]
public void GatewayProject_TargetsNet10()
{
@@ -12,6 +13,7 @@ public sealed class GatewayProjectReferenceTests
Assert.Equal("net10.0", ElementValue(project, "TargetFramework"));
}
/// <summary>Verifies that the gateway project does not reference MXAccess COM.</summary>
[Fact]
public void GatewayProject_DoesNotReferenceMxAccessCom()
{
@@ -8,6 +8,7 @@ namespace MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyAdminCliRunnerTests
{
/// <summary>Verifies that CreateKeyAsync creates an authenticating key and audits the action.</summary>
[Fact]
public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits()
{
@@ -44,6 +45,7 @@ public sealed class ApiKeyAdminCliRunnerTests
Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01");
}
/// <summary>Verifies that ListKeysAsync does not print the raw secret.</summary>
[Fact]
public async Task ListKeysAsync_DoesNotPrintRawSecret()
{
@@ -72,6 +74,7 @@ public sealed class ApiKeyAdminCliRunnerTests
Assert.DoesNotContain("secret_hash", listJson, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that RevokeKeyAsync causes the revoked key to fail verification and is audited.</summary>
[Fact]
public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits()
{
@@ -105,6 +108,7 @@ public sealed class ApiKeyAdminCliRunnerTests
Assert.Contains(auditRecords, record => record.EventType == "revoke-key" && record.KeyId == "operator01");
}
/// <summary>Verifies that RotateKeyAsync prints the new secret once and invalidates the old secret.</summary>
[Fact]
public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret()
{
@@ -140,6 +144,7 @@ public sealed class ApiKeyAdminCliRunnerTests
Assert.True(newVerification.Succeeded);
}
/// <summary>Verifies that CreateKeyAsync prints the raw secret exactly once.</summary>
[Fact]
public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce()
{
@@ -4,6 +4,9 @@ namespace MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyAdminCommandLineParserTests
{
/// <summary>
/// Verifies non-API key commands return not-an-API-key result.
/// </summary>
[Fact]
public void Parse_NonApiKeyCommand_ReturnsNotApiKeyCommand()
{
@@ -13,6 +16,9 @@ public sealed class ApiKeyAdminCommandLineParserTests
Assert.Null(result.Command);
}
/// <summary>
/// Verifies API key create command parsing returns options.
/// </summary>
[Fact]
public void Parse_CreateKeyCommand_ReturnsOptions()
{
@@ -46,6 +52,9 @@ public sealed class ApiKeyAdminCommandLineParserTests
Assert.Contains("events:read", result.Command.Scopes);
}
/// <summary>
/// Verifies create key without display name returns error.
/// </summary>
[Fact]
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
{
@@ -57,6 +66,9 @@ public sealed class ApiKeyAdminCommandLineParserTests
Assert.Contains("--display-name", result.Error, StringComparison.Ordinal);
}
/// <summary>
/// Verifies key ID with underscore returns error.
/// </summary>
[Fact]
public void Parse_KeyIdWithUnderscore_ReturnsError()
{
@@ -4,6 +4,7 @@ namespace MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyParserTests
{
/// <summary>Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret.</summary>
[Fact]
public void TryParseAuthorizationHeader_ValidBearerToken_ReturnsKeyIdAndSecret()
{
@@ -19,6 +20,8 @@ public sealed class ApiKeyParserTests
Assert.Equal("secret_value", apiKey.Secret);
}
/// <summary>Verifies that TryParseAuthorizationHeader returns false for malformed tokens.</summary>
/// <param name="authorizationHeader">Malformed authorization header value.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -7,6 +7,9 @@ namespace MxGateway.Tests.Security.Authentication;
public sealed class ApiKeySecretHasherTests
{
/// <summary>
/// Verifies identical pepper and secret produce identical hashes.
/// </summary>
[Fact]
public void HashSecret_SamePepperAndSecret_ReturnsSameHash()
{
@@ -19,6 +22,9 @@ public sealed class ApiKeySecretHasherTests
Assert.NotEqual("raw-secret"u8.ToArray(), firstHash);
}
/// <summary>
/// Verifies different pepper values produce different hashes.
/// </summary>
[Fact]
public void HashSecret_DifferentPepper_ReturnsDifferentHash()
{
@@ -28,6 +34,9 @@ public sealed class ApiKeySecretHasherTests
Assert.NotEqual(firstHash, secondHash);
}
/// <summary>
/// Verifies missing pepper throws an exception.
/// </summary>
[Fact]
public void HashSecret_MissingPepper_Throws()
{
@@ -8,6 +8,7 @@ namespace MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyVerifierTests
{
/// <summary>Verifies that VerifyAsync returns identity and scopes for a valid key.</summary>
[Fact]
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
{
@@ -28,6 +29,7 @@ public sealed class ApiKeyVerifierTests
Assert.True(store.MarkedUsed);
}
/// <summary>Verifies that VerifyAsync does not expose the raw secret in the result.</summary>
[Fact]
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
{
@@ -44,6 +46,8 @@ public sealed class ApiKeyVerifierTests
Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal);
}
/// <summary>Verifies that VerifyAsync fails with unauthenticated status for a malformed key.</summary>
/// <param name="authorizationHeader">Authorization header value to test.</param>
[Theory]
[InlineData(null)]
[InlineData("Bearer mxgw_operator01")]
@@ -63,6 +67,7 @@ public sealed class ApiKeyVerifierTests
Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure);
}
/// <summary>Verifies that VerifyAsync fails for an unknown key.</summary>
[Fact]
public async Task VerifyAsync_UnknownKey_Fails()
{
@@ -79,6 +84,7 @@ public sealed class ApiKeyVerifierTests
Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure);
}
/// <summary>Verifies that VerifyAsync fails for a wrong secret.</summary>
[Fact]
public async Task VerifyAsync_WrongSecret_Fails()
{
@@ -95,6 +101,7 @@ public sealed class ApiKeyVerifierTests
Assert.False(store.MarkedUsed);
}
/// <summary>Verifies that VerifyAsync fails for a revoked key.</summary>
[Fact]
public async Task VerifyAsync_RevokedKey_Fails()
{
@@ -111,6 +118,7 @@ public sealed class ApiKeyVerifierTests
Assert.False(store.MarkedUsed);
}
/// <summary>Verifies that VerifyAsync fails when the pepper is missing.</summary>
[Fact]
public async Task VerifyAsync_MissingPepper_Fails()
{
@@ -166,15 +174,23 @@ public sealed class ApiKeyVerifierTests
return new ApiKeySecretHasher(configuration, Options.Create(options));
}
/// <summary>Fake in-memory API key store for testing.</summary>
private sealed class FakeApiKeyStore(ApiKeyRecord? storedKey) : IApiKeyStore
{
/// <summary>Gets whether the key was marked as used.</summary>
public bool MarkedUsed { get; private set; }
/// <summary>Finds an API key record by its ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
}
/// <summary>Finds an active (non-revoked) API key record by its ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return Task.FromResult(
@@ -183,6 +199,10 @@ public sealed class ApiKeyVerifierTests
: null);
}
/// <summary>Marks an API key as used at the specified time.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="usedUtc">Timestamp when the key was used.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
{
MarkedUsed = storedKey?.KeyId == keyId;
@@ -8,8 +8,14 @@ using MxGateway.Server.Security.Authentication;
namespace MxGateway.Tests.Security.Authentication;
/// <summary>
/// Tests for <see cref="SqliteAuthStore"/>.
/// </summary>
public sealed class SqliteAuthStoreTests
{
/// <summary>
/// Verifies that MigrateAsync initializes the database schema.
/// </summary>
[Fact]
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
{
@@ -25,6 +31,9 @@ public sealed class SqliteAuthStoreTests
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
}
/// <summary>
/// Verifies that MigrateAsync migrates and is idempotent.
/// </summary>
[Fact]
public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently()
{
@@ -42,6 +51,9 @@ public sealed class SqliteAuthStoreTests
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
}
/// <summary>
/// Verifies that gateway startup fails with a newer schema version.
/// </summary>
[Fact]
public async Task StartAsync_NewerSchemaVersion_BlocksStartup()
{
@@ -60,6 +72,9 @@ public sealed class SqliteAuthStoreTests
Assert.Contains("newer than supported version", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that FindActiveByKeyIdAsync returns an active key.
/// </summary>
[Fact]
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
{
@@ -80,6 +95,9 @@ public sealed class SqliteAuthStoreTests
Assert.Null(key.RevokedUtc);
}
/// <summary>
/// Verifies that FindActiveByKeyIdAsync returns null for a revoked key.
/// </summary>
[Fact]
public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull()
{
@@ -100,6 +118,9 @@ public sealed class SqliteAuthStoreTests
Assert.NotNull(storedKey.RevokedUtc);
}
/// <summary>
/// Verifies that the audit store persists audit events.
/// </summary>
[Fact]
public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent()
{
@@ -9,6 +9,7 @@ namespace MxGateway.Tests.Security.Authorization;
public sealed class GatewayGrpcAuthorizationInterceptorTests
{
/// <summary>Verifies that missing API key returns unauthenticated status.</summary>
[Fact]
public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated()
{
@@ -27,6 +28,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.DoesNotContain("secret", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that invalid API key error does not expose raw credentials.</summary>
[Fact]
public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus()
{
@@ -44,6 +46,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.DoesNotContain("super-secret", exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that valid key without required scope returns permission denied.</summary>
[Fact]
public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied()
{
@@ -61,6 +64,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that valid key with scope sets request identity for the handler.</summary>
[Fact]
public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity()
{
@@ -86,6 +90,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Null(identityAccessor.Current);
}
/// <summary>Verifies that server stream handler requires proper scope.</summary>
[Fact]
public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied()
{
@@ -104,6 +109,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that server stream handler allows streams with proper scope.</summary>
[Fact]
public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream()
{
@@ -128,6 +134,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Null(identityAccessor.Current);
}
/// <summary>Verifies that disabled authentication skips API key verification.</summary>
[Fact]
public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification()
{
@@ -183,10 +190,16 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
{
/// <summary>Gets whether the verifier was called.</summary>
public bool WasCalled { get; private set; }
/// <summary>Gets the last authorization header seen by the verifier.</summary>
public string? LastAuthorizationHeader { get; private set; }
/// <summary>Verifies the authorization header against stored result.</summary>
/// <param name="authorizationHeader">The authorization header to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Configured verification result.</returns>
public Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
@@ -200,10 +213,15 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
{
/// <summary>Gets messages written to the stream.</summary>
public List<T> Messages { get; } = [];
/// <summary>Gets or sets write options for the stream.</summary>
public WriteOptions? WriteOptions { get; set; }
/// <summary>Writes a message to the stream.</summary>
/// <param name="message">The message to write.</param>
/// <returns>Task representing the write operation.</returns>
public Task WriteAsync(T message)
{
Messages.Add(message);
@@ -221,43 +239,56 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
private Status status;
private WriteOptions? writeOptions;
/// <inheritdoc />
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:5000";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => cancellationToken;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore => responseTrailers;
/// <inheritdoc />
protected override Status StatusCore
{
get => status;
set => status = value;
}
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore
{
get => writeOptions;
set => writeOptions = value;
}
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
/// <inheritdoc />
protected override IDictionary<object, object> UserStateCore => userState;
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{
return Task.CompletedTask;
}
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
{
@@ -6,6 +6,9 @@ namespace MxGateway.Tests.Security.Authorization;
public sealed class GatewayGrpcScopeResolverTests
{
/// <summary>Verifies that ResolveRequiredScope returns the expected scope for known RPC request types.</summary>
/// <param name="requestType">The gRPC request type to test.</param>
/// <param name="expectedScope">The expected scope for the request.</param>
[Theory]
[InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)]
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
@@ -25,6 +28,9 @@ public sealed class GatewayGrpcScopeResolverTests
Assert.Equal(expectedScope, scope);
}
/// <summary>Verifies that ResolveRequiredScope returns the expected scope for MXAccess invoke commands.</summary>
/// <param name="commandKind">The MXAccess command kind to test.</param>
/// <param name="expectedScope">The expected scope for the command.</param>
[Theory]
[InlineData(MxCommandKind.Register, GatewayScopes.InvokeRead)]
[InlineData(MxCommandKind.AddItem, GatewayScopes.InvokeRead)]