From ff86b3f0b09fe8262cdde51ff28b6efa3f0aa4fa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 18:15:22 -0400 Subject: [PATCH 1/2] Implement dashboard authentication --- docs/gateway-dashboard-design.md | 20 +- docs/gateway-process-design.md | 10 + gateway.md | 7 + .../DashboardAuthenticationDefaults.cs | 10 + .../DashboardAuthenticationResult.cs | 19 ++ .../Dashboard/DashboardAuthenticator.cs | 81 +++++++ .../DashboardAuthorizationHandler.cs | 59 +++++ .../DashboardAuthorizationRequirement.cs | 5 + ...DashboardEndpointRouteBuilderExtensions.cs | 217 ++++++++++++++++++ .../DashboardServiceCollectionExtensions.cs | 42 ++++ .../Dashboard/IDashboardAuthenticator.cs | 8 + src/MxGateway.Server/GatewayApplication.cs | 3 + .../Dashboard/DashboardAuthenticatorTests.cs | 113 +++++++++ .../DashboardAuthorizationHandlerTests.cs | 91 ++++++++ .../Dashboard/DashboardCookieOptionsTests.cs | 32 +++ 15 files changed, 710 insertions(+), 7 deletions(-) create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs create mode 100644 src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs diff --git a/docs/gateway-dashboard-design.md b/docs/gateway-dashboard-design.md index 70e68cc..d691c9f 100644 --- a/docs/gateway-dashboard-design.md +++ b/docs/gateway-dashboard-design.md @@ -257,19 +257,18 @@ Do not show API key secrets or pepper values. ## Authentication And Authorization -Dashboard access should use the same API-key authentication model as gRPC where +Dashboard access uses the same API-key authentication model as gRPC where practical. -Recommended v1 behavior: +Implemented v1 behavior: -- dashboard disabled by default unless configured, - when enabled, require API key auth, - require `admin` scope for dashboard access, -- accept API key through a secure cookie established by a simple login form, or - through reverse-proxy/header configuration for local deployments, -- do not put API keys in query strings. +- accept API key through a secure cookie established by a simple login form, +- do not put API keys in query strings, +- validate anti-forgery tokens for login and logout posts. -Simplest implementation path: +The implementation path is: 1. Add `/dashboard/login`. 2. User submits API key over HTTPS. @@ -281,6 +280,13 @@ Simplest implementation path: For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost` option. It must default to false. +`DashboardAuthenticator` keeps API-key validation outside UI components. It +formats the submitted key as a bearer authorization header for +`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is +enabled, and creates the dashboard cookie principal without storing raw API key +material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and +explicit loopback bypass decisions for all protected dashboard routes. + ## Configuration Suggested configuration: diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index 33b2b7e..408b687 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -650,6 +650,16 @@ server-streaming calls and stores the authenticated `ApiKeyIdentity` in `Authentication:Mode` set to `Disabled` bypasses API-key verification for local development only. +Dashboard authentication reuses the API-key verifier and scope model. The +dashboard login endpoint accepts the key in a form post, checks `admin` scope +when `Dashboard:RequireAdminScope` is enabled, and signs in with the +`MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict +SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears +that cookie. Login and logout posts use anti-forgery validation, and dashboard +API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost` +allows only loopback requests to bypass the dashboard cookie requirement and +defaults to `false`. + Recommended scopes: - `session:open` diff --git a/gateway.md b/gateway.md index 2dfcbc2..ca82673 100644 --- a/gateway.md +++ b/gateway.md @@ -109,6 +109,13 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can project without binding to a metrics exporter. `DashboardSnapshotService` projects sessions, workers, metrics, faults, and effective configuration into immutable DTOs for read-only dashboard rendering. +Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login` +accepts the API key in a form body, validates the configured `admin` scope, +and issues an HTTP-only secure cookie for subsequent dashboard requests. +`/dashboard/logout` clears that cookie. Login and logout posts validate +anti-forgery tokens, and API keys are never accepted through query strings. +`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for +loopback requests only when explicitly enabled. ### Worker Process diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs new file mode 100644 index 0000000..84cea7a --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs @@ -0,0 +1,10 @@ +namespace MxGateway.Server.Dashboard; + +public static class DashboardAuthenticationDefaults +{ + public const string AuthenticationScheme = "MxGateway.Dashboard"; + public const string AuthorizationPolicy = "MxGateway.Dashboard"; + public const string ScopeClaimType = "scope"; + public const string KeyPrefixClaimType = "mxgateway:key_prefix"; + public const string CookieName = "__Host-MxGatewayDashboard"; +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs new file mode 100644 index 0000000..36fbadf --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; + +namespace MxGateway.Server.Dashboard; + +public sealed record DashboardAuthenticationResult( + bool Succeeded, + ClaimsPrincipal? Principal, + string? FailureMessage) +{ + public static DashboardAuthenticationResult Success(ClaimsPrincipal principal) + { + return new DashboardAuthenticationResult(true, principal, null); + } + + public static DashboardAuthenticationResult Fail(string failureMessage) + { + return new DashboardAuthenticationResult(false, null, failureMessage); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs new file mode 100644 index 0000000..b0d269b --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardAuthenticator( + IApiKeyVerifier apiKeyVerifier, + IOptions options) : IDashboardAuthenticator +{ + private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access."; + + public async Task AuthenticateAsync( + string? apiKey, + CancellationToken cancellationToken) + { + if (options.Value.Authentication.Mode == AuthenticationMode.Disabled) + { + return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity( + KeyId: "authentication-disabled", + KeyPrefix: "authentication-disabled", + DisplayName: "Authentication Disabled", + Scopes: new HashSet([GatewayScopes.Admin], StringComparer.Ordinal)))); + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + return DashboardAuthenticationResult.Fail(GenericFailureMessage); + } + + ApiKeyVerificationResult verificationResult = await apiKeyVerifier + .VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken) + .ConfigureAwait(false); + + if (!verificationResult.Succeeded || verificationResult.Identity is null) + { + return DashboardAuthenticationResult.Fail(GenericFailureMessage); + } + + if (options.Value.Dashboard.RequireAdminScope + && !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin)) + { + return DashboardAuthenticationResult.Fail(GenericFailureMessage); + } + + return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity)); + } + + private static string FormatAuthorizationHeader(string apiKey) + { + string trimmedApiKey = apiKey.Trim(); + + return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? trimmedApiKey + : $"Bearer {trimmedApiKey}"; + } + + private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity) + { + List claims = + [ + new Claim(ClaimTypes.NameIdentifier, identity.KeyId), + new Claim(ClaimTypes.Name, identity.DisplayName), + new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix) + ]; + + claims.AddRange(identity.Scopes.Select(scope => new Claim( + DashboardAuthenticationDefaults.ScopeClaimType, + scope))); + + ClaimsIdentity claimsIdentity = new( + claims, + DashboardAuthenticationDefaults.AuthenticationScheme, + ClaimTypes.Name, + DashboardAuthenticationDefaults.ScopeClaimType); + + return new ClaimsPrincipal(claimsIdentity); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs b/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs new file mode 100644 index 0000000..6158138 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs @@ -0,0 +1,59 @@ +using System.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardAuthorizationHandler( + IHttpContextAccessor httpContextAccessor, + IOptions options) : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + DashboardAuthorizationRequirement requirement) + { + GatewayOptions gatewayOptions = options.Value; + + if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled) + { + context.Succeed(requirement); + + return Task.CompletedTask; + } + + if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest()) + { + context.Succeed(requirement); + + return Task.CompletedTask; + } + + if (context.User.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private bool IsLoopbackRequest() + { + IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress; + + return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress); + } + + private static bool HasAdminScope(AuthorizationHandlerContext context) + { + return context.User.HasClaim( + DashboardAuthenticationDefaults.ScopeClaimType, + GatewayScopes.Admin); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs b/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs new file mode 100644 index 0000000..45862e2 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Authorization; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement; diff --git a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..57b04a1 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -0,0 +1,217 @@ +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.HttpResults; +using MxGateway.Server.Configuration; + +namespace MxGateway.Server.Dashboard; + +public static class DashboardEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints) + { + IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService(); + IConfigurationSection dashboardSection = configuration + .GetSection($"{GatewayOptions.SectionName}:Dashboard"); + + if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled) + { + return endpoints; + } + + string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase); + RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase); + + dashboard.MapGet( + "/", + (HttpContext httpContext, IAntiforgery antiforgery, IDashboardSnapshotService snapshotService) => + GetDashboardHomeAsync(httpContext, antiforgery, snapshotService, pathBase)) + .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy) + .WithName("DashboardHome"); + + dashboard.MapGet( + "/login", + (HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase)) + .AllowAnonymous() + .WithName("DashboardLogin"); + + dashboard.MapPost( + "/login", + (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => + PostLoginAsync(httpContext, antiforgery, authenticator, pathBase)) + .AllowAnonymous() + .WithName("DashboardLoginPost"); + + dashboard.MapPost( + "/logout", + (HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase)) + .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy) + .WithName("DashboardLogout"); + + dashboard.MapGet("/denied", () => Results.Content( + RenderPage("Access denied", "

