Merge remote-tracking branch 'origin/main' into agent-3/issue-4-add-structured-logging-and-metrics-foundation

# Conflicts:
#	src/MxGateway.Server/GatewayApplication.cs
#	src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs
This commit is contained in:
Joseph Doherty
2026-04-26 16:17:22 -04:00
34 changed files with 24345 additions and 1 deletions
@@ -0,0 +1,119 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
namespace MxGateway.Tests.Configuration;
public sealed class GatewayOptionsTests
{
[Fact]
public void OptionsBinding_UsesDesignDefaults()
{
GatewayOptions options = BindOptions(new Dictionary<string, string?>());
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
Assert.Equal(@"C:\ProgramData\MxGateway\gateway-auth.db", options.Authentication.SqlitePath);
Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName);
Assert.True(options.Authentication.RunMigrationsOnStartup);
Assert.Equal(@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe", options.Worker.ExecutablePath);
Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture);
Assert.Equal(30, options.Worker.StartupTimeoutSeconds);
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds);
Assert.Equal(15, options.Worker.HeartbeatGraceSeconds);
Assert.Equal(16 * 1024 * 1024, options.Worker.MaxMessageBytes);
Assert.Equal(30, options.Sessions.DefaultCommandTimeoutSeconds);
Assert.Equal(64, options.Sessions.MaxSessions);
Assert.False(options.Sessions.AllowMultipleEventSubscribers);
Assert.Equal(10_000, options.Events.QueueCapacity);
Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy);
Assert.True(options.Dashboard.Enabled);
Assert.Equal("/dashboard", options.Dashboard.PathBase);
Assert.True(options.Dashboard.RequireAdminScope);
Assert.False(options.Dashboard.AllowAnonymousLocalhost);
Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds);
Assert.Equal(100, options.Dashboard.RecentFaultLimit);
Assert.Equal(200, options.Dashboard.RecentSessionLimit);
Assert.False(options.Dashboard.ShowTagValues);
Assert.Equal(1u, options.Protocol.WorkerProtocolVersion);
}
[Fact]
public void OptionsBinding_AppliesConfigurationOverrides()
{
GatewayOptions options = BindOptions(
new Dictionary<string, string?>
{
["MxGateway:Authentication:Mode"] = "Disabled",
["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\MxGateway.Worker.exe",
["MxGateway:Sessions:MaxSessions"] = "12",
["MxGateway:Events:QueueCapacity"] = "256",
["MxGateway:Dashboard:Enabled"] = "false"
});
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
Assert.Equal(@"C:\Gateway\MxGateway.Worker.exe", options.Worker.ExecutablePath);
Assert.Equal(12, options.Sessions.MaxSessions);
Assert.Equal(256, options.Events.QueueCapacity);
Assert.False(options.Dashboard.Enabled);
}
[Theory]
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
[InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")]
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
{
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
_ = BindOptions(new Dictionary<string, string?> { [key] = value }));
Assert.Contains(exception.Failures, failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
}
[Fact]
public void EffectiveConfiguration_RedactsPepperSecretName()
{
using ServiceProvider services = BuildServices(
new Dictionary<string, string?>
{
["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName"
});
IGatewayConfigurationProvider provider = services.GetRequiredService<IGatewayConfigurationProvider>();
EffectiveGatewayConfiguration configuration = provider.GetEffectiveConfiguration();
Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Authentication.PepperSecretName);
Assert.DoesNotContain(
"RawPepperSecretName",
System.Text.Json.JsonSerializer.Serialize(configuration),
StringComparison.Ordinal);
}
private static GatewayOptions BindOptions(IReadOnlyDictionary<string, string?> configurationValues)
{
using ServiceProvider services = BuildServices(configurationValues);
return services.GetRequiredService<IOptions<GatewayOptions>>().Value;
}
private static ServiceProvider BuildServices(IReadOnlyDictionary<string, string?> configurationValues)
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configurationValues)
.Build();
ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration();
return services.BuildServiceProvider(validateScopes: true);
}
}
@@ -0,0 +1,195 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
namespace MxGateway.Tests.Contracts;
public sealed class ProtobufContractRoundTripTests
{
[Fact]
public void GatewayDescriptor_ContainsInitialPublicServiceMethods()
{
var service = Assert.Single(
MxaccessGatewayReflection.Descriptor.Services,
descriptor => descriptor.Name == "MxAccessGateway");
Assert.Contains(service.Methods, method => method.Name == "OpenSession");
Assert.Contains(service.Methods, method => method.Name == "CloseSession");
Assert.Contains(service.Methods, method => method.Name == "Invoke");
Assert.Contains(service.Methods, method => method.Name == "StreamEvents");
}
[Fact]
public void WorkerEnvelopeDescriptor_ContainsRequiredCorrelationFields()
{
var fields = WorkerEnvelope.Descriptor.Fields.InDeclarationOrder();
Assert.Contains(fields, field => field.Name == "protocol_version");
Assert.Contains(fields, field => field.Name == "session_id");
Assert.Contains(fields, field => field.Name == "sequence");
Assert.Contains(fields, field => field.Name == "correlation_id");
}
[Fact]
public void CommandRequest_RoundTripsMethodSpecificPayload()
{
var original = new MxCommandRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-1",
Command = new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand
{
ClientName = "mxaccessgw-test-client",
},
},
};
var parsed = MxCommandRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.Register, parsed.Command.PayloadCase);
}
[Fact]
public void CommandReply_RoundTripsHResultReturnValueOutParamsAndStatuses()
{
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-1",
Kind = MxCommandKind.AddItem,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
},
Hresult = 0,
ReturnValue = new MxValue
{
DataType = MxDataType.Integer,
Int32Value = 1234,
VariantType = "VT_I4",
},
AddItem = new AddItemReply
{
ItemHandle = 1234,
},
};
original.Statuses.Add(new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingLmx,
Detail = 0,
});
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.True(parsed.HasHresult);
Assert.Equal(MxCommandReply.PayloadOneofCase.AddItem, parsed.PayloadCase);
Assert.Single(parsed.Statuses);
}
[Fact]
public void Event_RoundTripsValueStatusSequenceAndBufferedBody()
{
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 4, 26, 20, 0, 0, DateTimeKind.Utc));
var original = new MxEvent
{
Family = MxEventFamily.OnBufferedDataChange,
SessionId = "session-1",
ServerHandle = 10,
ItemHandle = 20,
Value = new MxValue
{
DataType = MxDataType.Float,
ArrayValue = new MxArray
{
ElementDataType = MxDataType.Float,
FloatValues = new FloatArray
{
Values = { 1.5f, 2.5f },
},
Dimensions = { 2 },
VariantType = "VT_ARRAY|VT_R4",
},
},
Quality = 192,
SourceTimestamp = timestamp,
WorkerSequence = 42,
WorkerTimestamp = timestamp,
GatewayReceiveTimestamp = timestamp,
OnBufferedDataChange = new OnBufferedDataChangeEvent
{
DataType = MxDataType.Float,
QualityValues = new MxArray
{
ElementDataType = MxDataType.Integer,
Int32Values = new Int32Array
{
Values = { 192, 192 },
},
Dimensions = { 2 },
},
TimestampValues = new MxArray
{
ElementDataType = MxDataType.Time,
TimestampValues = new TimestampArray
{
Values = { timestamp, timestamp },
},
Dimensions = { 2 },
},
},
};
original.Statuses.Add(new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingNmx,
Detail = 0,
});
var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, parsed.BodyCase);
Assert.Single(parsed.Statuses);
}
[Fact]
public void WorkerEnvelope_RoundTripsProtocolFieldsAndCommandBody()
{
var original = new WorkerEnvelope
{
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
SessionId = "session-1",
Sequence = 7,
CorrelationId = "gateway-correlation-1",
WorkerCommand = new WorkerCommand
{
EnqueueTimestamp = Timestamp.FromDateTime(
new DateTime(2026, 4, 26, 20, 5, 0, DateTimeKind.Utc)),
Command = new MxCommand
{
Kind = MxCommandKind.Advise,
Advise = new AdviseCommand
{
ServerHandle = 10,
ItemHandle = 20,
},
},
},
};
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, parsed.BodyCase);
Assert.Equal(MxCommand.PayloadOneofCase.Advise, parsed.WorkerCommand.Command.PayloadCase);
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MxGateway.Server;
using MxGateway.Server.Metrics;
@@ -31,4 +32,37 @@ public sealed class GatewayApplicationTests
Assert.NotNull(metrics);
}
[Theory]
[InlineData(
"MxGateway:Worker:ExecutablePath",
"worker.dll",
"MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData(
"MxGateway:Events:QueueCapacity",
"0",
"MxGateway:Events:QueueCapacity must be greater than zero.")]
[InlineData(
"MxGateway:Authentication:PepperSecretName",
"",
"MxGateway:Authentication:PepperSecretName is required")]
[InlineData(
"MxGateway:Dashboard:PathBase",
"dashboard",
"MxGateway:Dashboard:PathBase must start with '/'.")]
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
string key,
string value,
string expectedFailure)
{
await using WebApplication app = GatewayApplication.Build(
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
OptionsValidationException exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => app.StartAsync());
Assert.Contains(
exception.Failures,
failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
}
}