diff --git a/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs b/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs new file mode 100644 index 0000000..3873a98 --- /dev/null +++ b/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Runtime.InteropServices; +using MxGateway.Contracts.Proto; +using MxGateway.Worker.Conversion; + +namespace MxGateway.Worker.Tests.Conversion; + +public sealed class HResultConverterTests +{ + private readonly HResultConverter _converter = new(); + + [Fact] + public void Convert_WithComException_CapturesExceptionHResult() + { + COMException exception = new("Sensitive provider text should not be copied.", unchecked((int)0x80070057)); + + HResultConversion converted = _converter.Convert(exception); + + Assert.Equal(unchecked((int)0x80070057), converted.HResult); + Assert.Equal(ProtocolStatusCode.MxaccessFailure, converted.ProtocolStatus.Code); + Assert.Contains("0x80070057", converted.ProtocolStatus.Message); + Assert.Contains(typeof(COMException).FullName!, converted.DiagnosticMessage); + Assert.DoesNotContain("Sensitive provider text", converted.DiagnosticMessage); + } + + [Fact] + public void CreateProtocolStatus_WithSuccessHResult_ReturnsOk() + { + ProtocolStatus status = _converter.CreateProtocolStatus(0); + + Assert.Equal(ProtocolStatusCode.Ok, status.Code); + Assert.Equal("HRESULT 0x00000000", status.Message); + } + + [Fact] + public void Convert_WithNonComException_CapturesExceptionHResult() + { + InvalidOperationException exception = new("do not include this"); + + HResultConversion converted = _converter.Convert(exception); + + Assert.Equal(exception.HResult, converted.HResult); + Assert.Equal(ProtocolStatusCode.MxaccessFailure, converted.ProtocolStatus.Code); + Assert.Contains("0x", converted.DiagnosticMessage); + Assert.DoesNotContain("do not include this", converted.DiagnosticMessage); + } +} diff --git a/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs b/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs new file mode 100644 index 0000000..da40034 --- /dev/null +++ b/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using MxGateway.Contracts.Proto; +using MxGateway.Worker.Conversion; + +namespace MxGateway.Worker.Tests.Conversion; + +public sealed class MxStatusProxyConverterTests +{ + private readonly MxStatusProxyConverter _converter = new(); + + [Fact] + public void Convert_WithStatusStruct_PreservesStatusFields() + { + FakeMxStatusProxy status = new() + { + success = 1, + category = 5, + detectedBy = 3, + detail = 21, + }; + + MxStatusProxy converted = _converter.Convert(status); + + Assert.Equal(1, converted.Success); + Assert.Equal(MxStatusCategory.OperationalError, converted.Category); + Assert.Equal(MxStatusSource.RespondingNmx, converted.DetectedBy); + Assert.Equal(21, converted.Detail); + Assert.Equal(5, converted.RawCategory); + Assert.Equal(3, converted.RawDetectedBy); + Assert.Equal("Invalid reference", converted.DiagnosticText); + } + + [Fact] + public void ConvertMany_WithStatusArray_DoesNotCollapseEntries() + { + FakeMxStatusProxy[] statuses = + [ + new() + { + success = 1, + category = 0, + detectedBy = 0, + detail = 0, + }, + new() + { + success = 0, + category = 6, + detectedBy = 5, + detail = 33, + }, + ]; + + IReadOnlyList converted = _converter.ConvertMany(statuses); + + Assert.Equal(2, converted.Count); + Assert.Equal(MxStatusCategory.Ok, converted[0].Category); + Assert.Equal(MxStatusCategory.SecurityError, converted[1].Category); + Assert.Equal(MxStatusSource.RespondingAutomationObject, converted[1].DetectedBy); + Assert.Equal("Write access denied", converted[1].DiagnosticText); + } + + [Fact] + public void Convert_WithUnknownCategoryAndSource_PreservesRawFields() + { + FakeMxStatusProxy status = new() + { + success = -1, + category = 99, + detectedBy = 42, + detail = 1234, + }; + + MxStatusProxy converted = _converter.Convert(status); + + Assert.Equal(-1, converted.Success); + Assert.Equal(MxStatusCategory.Unknown, converted.Category); + Assert.Equal(MxStatusSource.Unknown, converted.DetectedBy); + Assert.Equal(99, converted.RawCategory); + Assert.Equal(42, converted.RawDetectedBy); + Assert.Equal(1234, converted.Detail); + Assert.Equal(string.Empty, converted.DiagnosticText); + } + + [Fact] + public void PreserveCompletionOnlyStatusBytes_ReturnsRawHexMetadata() + { + string rawStatus = _converter.PreserveCompletionOnlyStatusBytes( + [0x00, 0x00, 0x50, 0x80, 0x00]); + + Assert.Equal("completion_only_status_hex=0000508000", rawStatus); + } + + [Fact] + public void Convert_WithMissingStatusField_ThrowsConversionException() + { + MxStatusConversionException exception = + Assert.Throws(() => _converter.Convert(new MissingFields())); + + Assert.Contains("success", exception.Message); + } + + public struct FakeMxStatusProxy + { + public short success; + + public int category; + + public int detectedBy; + + public short detail; + } + + private sealed class MissingFields + { + } +} diff --git a/src/MxGateway.Worker/Conversion/HResultConversion.cs b/src/MxGateway.Worker/Conversion/HResultConversion.cs new file mode 100644 index 0000000..fced01e --- /dev/null +++ b/src/MxGateway.Worker/Conversion/HResultConversion.cs @@ -0,0 +1,22 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Worker.Conversion; + +public sealed class HResultConversion +{ + public HResultConversion( + int hresult, + ProtocolStatus protocolStatus, + string diagnosticMessage) + { + HResult = hresult; + ProtocolStatus = protocolStatus; + DiagnosticMessage = diagnosticMessage; + } + + public int HResult { get; } + + public ProtocolStatus ProtocolStatus { get; } + + public string DiagnosticMessage { get; } +} diff --git a/src/MxGateway.Worker/Conversion/HResultConverter.cs b/src/MxGateway.Worker/Conversion/HResultConverter.cs new file mode 100644 index 0000000..cd8db53 --- /dev/null +++ b/src/MxGateway.Worker/Conversion/HResultConverter.cs @@ -0,0 +1,48 @@ +using System; +using System.Runtime.InteropServices; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Worker.Conversion; + +public sealed class HResultConverter +{ + public HResultConversion Convert(Exception exception) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + int hresult = exception is COMException comException + ? comException.ErrorCode + : exception.HResult; + + return new HResultConversion( + hresult, + CreateProtocolStatus(hresult, exception), + CreateSafeDiagnosticMessage(exception)); + } + + public ProtocolStatus CreateProtocolStatus( + int hresult, + Exception? exception = null) + { + return new ProtocolStatus + { + Code = hresult == 0 ? ProtocolStatusCode.Ok : ProtocolStatusCode.MxaccessFailure, + Message = exception is null + ? FormatHResult(hresult) + : $"{exception.GetType().Name}: {FormatHResult(hresult)}", + }; + } + + private static string CreateSafeDiagnosticMessage(Exception exception) + { + return $"{exception.GetType().FullName}: {FormatHResult(exception.HResult)}"; + } + + private static string FormatHResult(int hresult) + { + return $"HRESULT 0x{unchecked((uint)hresult):X8}"; + } +} diff --git a/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs b/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs new file mode 100644 index 0000000..d146eca --- /dev/null +++ b/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs @@ -0,0 +1,11 @@ +using System; + +namespace MxGateway.Worker.Conversion; + +public sealed class MxStatusConversionException : Exception +{ + public MxStatusConversionException(string message) + : base(message) + { + } +} diff --git a/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs b/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs new file mode 100644 index 0000000..086157e --- /dev/null +++ b/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; + +namespace MxGateway.Worker.Conversion; + +internal static class MxStatusDetailText +{ + private static readonly IReadOnlyDictionary KnownDetails = new Dictionary + { + [16] = "Request timed out", + [17] = "Platform communication error", + [18] = "Invalid platform ID", + [19] = "Invalid engine ID", + [20] = "Engine communication error", + [21] = "Invalid reference", + [22] = "No Galaxy Repository", + [23] = "Invalid object ID", + [24] = "Object signature mismatch", + [25] = "Invalid primitive ID", + [26] = "Invalid attribute ID", + [27] = "Invalid property ID", + [28] = "Index out of range", + [29] = "Data out of range", + [30] = "Incorrect data type", + [31] = "Attribute not readable", + [32] = "Attribute not writeable", + [33] = "Write access denied", + [34] = "Unknown error", + [36] = "Wrong data type", + [37] = "Wrong number of dimensions", + [38] = "Invalid index", + [39] = "Index out of order", + [40] = "Dimension does not exist", + [41] = "Conversion not supported", + [42] = "Unable to convert string", + [43] = "Overflow", + [44] = "Attribute signature mismatch", + [47] = "Nmx version mismatch", + [48] = "Nmx command not valid", + [49] = "Lmx version mismatch", + [50] = "Lmx command not valid", + [56] = "Secured Write", + [57] = "Verified Write", + [60] = "User did not have the necessary permissions to write", + [61] = "Verifier did not have the necessary permissions to verify", + [541] = "Conversion to intended data type is not supported", + [542] = "Unable to convert the input string to intended data type", + [8017] = "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification", + }; + + public static string Lookup(int detail) + { + return KnownDetails.TryGetValue(detail, out string text) ? text : string.Empty; + } +} diff --git a/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs b/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs new file mode 100644 index 0000000..fc6984f --- /dev/null +++ b/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Worker.Conversion; + +public sealed class MxStatusProxyConverter +{ + public MxStatusProxy Convert(object status) + { + if (status is null) + { + throw new ArgumentNullException(nameof(status)); + } + + Type statusType = status.GetType(); + int success = ReadInt32Field(status, statusType, "success"); + int rawCategory = ReadInt32Field(status, statusType, "category"); + int rawDetectedBy = ReadInt32Field(status, statusType, "detectedBy"); + int detail = ReadInt32Field(status, statusType, "detail"); + + return new MxStatusProxy + { + Success = success, + Category = MapCategory(rawCategory), + DetectedBy = MapSource(rawDetectedBy), + Detail = detail, + RawCategory = rawCategory, + RawDetectedBy = rawDetectedBy, + DiagnosticText = MxStatusDetailText.Lookup(detail), + }; + } + + public IReadOnlyList ConvertMany(Array? statuses) + { + if (statuses is null) + { + return Array.Empty(); + } + + List converted = new(statuses.Length); + foreach (object? status in statuses) + { + if (status is null) + { + converted.Add(new MxStatusProxy + { + Category = MxStatusCategory.Unknown, + DetectedBy = MxStatusSource.Unknown, + DiagnosticText = "Null MXSTATUS_PROXY entry.", + }); + continue; + } + + converted.Add(Convert(status)); + } + + return converted; + } + + public string PreserveCompletionOnlyStatusBytes(byte[] statusBytes) + { + if (statusBytes is null) + { + throw new ArgumentNullException(nameof(statusBytes)); + } + + return $"completion_only_status_hex={BitConverter.ToString(statusBytes).Replace("-", string.Empty)}"; + } + + private static int ReadInt32Field( + object value, + Type valueType, + string fieldName) + { + FieldInfo? field = valueType.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public); + if (field is null) + { + throw new MxStatusConversionException( + $"Status object type '{valueType.FullName}' does not expose required field '{fieldName}'."); + } + + object? fieldValue = field.GetValue(value); + if (fieldValue is null) + { + throw new MxStatusConversionException( + $"Status object field '{fieldName}' on type '{valueType.FullName}' is null."); + } + + return System.Convert.ToInt32(fieldValue, CultureInfo.InvariantCulture); + } + + private static MxStatusCategory MapCategory(int rawCategory) + { + return rawCategory switch + { + -1 => MxStatusCategory.Unknown, + 0 => MxStatusCategory.Ok, + 1 => MxStatusCategory.Pending, + 2 => MxStatusCategory.Warning, + 3 => MxStatusCategory.CommunicationError, + 4 => MxStatusCategory.ConfigurationError, + 5 => MxStatusCategory.OperationalError, + 6 => MxStatusCategory.SecurityError, + 7 => MxStatusCategory.SoftwareError, + 8 => MxStatusCategory.OtherError, + _ => MxStatusCategory.Unknown, + }; + } + + private static MxStatusSource MapSource(int rawDetectedBy) + { + return rawDetectedBy switch + { + -1 => MxStatusSource.Unknown, + 0 => MxStatusSource.RequestingLmx, + 1 => MxStatusSource.RespondingLmx, + 2 => MxStatusSource.RequestingNmx, + 3 => MxStatusSource.RespondingNmx, + 4 => MxStatusSource.RequestingAutomationObject, + 5 => MxStatusSource.RespondingAutomationObject, + _ => MxStatusSource.Unknown, + }; + } +}