Add XML documentation across gateway, worker, and .NET client
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
+31
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user