Merge remote-tracking branch 'origin/main' into agent-2/issue-27-implement-additem-additem2-removeitem
This commit is contained in:
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -663,6 +663,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`
|
||||
@@ -881,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<GatewayOptions> options) : IDashboardAuthenticator
|
||||
{
|
||||
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
|
||||
|
||||
public async Task<DashboardAuthenticationResult> 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<string>([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<Claim> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
|
||||
@@ -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<IConfiguration>();
|
||||
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", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
|
||||
"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 = $"""
|
||||
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/logout")}" class="mb-3">
|
||||
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
<dl>
|
||||
<dt>Open sessions</dt>
|
||||
<dd>{snapshot.Sessions.Count}</dd>
|
||||
<dt>Workers</dt>
|
||||
<dd>{snapshot.Workers.Count}</dd>
|
||||
<dt>Faults</dt>
|
||||
<dd>{snapshot.Faults.Count}</dd>
|
||||
</dl>
|
||||
""";
|
||||
|
||||
return TypedResults.Content(RenderPage("MXAccess Gateway Dashboard", body), "text/html");
|
||||
}
|
||||
|
||||
private static Task<ContentHttpResult> 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<IResult> 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<IResult> 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
|
||||
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||
|
||||
string body = $"""
|
||||
{alert}
|
||||
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}">
|
||||
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
||||
<label for="apiKey">API key</label>
|
||||
<input id="apiKey" name="apiKey" type="password" autocomplete="off" />
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
""";
|
||||
|
||||
return RenderPage("Dashboard Sign In", body);
|
||||
}
|
||||
|
||||
private static string RenderPage(string title, string body)
|
||||
{
|
||||
return $"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAntiforgery();
|
||||
services
|
||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(
|
||||
DashboardAuthenticationDefaults.AuthorizationPolicy,
|
||||
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
|
||||
});
|
||||
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureCookieOptions(
|
||||
CookieAuthenticationOptions cookieOptions,
|
||||
IOptions<GatewayOptions> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public interface IDashboardAuthenticator
|
||||
{
|
||||
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? apiKey,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ public static class GatewayApplication
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseGatewayRequestLoggingScope();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapGatewayEndpoints();
|
||||
|
||||
return app;
|
||||
@@ -56,6 +58,7 @@ public static class GatewayApplication
|
||||
.WithName("LiveHealth");
|
||||
|
||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||
endpoints.MapGatewayDashboard();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -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<string>(scopes, StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
||||
{
|
||||
public string? LastAuthorizationHeader { get; private set; }
|
||||
|
||||
public Task<ApiKeyVerificationResult> VerifyAsync(
|
||||
string? authorizationHeader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastAuthorizationHeader = authorizationHeader;
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AuthorizationHandlerContext> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<WorkerCommandReply> 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<WorkerClientException>(
|
||||
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<WorkerProcessHandle> 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<WorkerProcessHandle> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<WorkerClientException>(
|
||||
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<WorkerCommandReply> 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<WorkerEvent> 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<WorkerCommandReply> invokeTask = client.InvokeAsync(
|
||||
CreateCommand(MxCommandKind.Ping),
|
||||
TimeSpan.FromMilliseconds(50),
|
||||
CancellationToken.None);
|
||||
WorkerEnvelope commandEnvelope = await fakeWorker.ReadCommandAsync();
|
||||
|
||||
WorkerClientException exception = await Assert.ThrowsAsync<WorkerClientException>(
|
||||
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<bool> predicate,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
using CancellationTokenSource cancellationTokenSource = new(timeout);
|
||||
while (!predicate())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FakeWorkerHarness> 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<FakeWorkerHarness> 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<WorkerEnvelope> 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<WorkerEnvelope> ReadGatewayEnvelopeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<WorkerEnvelope> 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<WorkerEnvelope> 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<byte> 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<WorkerEnvelope> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user