380 lines
15 KiB
C#
380 lines
15 KiB
C#
using System.Text.Json;
|
|
using Google.Protobuf;
|
|
using MxGateway.Contracts;
|
|
using MxGateway.Contracts.Proto;
|
|
|
|
namespace MxGateway.Tests.Contracts;
|
|
|
|
public sealed class ClientBehaviorFixtureTests
|
|
{
|
|
private static readonly JsonParser ProtobufJsonParser = new(JsonParser.Settings.Default);
|
|
|
|
[Fact]
|
|
public void BehaviorManifest_DeclaresCurrentProtocolVersionsAndExistingFixtures()
|
|
{
|
|
using JsonDocument manifest = LoadBehaviorManifest();
|
|
JsonElement root = manifest.RootElement;
|
|
|
|
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
|
Assert.Equal("mxaccess-gateway-client-behavior", root.GetProperty("fixtureSet").GetString());
|
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32());
|
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32());
|
|
|
|
HashSet<string> fixtureIds = new(StringComparer.Ordinal);
|
|
foreach (JsonElement fixture in root.GetProperty("fixtures").EnumerateArray())
|
|
{
|
|
string id = fixture.GetProperty("id").GetString()!;
|
|
string path = fixture.GetProperty("path").GetString()!;
|
|
string category = fixture.GetProperty("category").GetString()!;
|
|
string messageType = fixture.GetProperty("messageType").GetString()!;
|
|
|
|
Assert.True(fixtureIds.Add(id), $"Duplicate behavior fixture id '{id}'.");
|
|
Assert.Contains(category, KnownCategories);
|
|
Assert.Contains(messageType, KnownMessageTypes);
|
|
Assert.True(
|
|
File.Exists(Path.Combine(GetBehaviorFixtureRoot().FullName, path)),
|
|
$"Expected behavior fixture '{path}' to exist.");
|
|
Assert.False(Path.IsPathRooted(path), $"Fixture path '{path}' must be relative.");
|
|
Assert.NotEmpty(fixture.GetProperty("expectation").GetString()!);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ProtoInputManifest_ReferencesBehaviorFixtureRoot()
|
|
{
|
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
|
string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json");
|
|
|
|
using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
|
|
string fixtureRoot = manifest.RootElement.GetProperty("behaviorFixtureRoot").GetString()!;
|
|
|
|
Assert.Equal("clients/proto/fixtures/behavior", fixtureRoot);
|
|
Assert.True(Directory.Exists(Path.Combine(repositoryRoot.FullName, fixtureRoot)));
|
|
}
|
|
|
|
[Fact]
|
|
public void CommandReplyFixtures_ParseWithCurrentContractAndPreserveMxAccessDetails()
|
|
{
|
|
IReadOnlyList<JsonElement> fixtures = LoadManifestFixtures("command_replies");
|
|
Assert.NotEmpty(fixtures);
|
|
|
|
foreach (JsonElement fixture in fixtures)
|
|
{
|
|
MxCommandReply reply = ParseFixture<MxCommandReply>(
|
|
fixture,
|
|
MxCommandReply.Parser);
|
|
|
|
Assert.NotEqual(MxCommandKind.Unspecified, reply.Kind);
|
|
Assert.NotEqual(ProtocolStatusCode.Unspecified, reply.ProtocolStatus.Code);
|
|
Assert.True(reply.HasHresult, $"Fixture '{GetFixtureId(fixture)}' must carry an HRESULT.");
|
|
Assert.NotEmpty(reply.Statuses);
|
|
Assert.NotEqual(MxDataType.Unspecified, reply.ReturnValue.DataType);
|
|
Assert.True(
|
|
reply.ReturnValue.KindCase != MxValue.KindOneofCase.None || reply.ReturnValue.IsNull,
|
|
$"Fixture '{GetFixtureId(fixture)}' must carry a typed value, raw value, or explicit null.");
|
|
}
|
|
|
|
MxCommandReply failedWrite = ParseFixture<MxCommandReply>(
|
|
Assert.Single(fixtures, fixture => GetFixtureId(fixture) == "command-reply.write.mxaccess-failure"),
|
|
MxCommandReply.Parser);
|
|
|
|
Assert.Equal(ProtocolStatusCode.MxaccessFailure, failedWrite.ProtocolStatus.Code);
|
|
Assert.Equal(-2147220992, failedWrite.Hresult);
|
|
Assert.True(failedWrite.Statuses.Count > 1);
|
|
Assert.All(failedWrite.Statuses, status => Assert.Equal(0, status.Success));
|
|
}
|
|
|
|
[Fact]
|
|
public void EventStreamFixtures_ParseWithMonotonicSequencesAndExpectedFamilies()
|
|
{
|
|
IReadOnlyList<JsonElement> fixtures = LoadManifestFixtures("event_streams");
|
|
Assert.NotEmpty(fixtures);
|
|
|
|
foreach (JsonElement fixture in fixtures)
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture)));
|
|
ulong previousSequence = 0;
|
|
List<MxEventFamily> families = [];
|
|
|
|
foreach (JsonElement eventElement in document.RootElement.GetProperty("events").EnumerateArray())
|
|
{
|
|
MxEvent gatewayEvent = ProtobufJsonParser.Parse<MxEvent>(eventElement.GetRawText());
|
|
|
|
Assert.True(gatewayEvent.WorkerSequence > previousSequence);
|
|
Assert.Equal(document.RootElement.GetProperty("sessionId").GetString(), gatewayEvent.SessionId);
|
|
Assert.NotEmpty(gatewayEvent.Statuses);
|
|
AssertEventBodyMatchesFamily(gatewayEvent);
|
|
|
|
previousSequence = gatewayEvent.WorkerSequence;
|
|
families.Add(gatewayEvent.Family);
|
|
}
|
|
|
|
Assert.Contains(MxEventFamily.OnDataChange, families);
|
|
Assert.Contains(MxEventFamily.OnWriteComplete, families);
|
|
Assert.Contains(MxEventFamily.OperationComplete, families);
|
|
Assert.Contains(MxEventFamily.OnBufferedDataChange, families);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ValueConversionFixtures_ParseTypedValuesAndRawFallbacks()
|
|
{
|
|
JsonElement cases = LoadCaseSet("value_conversion", "cases");
|
|
bool sawRawFallback = false;
|
|
bool sawRawArrayFallback = false;
|
|
bool sawTypedArray = false;
|
|
|
|
foreach (JsonElement valueCase in cases.EnumerateArray())
|
|
{
|
|
MxValue value = ProtobufJsonParser.Parse<MxValue>(
|
|
valueCase.GetProperty("value").GetRawText());
|
|
string expectedKind = valueCase.GetProperty("expectedKind").GetString()!;
|
|
|
|
Assert.NotEqual(MxDataType.Unspecified, value.DataType);
|
|
AssertJsonKindMatchesValueKind(expectedKind, value);
|
|
|
|
sawRawFallback |= value.KindCase == MxValue.KindOneofCase.RawValue
|
|
&& !string.IsNullOrWhiteSpace(value.RawDiagnostic)
|
|
&& value.RawDataType != 0;
|
|
sawRawArrayFallback |= value.KindCase == MxValue.KindOneofCase.ArrayValue
|
|
&& value.ArrayValue.ValuesCase == MxArray.ValuesOneofCase.RawValues
|
|
&& !string.IsNullOrWhiteSpace(value.ArrayValue.RawDiagnostic)
|
|
&& value.ArrayValue.RawElementDataType != 0;
|
|
sawTypedArray |= value.KindCase == MxValue.KindOneofCase.ArrayValue
|
|
&& value.ArrayValue.ValuesCase != MxArray.ValuesOneofCase.RawValues;
|
|
}
|
|
|
|
Assert.True(sawRawFallback, "Expected at least one raw scalar fallback case.");
|
|
Assert.True(sawRawArrayFallback, "Expected at least one raw array fallback case.");
|
|
Assert.True(sawTypedArray, "Expected at least one typed array case.");
|
|
}
|
|
|
|
[Fact]
|
|
public void StatusConversionFixtures_ParseStatusArraysAndRawFields()
|
|
{
|
|
JsonElement cases = LoadCaseSet("status_conversion", "cases");
|
|
bool sawRawUnknown = false;
|
|
|
|
foreach (JsonElement statusCase in cases.EnumerateArray())
|
|
{
|
|
MxStatusProxy status = ProtobufJsonParser.Parse<MxStatusProxy>(
|
|
statusCase.GetProperty("status").GetRawText());
|
|
|
|
Assert.NotEqual(MxStatusCategory.Unspecified, status.Category);
|
|
Assert.NotEqual(MxStatusSource.Unspecified, status.DetectedBy);
|
|
Assert.NotEmpty(status.DiagnosticText);
|
|
|
|
sawRawUnknown |= status.Category == MxStatusCategory.Unknown
|
|
&& status.RawCategory != 0
|
|
&& status.RawDetectedBy != 0;
|
|
}
|
|
|
|
Assert.True(sawRawUnknown, "Expected a status case with unknown raw native fields.");
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthErrorFixtures_MapAuthenticationAuthorizationAndRedactCredentials()
|
|
{
|
|
JsonElement cases = LoadCaseSet("auth_errors", "cases");
|
|
HashSet<string> statusCodes = new(StringComparer.Ordinal);
|
|
|
|
foreach (JsonElement authCase in cases.EnumerateArray())
|
|
{
|
|
string grpcStatusCode = authCase.GetProperty("grpcStatusCode").GetString()!;
|
|
string category = authCase.GetProperty("clientErrorCategory").GetString()!;
|
|
string redactedOutput = authCase.GetProperty("expectedRedactedOutput").GetString()!;
|
|
string serialized = authCase.GetRawText();
|
|
|
|
Assert.Contains(grpcStatusCode, AuthGrpcStatusCodes);
|
|
Assert.Contains(category, AuthClientErrorCategories);
|
|
string authorization = authCase.GetProperty("inputMetadata").GetProperty("authorization").GetString()!;
|
|
if (!string.IsNullOrEmpty(authorization))
|
|
{
|
|
Assert.Contains("<redacted>", serialized);
|
|
}
|
|
|
|
Assert.DoesNotContain("mxgw_", serialized, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("secret", redactedOutput, StringComparison.OrdinalIgnoreCase);
|
|
|
|
statusCodes.Add(grpcStatusCode);
|
|
}
|
|
|
|
Assert.Contains("UNAUTHENTICATED", statusCodes);
|
|
Assert.Contains("PERMISSION_DENIED", statusCodes);
|
|
}
|
|
|
|
[Fact]
|
|
public void TimeoutCancelFixtures_DocumentClientWaitAndWorkerCommandBehavior()
|
|
{
|
|
JsonElement cases = LoadCaseSet("timeout_cancel", "cases");
|
|
HashSet<string> statusCodes = new(StringComparer.Ordinal);
|
|
|
|
foreach (JsonElement timeoutCase in cases.EnumerateArray())
|
|
{
|
|
string grpcStatusCode = timeoutCase.GetProperty("grpcStatusCode").GetString()!;
|
|
|
|
Assert.Contains(grpcStatusCode, TimeoutGrpcStatusCodes);
|
|
Assert.NotEmpty(timeoutCase.GetProperty("clientDeadline").GetString()!);
|
|
Assert.NotEmpty(timeoutCase.GetProperty("gatewayWaitBehavior").GetString()!);
|
|
Assert.NotEmpty(timeoutCase.GetProperty("workerCommandBehavior").GetString()!);
|
|
Assert.NotEmpty(timeoutCase.GetProperty("expectedClientAction").GetString()!);
|
|
|
|
statusCodes.Add(grpcStatusCode);
|
|
}
|
|
|
|
Assert.Contains("DEADLINE_EXCEEDED", statusCodes);
|
|
Assert.Contains("CANCELLED", statusCodes);
|
|
}
|
|
|
|
private static readonly string[] KnownCategories =
|
|
[
|
|
"command_replies",
|
|
"event_streams",
|
|
"value_conversion",
|
|
"status_conversion",
|
|
"auth_errors",
|
|
"timeout_cancel",
|
|
];
|
|
|
|
private static readonly string[] KnownMessageTypes =
|
|
[
|
|
"mxaccess_gateway.v1.MxCommandReply",
|
|
"mxaccess_gateway.v1.MxEvent",
|
|
"mxaccess_gateway.v1.MxValue",
|
|
"mxaccess_gateway.v1.MxStatusProxy",
|
|
"client_behavior.v1.AuthErrorCase",
|
|
"client_behavior.v1.TimeoutCancelCase",
|
|
];
|
|
|
|
private static readonly string[] AuthGrpcStatusCodes =
|
|
[
|
|
"UNAUTHENTICATED",
|
|
"PERMISSION_DENIED",
|
|
];
|
|
|
|
private static readonly string[] AuthClientErrorCategories =
|
|
[
|
|
"AuthenticationError",
|
|
"AuthorizationError",
|
|
];
|
|
|
|
private static readonly string[] TimeoutGrpcStatusCodes =
|
|
[
|
|
"DEADLINE_EXCEEDED",
|
|
"CANCELLED",
|
|
];
|
|
|
|
private static T ParseFixture<T>(
|
|
JsonElement fixture,
|
|
MessageParser<T> parser)
|
|
where T : IMessage<T>
|
|
{
|
|
return parser.ParseJson(File.ReadAllText(GetFixturePath(fixture)));
|
|
}
|
|
|
|
private static JsonElement LoadCaseSet(
|
|
string category,
|
|
string propertyName)
|
|
{
|
|
JsonElement fixture = Assert.Single(LoadManifestFixtures(category));
|
|
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture)));
|
|
|
|
return document.RootElement.GetProperty(propertyName).Clone();
|
|
}
|
|
|
|
private static IReadOnlyList<JsonElement> LoadManifestFixtures(string category)
|
|
{
|
|
using JsonDocument manifest = LoadBehaviorManifest();
|
|
|
|
return manifest.RootElement
|
|
.GetProperty("fixtures")
|
|
.EnumerateArray()
|
|
.Where(fixture => fixture.GetProperty("category").GetString() == category)
|
|
.Select(fixture => fixture.Clone())
|
|
.ToArray();
|
|
}
|
|
|
|
private static JsonDocument LoadBehaviorManifest()
|
|
{
|
|
return JsonDocument.Parse(File.ReadAllText(Path.Combine(GetBehaviorFixtureRoot().FullName, "manifest.json")));
|
|
}
|
|
|
|
private static string GetFixturePath(JsonElement fixture)
|
|
{
|
|
return Path.Combine(GetBehaviorFixtureRoot().FullName, fixture.GetProperty("path").GetString()!);
|
|
}
|
|
|
|
private static string GetFixtureId(JsonElement fixture)
|
|
{
|
|
return fixture.GetProperty("id").GetString()!;
|
|
}
|
|
|
|
private static DirectoryInfo GetBehaviorFixtureRoot()
|
|
{
|
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
|
|
|
return new DirectoryInfo(Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "behavior"));
|
|
}
|
|
|
|
private static DirectoryInfo FindRepositoryRoot()
|
|
{
|
|
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
|
|
|
while (current is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
|
|
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
|
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
|
{
|
|
return current;
|
|
}
|
|
|
|
current = current.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory.");
|
|
}
|
|
|
|
private static void AssertEventBodyMatchesFamily(MxEvent gatewayEvent)
|
|
{
|
|
switch (gatewayEvent.Family)
|
|
{
|
|
case MxEventFamily.OnDataChange:
|
|
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase);
|
|
break;
|
|
case MxEventFamily.OnWriteComplete:
|
|
Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, gatewayEvent.BodyCase);
|
|
break;
|
|
case MxEventFamily.OperationComplete:
|
|
Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, gatewayEvent.BodyCase);
|
|
break;
|
|
case MxEventFamily.OnBufferedDataChange:
|
|
Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, gatewayEvent.BodyCase);
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"Unexpected event family '{gatewayEvent.Family}'.");
|
|
}
|
|
}
|
|
|
|
private static void AssertJsonKindMatchesValueKind(
|
|
string expectedKind,
|
|
MxValue value)
|
|
{
|
|
MxValue.KindOneofCase expected = expectedKind switch
|
|
{
|
|
"boolValue" => MxValue.KindOneofCase.BoolValue,
|
|
"int32Value" => MxValue.KindOneofCase.Int32Value,
|
|
"int64Value" => MxValue.KindOneofCase.Int64Value,
|
|
"floatValue" => MxValue.KindOneofCase.FloatValue,
|
|
"doubleValue" => MxValue.KindOneofCase.DoubleValue,
|
|
"stringValue" => MxValue.KindOneofCase.StringValue,
|
|
"timestampValue" => MxValue.KindOneofCase.TimestampValue,
|
|
"arrayValue" => MxValue.KindOneofCase.ArrayValue,
|
|
"rawValue" => MxValue.KindOneofCase.RawValue,
|
|
_ => throw new InvalidOperationException($"Unexpected expected value kind '{expectedKind}'."),
|
|
};
|
|
|
|
Assert.Equal(expected, value.KindCase);
|
|
}
|
|
}
|