The signed-in API key is not authorized for dashboard access.

"), + "text/html")) + .AllowAnonymous() + .WithName("DashboardAccessDenied"); + + return endpoints; + } + + private static ContentHttpResult GetDashboardHomeAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + IDashboardSnapshotService snapshotService, + string pathBase) + { + AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext); + DashboardSnapshot snapshot = snapshotService.GetSnapshot(); + string requestToken = tokens.RequestToken ?? string.Empty; + string body = $""" +
+ + +
+
+
Open sessions
+
{snapshot.Sessions.Count}
+
Workers
+
{snapshot.Workers.Count}
+
Faults
+
{snapshot.Faults.Count}
+
+ """; + + return TypedResults.Content(RenderPage("MXAccess Gateway Dashboard", body), "text/html"); + } + + private static Task GetLoginAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + string pathBase) + { + string returnUrl = SanitizeReturnUrl( + httpContext.Request.Query["returnUrl"].ToString(), + pathBase); + + return Task.FromResult(TypedResults.Content( + RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null), + "text/html")); + } + + private static async Task PostLoginAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + IDashboardAuthenticator authenticator, + string pathBase) + { + await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); + + IFormCollection form = await httpContext.Request + .ReadFormAsync(httpContext.RequestAborted) + .ConfigureAwait(false); + string returnUrl = SanitizeReturnUrl( + form["returnUrl"].ToString(), + pathBase); + + DashboardAuthenticationResult result = await authenticator + .AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted) + .ConfigureAwait(false); + + if (!result.Succeeded || result.Principal is null) + { + return TypedResults.Content( + RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage), + "text/html", + statusCode: StatusCodes.Status401Unauthorized); + } + + await httpContext + .SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal) + .ConfigureAwait(false); + + return Results.LocalRedirect(returnUrl); + } + + private static async Task PostLogoutAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + string pathBase) + { + await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); + await httpContext + .SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme) + .ConfigureAwait(false); + + return Results.LocalRedirect($"{pathBase}/login"); + } + + private static string RenderLoginPage( + HttpContext httpContext, + IAntiforgery antiforgery, + string returnUrl, + string pathBase, + string? failureMessage) + { + AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext); + string requestToken = tokens.RequestToken ?? string.Empty; + string alert = string.IsNullOrWhiteSpace(failureMessage) + ? string.Empty + : $"

{HtmlEncoder.Default.Encode(failureMessage)}

"; + + string body = $""" + {alert} +
+ + + + + +
+ """; + + return RenderPage("Dashboard Sign In", body); + } + + private static string RenderPage(string title, string body) + { + return $""" + + + + + + {HtmlEncoder.Default.Encode(title)} + + +
+

