Resolve Tests-003, -004, -005, -006 code-review findings

Tests-003: temp auth-DB directories leaked under %TEMP%. Added the
TempDatabaseDirectory IDisposable helper (clears the Sqlite connection pool,
then recursively deletes); SqliteAuthStoreTests and ApiKeyAdminCliRunnerTests
now dispose every directory they create.

Tests-004: added end-to-end coverage composing the real authorization
interceptor in front of the real MxAccessGatewayService, plus scope-resolver
tests confirming an unmapped request type fails closed to the admin scope.

Tests-005: added coverage for a worker faulting mid-command — a pipe
disconnect and a worker fault while an InvokeAsync is in flight both fail the
pending invoke. No product change needed.

Tests-006 (re-triaged): the flaky ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess
is a test race, not a product bug — the kill runs synchronously inside
SetFaulted. Rewrote it to await FakeWorkerProcess exit deterministically, and
replaced fixed Task.Delay timing in the late-reply and heartbeat tests with
FIFO ordering and an injected ManualTimeProvider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 21:44:55 -04:00
parent 98f9b7792b
commit 5ade3f4f48
8 changed files with 539 additions and 31 deletions
@@ -1,9 +1,15 @@
using System.Runtime.CompilerServices;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
namespace MxGateway.Tests.Security.Authorization;
@@ -156,6 +162,110 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Null(identityAccessor.Current);
}
/// <summary>
/// End-to-end composition test: runs an <c>OpenSession</c> call through the real
/// interceptor in front of the real <see cref="MxAccessGatewayService"/> with a key
/// that lacks the <c>session:open</c> scope, and asserts the interceptor denies the
/// call with <see cref="StatusCode.PermissionDenied"/> before the service runs.
/// </summary>
[Fact]
public async Task InterceptorComposedWithService_OpenSessionMissingScope_DeniesBeforeServiceRuns()
{
GatewayRequestIdentityAccessor identityAccessor = new();
RecordingSessionManager sessionManager = new();
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
identityAccessor);
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => interceptor.UnaryServerHandler(
new OpenSessionRequest { ClientSessionName = "operator-session" },
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(request, context) => service.OpenSession(request, context)));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal);
Assert.Equal(0, sessionManager.OpenSessionCount);
}
/// <summary>
/// End-to-end composition test: runs an <c>OpenSession</c> call through the real
/// interceptor in front of the real <see cref="MxAccessGatewayService"/> with a key
/// that holds <c>session:open</c>, and asserts the service runs and observes the
/// interceptor-supplied identity.
/// </summary>
[Fact]
public async Task InterceptorComposedWithService_OpenSessionWithScope_RunsServiceWithIdentity()
{
GatewayRequestIdentityAccessor identityAccessor = new();
RecordingSessionManager sessionManager = new();
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)),
identityAccessor);
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
OpenSessionReply reply = await interceptor.UnaryServerHandler(
new OpenSessionRequest { ClientSessionName = "operator-session" },
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(request, context) => service.OpenSession(request, context));
Assert.Equal("session-1", reply.SessionId);
Assert.Equal(1, sessionManager.OpenSessionCount);
Assert.Equal("Operator Key", sessionManager.LastClientIdentity);
}
/// <summary>
/// End-to-end composition test: an <c>Invoke</c> call through the real interceptor in
/// front of the real service with a key holding only <c>invoke:read</c> is denied
/// because the wrapped command is a write, confirming command-scope mapping is
/// enforced through the full composition.
/// </summary>
[Fact]
public async Task InterceptorComposedWithService_InvokeWriteCommandWithReadScope_DeniesBeforeServiceRuns()
{
GatewayRequestIdentityAccessor identityAccessor = new();
RecordingSessionManager sessionManager = new();
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)),
identityAccessor);
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
MxCommandRequest request = new()
{
SessionId = "session-1",
Command = new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand { ServerHandle = 1, ItemHandle = 2 },
},
};
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => interceptor.UnaryServerHandler(
request,
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(req, context) => service.Invoke(req, context)));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal);
Assert.Equal(0, sessionManager.InvokeCount);
}
private static MxAccessGatewayService CreateService(
ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor)
{
return new MxAccessGatewayService(
sessionManager,
identityAccessor,
new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new NoOpEventStreamService(),
new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance);
}
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
IApiKeyVerifier apiKeyVerifier,
IGatewayRequestIdentityAccessor identityAccessor,
@@ -188,6 +298,138 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
return new TestServerCallContext([new Metadata.Entry("authorization", authorizationHeader)]);
}
/// <summary>Records whether the gateway service ran past the interceptor for composition tests.</summary>
private sealed class RecordingSessionManager : ISessionManager
{
/// <summary>Gets the number of times OpenSessionAsync was invoked.</summary>
public int OpenSessionCount { get; private set; }
/// <summary>Gets the number of times InvokeAsync was invoked.</summary>
public int InvokeCount { get; private set; }
/// <summary>Gets the last client identity passed to OpenSessionAsync.</summary>
public string? LastClientIdentity { get; private set; }
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken)
{
OpenSessionCount++;
LastClientIdentity = clientIdentity;
GatewaySession session = new(
"session-1",
GatewayContractInfo.DefaultBackendName,
"pipe",
"nonce",
clientIdentity ?? "client",
"client-session",
"client-correlation",
TimeSpan.FromSeconds(7),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
return Task.FromResult(session);
}
/// <inheritdoc />
public bool TryGetSession(string sessionId, out GatewaySession session)
{
session = null!;
return false;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
InvokeCount++;
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
{
return AsyncEnumerable.Empty<WorkerEvent>();
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
{
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
{
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
/// <summary>Event stream service that yields nothing; alarm/event RPCs are not under test here.</summary>
private sealed class NoOpEventStreamService : IEventStreamService
{
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
}
/// <summary>Constraint enforcer that permits every operation for composition tests.</summary>
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
{
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
string target,
ConstraintFailure failure,
CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
{
/// <summary>Gets whether the verifier was called.</summary>