{HtmlEncoder.Default.Encode(title)}

+ {body} +
+ + + """; + } + + private static string NormalizePathBase(string pathBase) + { + string normalized = pathBase.TrimEnd('/'); + + return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal) + ? "/dashboard" + : normalized; + } + + private static string SanitizeReturnUrl(string? returnUrl, string pathBase) + { + if (string.IsNullOrWhiteSpace(returnUrl) + || !returnUrl.StartsWith("/", StringComparison.Ordinal) + || returnUrl.StartsWith("//", StringComparison.Ordinal) + || !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase) + || Uri.TryCreate(returnUrl, UriKind.Absolute, out _)) + { + return pathBase; + } + + return returnUrl; + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index cfbaa90..da0dddc 100644 --- a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -1,3 +1,8 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; + namespace MxGateway.Server.Dashboard; public static class DashboardServiceCollectionExtensions @@ -5,7 +10,44 @@ public static class DashboardServiceCollectionExtensions public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); + services.AddHttpContextAccessor(); + services.AddAntiforgery(); + services + .AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme) + .AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme); + services.AddOptions(DashboardAuthenticationDefaults.AuthenticationScheme) + .Configure>(ConfigureCookieOptions); + services.AddAuthorization(options => + { + options.AddPolicy( + DashboardAuthenticationDefaults.AuthorizationPolicy, + policy => policy.AddRequirements(new DashboardAuthorizationRequirement())); + }); + services.AddSingleton(); return services; } + + private static void ConfigureCookieOptions( + CookieAuthenticationOptions cookieOptions, + IOptions gatewayOptions) + { + string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/'); + if (string.IsNullOrWhiteSpace(pathBase)) + { + pathBase = "/dashboard"; + } + + cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName; + cookieOptions.Cookie.HttpOnly = true; + cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always; + cookieOptions.Cookie.SameSite = SameSiteMode.Strict; + cookieOptions.Cookie.Path = "/"; + cookieOptions.LoginPath = $"{pathBase}/login"; + cookieOptions.LogoutPath = $"{pathBase}/logout"; + cookieOptions.AccessDeniedPath = $"{pathBase}/denied"; + cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8); + cookieOptions.SlidingExpiration = true; + } } diff --git a/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs b/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs new file mode 100644 index 0000000..5c8c330 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs @@ -0,0 +1,8 @@ +namespace MxGateway.Server.Dashboard; + +public interface IDashboardAuthenticator +{ + Task AuthenticateAsync( + string? apiKey, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index 887cdcf..0a3f92d 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -19,6 +19,8 @@ public static class GatewayApplication WebApplication app = builder.Build(); app.UseGatewayRequestLoggingScope(); + app.UseAuthentication(); + app.UseAuthorization(); app.MapGatewayEndpoints(); return app; @@ -55,6 +57,7 @@ public static class GatewayApplication .WithName("LiveHealth"); endpoints.MapGrpcService(); + endpoints.MapGatewayDashboard(); return endpoints; } diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs new file mode 100644 index 0000000..aa7dfa5 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -0,0 +1,113 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardAuthenticatorTests +{ + [Fact] + public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal() + { + FakeApiKeyVerifier verifier = new(SuccessWithScopes(GatewayScopes.Admin)); + DashboardAuthenticator authenticator = CreateAuthenticator(verifier); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_super-secret", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + Assert.Equal("operator01", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Equal("Operator Key", result.Principal.FindFirst(ClaimTypes.Name)?.Value); + Assert.Contains(result.Principal.Claims, claim => + claim.Type == DashboardAuthenticationDefaults.ScopeClaimType + && claim.Value == GatewayScopes.Admin); + Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader); + } + + [Fact] + public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey() + { + DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier( + SuccessWithScopes(GatewayScopes.EventsRead))); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_super-secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey() + { + DashboardAuthenticator authenticator = CreateAuthenticator( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), + requireAdminScope: false); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_secret", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + } + + [Fact] + public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure() + { + DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier( + ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch))); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_super-secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal); + } + + private static DashboardAuthenticator CreateAuthenticator( + IApiKeyVerifier verifier, + bool requireAdminScope = true) + { + return new DashboardAuthenticator( + verifier, + Options.Create(new GatewayOptions + { + Dashboard = new DashboardOptions + { + RequireAdminScope = requireAdminScope + } + })); + } + + private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes) + { + return ApiKeyVerificationResult.Success(new ApiKeyIdentity( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + DisplayName: "Operator Key", + Scopes: new HashSet(scopes, StringComparer.Ordinal))); + } + + private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier + { + public string? LastAuthorizationHeader { get; private set; } + + public Task VerifyAsync( + string? authorizationHeader, + CancellationToken cancellationToken) + { + LastAuthorizationHeader = authorizationHeader; + + return Task.FromResult(result); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs new file mode 100644 index 0000000..7683b4f --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardAuthorizationHandlerTests +{ + [Fact] + public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + IPAddress.Parse("10.0.0.5"), + allowAnonymousLocalhost: false); + + Assert.False(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + IPAddress.Loopback, + allowAnonymousLocalhost: true); + + Assert.True(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + CreatePrincipal(GatewayScopes.EventsRead), + IPAddress.Loopback, + allowAnonymousLocalhost: false); + + Assert.False(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + CreatePrincipal(GatewayScopes.Admin), + IPAddress.Parse("10.0.0.5"), + allowAnonymousLocalhost: false); + + Assert.True(context.HasSucceeded); + } + + private static async Task AuthorizeAsync( + ClaimsPrincipal principal, + IPAddress remoteAddress, + bool allowAnonymousLocalhost) + { + DashboardAuthorizationRequirement requirement = new(); + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = remoteAddress; + DashboardAuthorizationHandler handler = new( + new HttpContextAccessor { HttpContext = httpContext }, + Options.Create(new GatewayOptions + { + Dashboard = new DashboardOptions + { + AllowAnonymousLocalhost = allowAnonymousLocalhost, + RequireAdminScope = true + } + })); + AuthorizationHandlerContext context = new([requirement], principal, httpContext); + + await handler.HandleAsync(context); + + return context; + } + + private static ClaimsPrincipal CreatePrincipal(string scope) + { + ClaimsIdentity identity = new( + [new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)], + DashboardAuthenticationDefaults.AuthenticationScheme); + + return new ClaimsPrincipal(identity); + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs new file mode 100644 index 0000000..f643986 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using MxGateway.Server; +using MxGateway.Server.Dashboard; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardCookieOptionsTests +{ + [Fact] + public void Build_ConfiguresSecureDashboardCookie() + { + WebApplication app = GatewayApplication.Build([]); + IOptionsMonitor optionsMonitor = app.Services + .GetRequiredService>(); + + CookieAuthenticationOptions options = optionsMonitor.Get( + DashboardAuthenticationDefaults.AuthenticationScheme); + + Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name); + Assert.True(options.Cookie.HttpOnly); + Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy); + Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite); + Assert.Equal("/", options.Cookie.Path); + Assert.Equal("/dashboard/login", options.LoginPath); + Assert.Equal("/dashboard/logout", options.LogoutPath); + Assert.Equal("/dashboard/denied", options.AccessDeniedPath); + } +} From 35e4442c7be82f167347f8c5dfe8a3b45c75eec0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 18:20:45 -0400 Subject: [PATCH 2/2] Build fake worker test harness --- docs/GatewayTesting.md | 51 +++ docs/gateway-process-design.md | 5 + ...ssionWorkerClientFactoryFakeWorkerTests.cs | 216 ++++++++++ .../Gateway/Workers/FakeWorkerHarnessTests.cs | 190 +++++++++ .../Workers/Fakes/FakeWorkerHarness.cs | 378 ++++++++++++++++++ 5 files changed, 840 insertions(+) create mode 100644 docs/GatewayTesting.md create mode 100644 src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md new file mode 100644 index 0000000..1e26fc7 --- /dev/null +++ b/docs/GatewayTesting.md @@ -0,0 +1,51 @@ +# Gateway Testing + +Gateway tests run without installed MXAccess by using fake workers, fake +transports, and in-process gRPC service fakes. Live MXAccess verification belongs +in opt-in integration tests because it depends on installed COM components and +provider state. + +## Fake Worker Harness + +`FakeWorkerHarness` in `src/MxGateway.Tests/Gateway/Workers/Fakes/` provides an +in-process worker side for named-pipe IPC tests. It uses the same +`WorkerFrameReader`, `WorkerFrameWriter`, and `WorkerEnvelope` contract as the +gateway so tests exercise real frame validation and worker-client state changes. + +Use the harness when a gateway or session test needs worker behavior without +starting `MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts: + +- `WorkerHello` and `WorkerReady` startup, +- command replies with matching correlation ids, +- ordered `WorkerEvent` frames, +- `WorkerFault` frames, +- shutdown acknowledgements, +- malformed protobuf payloads and oversized frame headers, +- slow or hung workers by withholding a reply. + +Session-level tests can connect the harness to the pipe created by +`SessionWorkerClientFactory` with `ConnectToGatewayPipeAsync`. Lower-level +`WorkerClient` tests can use `CreateConnectedPairAsync` to create both pipe ends +inside the test. + +## Focused Commands + +Run the fake worker tests after changing gateway worker IPC, session startup, or +event streaming behavior: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests +``` + +Run the gateway test project after shared gateway test infrastructure changes: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj +``` + +## Related Documentation + +- [Gateway Process Design](./gateway-process-design.md) +- [Worker Frame Protocol](./WorkerFrameProtocol.md) +- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md) diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index 8c0036f..8bcaef8 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -891,6 +891,11 @@ behavior unless an explicit non-parity backend is designed. Gateway tests should be able to run without installed MXAccess by using fake workers and fake transports. +Use `FakeWorkerHarness` for tests that need real gateway-to-worker framing, +handshake, command, event, fault, or malformed-protocol behavior without loading +MXAccess COM. See [Gateway Testing](./GatewayTesting.md) for the harness scope +and focused test commands. + Focused tests: - session state transitions, diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs b/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs new file mode 100644 index 0000000..625dd38 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs @@ -0,0 +1,216 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Configuration; +using MxGateway.Server.Metrics; +using MxGateway.Server.Sessions; +using MxGateway.Server.Workers; +using MxGateway.Tests.Gateway.Workers.Fakes; + +namespace MxGateway.Tests.Gateway.Sessions; + +public sealed class SessionWorkerClientFactoryFakeWorkerTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient() + { + ScriptedFakeWorkerProcessLauncher launcher = new(); + using GatewayMetrics metrics = new(); + SessionWorkerClientFactory factory = new( + launcher, + Options.Create(CreateOptions()), + metrics, + NullLoggerFactory.Instance); + GatewaySession session = CreateSession(); + + await using IWorkerClient workerClient = await factory.CreateAsync( + session, + CancellationToken.None); + + Assert.Equal(WorkerClientState.Ready, workerClient.State); + Assert.Equal(ScriptedFakeWorkerProcessLauncher.ProcessId, workerClient.ProcessId); + Assert.NotNull(launcher.Harness); + + Task invokeTask = workerClient.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + WorkerEnvelope commandEnvelope = await launcher.Harness.ReadCommandAsync(); + await launcher.Harness.ReplyToCommandAsync(commandEnvelope); + WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); + + Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); + Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code); + } + + [Fact] + public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException() + { + FailingStartupWorkerProcessLauncher launcher = new(); + using GatewayMetrics metrics = new(); + SessionWorkerClientFactory factory = new( + launcher, + Options.Create(CreateOptions()), + metrics, + NullLoggerFactory.Instance); + GatewaySession session = CreateSession(); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await factory.CreateAsync(session, CancellationToken.None).WaitAsync(TestTimeout)); + + Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode); + Assert.True(launcher.Process.IsDisposed); + } + + private static GatewayOptions CreateOptions() + { + return new GatewayOptions + { + Worker = new WorkerOptions + { + StartupTimeoutSeconds = 5, + ShutdownTimeoutSeconds = 5, + HeartbeatIntervalSeconds = 30, + HeartbeatGraceSeconds = 30, + MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + }, + Events = new EventOptions + { + QueueCapacity = 16, + }, + }; + } + + private static GatewaySession CreateSession() + { + return new GatewaySession( + FakeWorkerHarness.DefaultSessionId, + GatewayContractInfo.DefaultBackendName, + $"mxaccessgw-session-fake-worker-{Guid.NewGuid():N}", + FakeWorkerHarness.DefaultNonce, + "test-client", + "fake-worker-session-test", + "client-correlation-1", + TestTimeout, + TestTimeout, + TestTimeout, + DateTimeOffset.UtcNow); + } + + private static WorkerCommand CreateCommand(MxCommandKind kind) + { + return new WorkerCommand + { + Command = new MxCommand + { + Kind = kind, + }, + }; + } + + private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher + { + public const int ProcessId = 2468; + private readonly FakeWorkerProcess _process = new(ProcessId); + + public FakeWorkerHarness? Harness { get; private set; } + + public Task LaunchAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken = default) + { + _ = RunWorkerAsync(request, cancellationToken); + + return Task.FromResult(CreateHandle(_process)); + } + + private async Task RunWorkerAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken) + { + Harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync( + request.SessionId, + request.Nonce, + request.PipeName, + request.ProtocolVersion, + cancellationToken: cancellationToken).ConfigureAwait(false); + await Harness.CompleteStartupAsync(ProcessId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + private sealed class FailingStartupWorkerProcessLauncher : IWorkerProcessLauncher + { + public FakeWorkerProcess Process { get; } = new(processId: 3579); + + public Task LaunchAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken = default) + { + _ = RunWorkerAsync(request, cancellationToken); + + return Task.FromResult(CreateHandle(Process)); + } + + private async Task RunWorkerAsync( + WorkerProcessLaunchRequest request, + CancellationToken cancellationToken) + { + await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync( + request.SessionId, + request.Nonce, + request.PipeName, + request.ProtocolVersion, + cancellationToken: cancellationToken).ConfigureAwait(false); + _ = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + await harness.SendWorkerHelloAsync( + workerProcessId: Process.Id, + workerProtocolVersion: request.ProtocolVersion + 1, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + private static WorkerProcessHandle CreateHandle(IWorkerProcess process) + { + return new WorkerProcessHandle( + process, + new WorkerProcessCommandLine("fake-worker.exe", []), + DateTimeOffset.UtcNow); + } + + private sealed class FakeWorkerProcess(int processId) : IWorkerProcess + { + private bool _disposed; + + public int Id { get; } = processId; + + public bool HasExited { get; private set; } + + public int? ExitCode { get; private set; } + + public int KillCount { get; private set; } + + public ValueTask WaitForExitAsync(CancellationToken cancellationToken) + { + HasExited = true; + ExitCode = 0; + return ValueTask.CompletedTask; + } + + public void Kill(bool entireProcessTree) + { + KillCount++; + HasExited = true; + ExitCode = -1; + } + + public void Dispose() + { + _disposed = true; + } + + public bool IsDisposed => _disposed; + } +} diff --git a/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs b/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs new file mode 100644 index 0000000..b5daed4 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs @@ -0,0 +1,190 @@ +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Workers; +using MxGateway.Tests.Gateway.Workers.Fakes; + +namespace MxGateway.Tests.Gateway.Workers; + +public sealed class FakeWorkerHarnessTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + + Task startTask = client.StartAsync(CancellationToken.None); + WorkerEnvelope gatewayHello = await fakeWorker.CompleteStartupAsync(); + await startTask.WaitAsync(TestTimeout); + + Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase); + Assert.Equal(FakeWorkerHarness.DefaultNonce, gatewayHello.GatewayHello.Nonce); + Assert.Equal(WorkerClientState.Ready, client.State); + Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, client.ProcessId); + } + + [Fact] + public async Task StartAsync_WithProtocolMismatch_FailsStartup() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + + Task startTask = client.StartAsync(CancellationToken.None); + WorkerEnvelope gatewayHello = await fakeWorker.ReadGatewayEnvelopeAsync(); + Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase); + await fakeWorker.SendWorkerHelloAsync( + workerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion + 1); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await startTask.WaitAsync(TestTimeout)); + + Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode); + } + + [Fact] + public async Task InvokeAsync_WithScriptedReply_CompletesCommand() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + Task invokeTask = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TestTimeout, + CancellationToken.None); + WorkerEnvelope commandEnvelope = await fakeWorker.ReadCommandAsync(); + await fakeWorker.ReplyToCommandAsync(commandEnvelope); + + WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); + + Assert.Equal(commandEnvelope.CorrelationId, reply.Reply.CorrelationId); + Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); + Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code); + } + + [Fact] + public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + using CancellationTokenSource cancellationTokenSource = new(TestTimeout); + + await using IAsyncEnumerator events = + client.ReadEventsAsync(cancellationTokenSource.Token).GetAsyncEnumerator(cancellationTokenSource.Token); + + await fakeWorker.EmitEventAsync(MxEventFamily.OnDataChange, cancellationTokenSource.Token); + await fakeWorker.EmitEventAsync(MxEventFamily.OperationComplete, cancellationTokenSource.Token); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal((ulong)3, events.Current.Event.WorkerSequence); + Assert.Equal(MxEventFamily.OnDataChange, events.Current.Event.Family); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal((ulong)4, events.Current.Event.WorkerSequence); + Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family); + } + + [Fact] + public async Task ReadLoop_WithScriptedFault_FaultsClient() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + await fakeWorker.EmitFaultAsync( + WorkerFaultCategory.MxaccessCommandFailed, + "scripted MXAccess command fault"); + + await WaitUntilAsync( + () => client.State == WorkerClientState.Faulted, + TestTimeout); + + Assert.Equal(WorkerClientState.Faulted, client.State); + } + + [Fact] + public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + Task invokeTask = client.InvokeAsync( + CreateCommand(MxCommandKind.Ping), + TimeSpan.FromMilliseconds(50), + CancellationToken.None); + WorkerEnvelope commandEnvelope = await fakeWorker.ReadCommandAsync(); + + WorkerClientException exception = await Assert.ThrowsAsync( + async () => await invokeTask.WaitAsync(TestTimeout)); + + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); + Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode); + } + + [Fact] + public async Task ReadLoop_WithMalformedFrame_FaultsClient() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + await fakeWorker.WriteMalformedPayloadAsync(new byte[] { 0x08, 0x96, 0x01 }); + + await WaitUntilAsync( + () => client.State == WorkerClientState.Faulted, + TestTimeout); + + Assert.Equal(WorkerClientState.Faulted, client.State); + } + + [Fact] + public async Task ShutdownAsync_WithShutdownAck_ClosesClient() + { + await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync(); + await using WorkerClient client = fakeWorker.CreateClient(); + await StartClientAsync(fakeWorker, client); + + Task shutdownTask = client.ShutdownAsync(TestTimeout, CancellationToken.None); + WorkerEnvelope shutdownEnvelope = await fakeWorker.ReadShutdownAsync(); + await fakeWorker.SendShutdownAckAsync(); + await shutdownTask.WaitAsync(TestTimeout); + + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdown, shutdownEnvelope.BodyCase); + Assert.Equal(WorkerClientState.Closed, client.State); + } + + private static async Task StartClientAsync( + FakeWorkerHarness fakeWorker, + WorkerClient client) + { + Task startTask = client.StartAsync(CancellationToken.None); + await fakeWorker.CompleteStartupAsync().ConfigureAwait(false); + await startTask.WaitAsync(TestTimeout).ConfigureAwait(false); + } + + private static WorkerCommand CreateCommand(MxCommandKind kind) + { + return new WorkerCommand + { + Command = new MxCommand + { + Kind = kind, + }, + }; + } + + private static async Task WaitUntilAsync( + Func predicate, + TimeSpan timeout) + { + using CancellationTokenSource cancellationTokenSource = new(timeout); + while (!predicate()) + { + await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs b/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs new file mode 100644 index 0000000..dd501f1 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs @@ -0,0 +1,378 @@ +using System.Buffers.Binary; +using System.IO.Pipes; +using Google.Protobuf.WellKnownTypes; +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Metrics; +using MxGateway.Server.Workers; + +namespace MxGateway.Tests.Gateway.Workers.Fakes; + +public sealed class FakeWorkerHarness : IAsyncDisposable +{ + public const string DefaultSessionId = "session-fake-worker"; + public const string DefaultNonce = "nonce-fake-worker"; + public const int DefaultWorkerProcessId = 9321; + + private readonly NamedPipeServerStream? _gatewayStream; + private readonly NamedPipeClientStream _workerStream; + private readonly WorkerFrameProtocolOptions _frameOptions; + private readonly WorkerFrameReader _reader; + private readonly WorkerFrameWriter _writer; + private bool _workerSideDisposed; + + private FakeWorkerHarness( + string sessionId, + string nonce, + NamedPipeServerStream? gatewayStream, + NamedPipeClientStream workerStream, + WorkerFrameProtocolOptions frameOptions) + { + SessionId = sessionId; + Nonce = nonce; + _gatewayStream = gatewayStream; + _workerStream = workerStream; + _frameOptions = frameOptions; + _reader = new WorkerFrameReader(_workerStream, frameOptions); + _writer = new WorkerFrameWriter(_workerStream, frameOptions); + } + + public string SessionId { get; } + + public string Nonce { get; } + + public ulong NextWorkerSequence { get; private set; } + + public static async Task CreateConnectedPairAsync( + string sessionId = DefaultSessionId, + string nonce = DefaultNonce, + uint protocolVersion = GatewayContractInfo.WorkerProtocolVersion, + int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + CancellationToken cancellationToken = default) + { + string pipeName = $"mxaccessgw-fake-worker-{Guid.NewGuid():N}"; + NamedPipeServerStream gatewayStream = new( + pipeName, + PipeDirection.InOut, + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + NamedPipeClientStream workerStream = CreateWorkerStream(pipeName); + + Task waitForConnectionTask = gatewayStream.WaitForConnectionAsync(cancellationToken); + await workerStream.ConnectAsync(cancellationToken).ConfigureAwait(false); + await waitForConnectionTask.ConfigureAwait(false); + + return new FakeWorkerHarness( + sessionId, + nonce, + gatewayStream, + workerStream, + new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes)); + } + + public static async Task ConnectToGatewayPipeAsync( + string sessionId, + string nonce, + string pipeName, + uint protocolVersion = GatewayContractInfo.WorkerProtocolVersion, + int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, + CancellationToken cancellationToken = default) + { + NamedPipeClientStream workerStream = CreateWorkerStream(pipeName); + await workerStream.ConnectAsync(cancellationToken).ConfigureAwait(false); + + return new FakeWorkerHarness( + sessionId, + nonce, + gatewayStream: null, + workerStream, + new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes)); + } + + public WorkerClient CreateClient( + WorkerClientOptions? options = null, + GatewayMetrics? metrics = null, + TimeProvider? timeProvider = null) + { + if (_gatewayStream is null) + { + throw new InvalidOperationException("This fake worker is connected to a gateway-owned pipe."); + } + + WorkerClientConnection connection = new( + SessionId, + Nonce, + _gatewayStream, + _frameOptions); + + return new WorkerClient(connection, options, metrics, timeProvider); + } + + public async Task CompleteStartupAsync( + int workerProcessId = DefaultWorkerProcessId, + string workerVersion = "fake-worker", + string mxaccessProgid = "LMXProxy.LMXProxyServer.1", + string mxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", + CancellationToken cancellationToken = default) + { + WorkerEnvelope gatewayHello = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + if (gatewayHello.BodyCase != WorkerEnvelope.BodyOneofCase.GatewayHello) + { + throw new InvalidOperationException($"Expected GatewayHello but received {gatewayHello.BodyCase}."); + } + + await SendWorkerHelloAsync( + workerProcessId, + workerVersion, + cancellationToken: cancellationToken).ConfigureAwait(false); + await SendWorkerReadyAsync( + workerProcessId, + mxaccessProgid, + mxaccessClsid, + cancellationToken).ConfigureAwait(false); + + return gatewayHello; + } + + public async Task ReadGatewayEnvelopeAsync(CancellationToken cancellationToken = default) + { + return await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task ReadCommandAsync(CancellationToken cancellationToken = default) + { + WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + if (envelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand) + { + throw new InvalidOperationException($"Expected WorkerCommand but received {envelope.BodyCase}."); + } + + return envelope; + } + + public async Task ReadShutdownAsync(CancellationToken cancellationToken = default) + { + WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); + if (envelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerShutdown) + { + throw new InvalidOperationException($"Expected WorkerShutdown but received {envelope.BodyCase}."); + } + + return envelope; + } + + public async Task SendWorkerHelloAsync( + int workerProcessId = DefaultWorkerProcessId, + string workerVersion = "fake-worker", + uint? workerProtocolVersion = null, + string? nonce = null, + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerHello = new WorkerHello + { + ProtocolVersion = workerProtocolVersion ?? _frameOptions.ProtocolVersion, + Nonce = nonce ?? Nonce, + WorkerProcessId = workerProcessId, + WorkerVersion = workerVersion, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task SendWorkerReadyAsync( + int workerProcessId = DefaultWorkerProcessId, + string mxaccessProgid = "LMXProxy.LMXProxyServer.1", + string mxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerReady = new WorkerReady + { + WorkerProcessId = workerProcessId, + MxaccessProgid = mxaccessProgid, + MxaccessClsid = mxaccessClsid, + ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task ReplyToCommandAsync( + WorkerEnvelope commandEnvelope, + ProtocolStatusCode statusCode = ProtocolStatusCode.Ok, + string statusMessage = "OK", + CancellationToken cancellationToken = default) + { + if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand) + { + throw new ArgumentException("Command envelope must contain WorkerCommand.", nameof(commandEnvelope)); + } + + MxCommandKind kind = commandEnvelope.WorkerCommand.Command?.Kind ?? MxCommandKind.Unspecified; + await _writer.WriteAsync( + CreateEnvelope( + commandEnvelope.CorrelationId, + envelope => envelope.WorkerCommandReply = new WorkerCommandReply + { + Reply = new MxCommandReply + { + SessionId = SessionId, + CorrelationId = commandEnvelope.CorrelationId, + Kind = kind, + ProtocolStatus = new ProtocolStatus + { + Code = statusCode, + Message = statusMessage, + }, + }, + CompletedTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task EmitEventAsync( + MxEventFamily family, + CancellationToken cancellationToken = default) + { + ulong sequence = NextWorkerSequence + 1; + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerEvent = new WorkerEvent + { + Event = new MxEvent + { + SessionId = SessionId, + Family = family, + WorkerSequence = sequence, + WorkerTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task EmitFaultAsync( + WorkerFaultCategory category, + string diagnosticMessage, + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerFault = new WorkerFault + { + Category = category, + DiagnosticMessage = diagnosticMessage, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.WorkerUnavailable, + Message = diagnosticMessage, + }, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task SendShutdownAckAsync( + ProtocolStatusCode statusCode = ProtocolStatusCode.Ok, + CancellationToken cancellationToken = default) + { + await _writer.WriteAsync( + CreateEnvelope( + correlationId: string.Empty, + envelope => envelope.WorkerShutdownAck = new WorkerShutdownAck + { + Status = new ProtocolStatus + { + Code = statusCode, + Message = statusCode.ToString(), + }, + }), + cancellationToken).ConfigureAwait(false); + } + + public async Task WriteMalformedPayloadAsync( + ReadOnlyMemory payload, + CancellationToken cancellationToken = default) + { + if (payload.IsEmpty) + { + throw new ArgumentException("Malformed payload must include at least one byte.", nameof(payload)); + } + + byte[] lengthPrefix = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(lengthPrefix, (uint)payload.Length); + await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); + await _workerStream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + } + + public async Task WriteOversizedFrameHeaderAsync( + uint payloadLength, + CancellationToken cancellationToken = default) + { + if (payloadLength <= _frameOptions.MaxMessageBytes) + { + throw new ArgumentOutOfRangeException( + nameof(payloadLength), + payloadLength, + "Payload length must exceed the configured maximum."); + } + + byte[] lengthPrefix = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(lengthPrefix, payloadLength); + await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeWorkerSideAsync() + { + if (_workerSideDisposed) + { + return; + } + + await _workerStream.DisposeAsync().ConfigureAwait(false); + _workerSideDisposed = true; + } + + public async ValueTask DisposeAsync() + { + await DisposeWorkerSideAsync().ConfigureAwait(false); + if (_gatewayStream is not null) + { + await _gatewayStream.DisposeAsync().ConfigureAwait(false); + } + } + + private WorkerEnvelope CreateEnvelope( + string correlationId, + Action setBody) + { + WorkerEnvelope envelope = new() + { + ProtocolVersion = _frameOptions.ProtocolVersion, + SessionId = SessionId, + Sequence = AdvanceSequence(), + CorrelationId = correlationId, + }; + setBody(envelope); + + return envelope; + } + + private ulong AdvanceSequence() + { + return ++NextWorkerSequence; + } + + private static NamedPipeClientStream CreateWorkerStream(string pipeName) + { + return new NamedPipeClientStream( + ".", + pipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous); + } +}