File: Backchannel\BackchannelContractTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.Diagnostics;
using Aspire.Tests;
using Microsoft.Extensions.Configuration;
using StreamJsonRpc;
 
namespace Aspire.Hosting.Backchannel;
 
/// <summary>
/// Validates that backchannel request/response types follow the contract rules.
/// </summary>
[Trait("Partition", "4")]
public class BackchannelContractTests
{
    private static readonly Type[] s_requestTypes =
    [
        typeof(GetCapabilitiesRequest),
        typeof(GetAppHostInfoRequest),
        typeof(GetDashboardInfoRequest),
        typeof(GetResourcesRequest),
        typeof(WatchResourcesRequest),
        typeof(GetConsoleLogsRequest),
        typeof(CallMcpToolRequest),
        typeof(StopAppHostRequest),
        typeof(ExecuteResourceCommandRequest),
        typeof(WaitForResourceRequest),
        typeof(GetPipelineStepsRequest),
        typeof(GetTerminalInfoRequest),
        typeof(ListTerminalsRequest),
    ];
 
    // V2 request/response types that must follow the contract
    private static readonly Type[] s_contractTypes =
    [
        .. s_requestTypes,
        typeof(GetCapabilitiesResponse),
        typeof(BackchannelTraceContext),
        typeof(GetAppHostInfoResponse),
        typeof(GetDashboardInfoResponse),
        typeof(GetResourcesResponse),
        typeof(CallMcpToolResponse),
        typeof(McpToolContentItem),
        typeof(StopAppHostResponse),
        typeof(ExecuteResourceCommandResponse),
        typeof(WaitForResourceResponse),
        typeof(GetPipelineStepsResponse),
        typeof(GetTerminalInfoResponse),
        typeof(TerminalReplicaInfo),
        typeof(TerminalPeerInfo),
        typeof(ListTerminalsResponse),
        typeof(TerminalSummary),
        typeof(ResourceSnapshot),
        typeof(ResourceSnapshotUrl),
        typeof(ResourceSnapshotUrlDisplayProperties),
        typeof(ResourceSnapshotRelationship),
        typeof(ResourceSnapshotHealthReport),
        typeof(ResourceSnapshotVolume),
        typeof(ResourceSnapshotEnvironmentVariable),
        typeof(ResourceSnapshotMcpServer),
        typeof(ResourceLogLine),
        typeof(ResourceLogBatch),
    ];
 
    /// <summary>
    /// Validates all backchannel contract rules:
    /// 1. All types are sealed classes
    /// 2. Properties use { get; init; } pattern (not { get; set; })
    /// 3. Required properties have 'required' modifier and are not nullable
    /// 4. Optional properties are nullable (T?) or have default values
    /// 5. No public fields allowed
    /// 6. Request/Response types follow naming convention
    /// </summary>
    [Fact]
    public void BackchannelTypes_FollowContractRules()
    {
        var errors = new StringBuilder();
 
        foreach (var type in s_contractTypes)
        {
            // Rule 1: Must be sealed class
            if (!type.IsClass)
            {
                errors.AppendLine($"{type.Name}: Must be a class (not struct or interface)");
            }
            else if (!type.IsSealed)
            {
                errors.AppendLine($"{type.Name}: Must be sealed");
            }
 
            // Rule 5: No public fields
            foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
            {
                errors.AppendLine($"{type.Name}.{field.Name}: Public fields not allowed, use properties");
            }
 
            // Rule 6: Naming convention (skip helper types)
            if (!type.Name.StartsWith("ResourceSnapshot") &&
                type != typeof(BackchannelTraceContext) &&
                type.Name != "McpToolContentItem" &&
                type.Name != "ResourceLogLine" &&
                type.Name != "ResourceLogBatch" &&
                type.Name != "TerminalReplicaInfo" &&
                type.Name != "TerminalPeerInfo" &&
                type.Name != "TerminalSummary")
            {
                if (!type.Name.EndsWith("Request") && !type.Name.EndsWith("Response"))
                {
                    errors.AppendLine($"{type.Name}: Name should end with 'Request' or 'Response'");
                }
            }
 
            foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
            {
                var setMethod = prop.GetSetMethod();
 
                // Skip computed properties (no setter)
                if (setMethod is null)
                {
                    continue;
                }
 
                // Rule 2: Must use { get; init; } not { get; set; }
                var isInitOnly = setMethod.ReturnParameter
                    .GetRequiredCustomModifiers()
                    .Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit");
 
                if (!isInitOnly)
                {
                    errors.AppendLine($"{type.Name}.{prop.Name}: Must use {{ get; init; }} not {{ get; set; }}");
                }
 
                var isRequired = prop.GetCustomAttribute<System.Runtime.CompilerServices.RequiredMemberAttribute>() is not null;
                var nullabilityContext = new NullabilityInfoContext();
                var nullabilityInfo = nullabilityContext.Create(prop);
 
                if (isRequired)
                {
                    // Rule 3: Required properties should not be nullable
                    bool isNullable = prop.PropertyType.IsValueType
                        ? Nullable.GetUnderlyingType(prop.PropertyType) is not null
                        : nullabilityInfo.WriteState == NullabilityState.Nullable;
 
                    if (isNullable)
                    {
                        errors.AppendLine($"{type.Name}.{prop.Name}: Required properties should not be nullable");
                    }
                }
                else
                {
                    // Rule 4: Optional reference types should be nullable or have defaults
                    if (!prop.PropertyType.IsValueType)
                    {
                        var isNullable = nullabilityInfo.WriteState == NullabilityState.Nullable;
                        var isCollectionWithDefault = prop.PropertyType.IsArray ||
                            (prop.PropertyType.IsGenericType && IsAllowedCollectionType(prop.PropertyType));
 
                        if (!isNullable && !isCollectionWithDefault)
                        {
                            errors.AppendLine($"{type.Name}.{prop.Name}: Optional properties should be nullable (T?) or have a default");
                        }
                    }
                }
            }
 
            if (s_requestTypes.Contains(type) &&
                !typeof(BackchannelRequest).IsAssignableFrom(type))
            {
                errors.AppendLine($"{type.Name}: Requests must derive from {nameof(BackchannelRequest)} so profiling propagation stays AOT-safe.");
            }
        }
 
        Assert.True(errors.Length == 0, $"Contract violations found:\n{errors}");
    }
 
    [Fact]
    public void RequestWithTraceContext_PreservesRequestProperties()
    {
        var errors = new StringBuilder();
        var traceContext = new BackchannelTraceContext
        {
            Baggage = new()
            {
                ["aspire.profiling.session_id"] = "new-session"
            }
        };
 
        foreach (var requestType in s_requestTypes)
        {
            var request = (BackchannelRequest)Activator.CreateInstance(requestType)!;
            var defaultRequest = (BackchannelRequest)Activator.CreateInstance(requestType)!;
            var expectedValues = new Dictionary<PropertyInfo, object?>();
 
            foreach (var property in requestType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
            {
                if (property.GetSetMethod() is null)
                {
                    continue;
                }
 
                var value = property.Name == nameof(BackchannelRequest.TraceContext)
                    ? new BackchannelTraceContext
                    {
                        Baggage = new()
                        {
                            ["aspire.profiling.session_id"] = "original-session"
                        }
                    }
                    : CreateNonDefaultValue(requestType, property, property.GetValue(defaultRequest));
 
                property.SetValue(request, value);
                expectedValues.Add(property, value);
            }
 
            var copy = request.WithTraceContext(traceContext);
 
            if (copy.GetType() != requestType)
            {
                errors.AppendLine($"ERROR {requestType.Name}: {nameof(BackchannelRequest.WithTraceContext)} returned {copy.GetType().Name}");
                continue;
            }
 
            foreach (var (property, originalValue) in expectedValues)
            {
                var expectedValue = property.Name == nameof(BackchannelRequest.TraceContext)
                    ? traceContext
                    : originalValue;
                var actualValue = property.GetValue(copy);
 
                if (!PropertyValuesEqual(expectedValue, actualValue))
                {
                    errors.AppendLine($"ERROR {requestType.Name}.{property.Name}: Expected {FormatValue(expectedValue)}, actual {FormatValue(actualValue)}");
                }
            }
        }
 
        Assert.True(errors.Length == 0, $"Trace context copy violations found:\n{errors}");
    }
 
    [Fact]
    public void ActivityTracingStrategy_PropagatesW3CTraceContextOnJsonRpcRequest()
    {
        using var source = new ActivitySource("test-json-rpc-trace");
        using var listener = ActivityListenerHelper.Create(source);
        using var clientActivity = source.StartActivity("client", ActivityKind.Client);
        Assert.NotNull(clientActivity);
 
        var formatter = new SystemTextJsonFormatter();
        var request = ((IJsonRpcMessageFactory)formatter).CreateRequestMessage();
        request.Method = "GetCapabilitiesAsync";
        request.Arguments = Array.Empty<object>();
 
        var strategy = new ActivityTracingStrategy(source);
        strategy.ApplyOutboundActivity(request);
 
        Assert.NotNull(request.TraceParent);
        using (strategy.ApplyInboundActivity(request))
        {
            Assert.NotNull(Activity.Current);
            Assert.Equal(clientActivity.TraceId, Activity.Current.TraceId);
            Assert.Equal(clientActivity.SpanId, Activity.Current.ParentSpanId);
        }
    }
 
    [Fact]
    public void JsonRpcServerCall_RestoresTraceContextBaggage()
    {
        Activity? startedActivity = null;
        var telemetry = new ProfilingTelemetry(CreateConfiguration(
            (KnownConfigNames.ProfilingEnabled, "true")));
        using var listener = ActivityListenerHelper.Create(ProfilingTelemetry.ActivitySource, onActivityStarted: activity => startedActivity = activity);
 
        using var activity = telemetry.StartJsonRpcServerCall(
            "GetCapabilitiesAsync",
            streaming: false,
            new BackchannelTraceContext
            {
                Baggage = new()
                {
                    [ProfilingTelemetry.Tags.ProfilingSessionId] = "session-1",
                    ["custom"] = "value"
                }
            });
 
        Assert.NotNull(startedActivity);
        Assert.Equal("session-1", startedActivity.GetBaggageItem(ProfilingTelemetry.Tags.ProfilingSessionId));
        Assert.Equal("value", startedActivity.GetBaggageItem("custom"));
        Assert.Equal("session-1", startedActivity.GetTagItem(ProfilingTelemetry.Tags.ProfilingSessionId));
    }
 
    [Fact]
    public void DcpRunApplication_UsesConfiguredProfilingParentWhenAmbientActivityIsNotProfiling()
    {
        var activities = new List<Activity>();
        using var profilingListener = ActivityListenerHelper.Create(ProfilingTelemetry.ActivitySource, onActivityStarted: activities.Add);
        using var processSource = new ActivitySource("test.process");
        using var processListener = ActivityListenerHelper.Create(processSource);
        using var processActivity = processSource.StartActivity("process npx.CMD", ActivityKind.Internal);
        Assert.NotNull(processActivity);
        var traceParent = processActivity.Id;
        Assert.NotNull(traceParent);
 
        processActivity.Stop();
 
        using var ambientSource = new ActivitySource("test.ambient");
        using var ambientListener = ActivityListenerHelper.Create(ambientSource);
        using var ambientActivity = ambientSource.StartActivity("hidden ambient", ActivityKind.Internal);
        Assert.NotNull(ambientActivity);
 
        var configuration = CreateConfiguration(
            (KnownConfigNames.ProfilingEnabled, "true"),
            (KnownConfigNames.ProfilingSessionId, "session-1"),
            (KnownConfigNames.ProfilingTraceParent, traceParent));
 
        using var activity = ProfilingTelemetry.StartDcpRunApplication(configuration, resourceCount: 1);
 
        var dcpActivity = Assert.Single(activities, activity => activity.OperationName == ProfilingTelemetry.Activities.DcpRunApplication);
        Assert.Equal(processActivity.TraceId, dcpActivity.TraceId);
        Assert.Equal(processActivity.SpanId, dcpActivity.ParentSpanId);
    }
 
    private static bool IsAllowedCollectionType(Type type)
    {
        var genericDef = type.GetGenericTypeDefinition();
        return genericDef == typeof(Dictionary<,>) ||
               genericDef == typeof(List<>) ||
               genericDef == typeof(IReadOnlyList<>) ||
               genericDef == typeof(IReadOnlyDictionary<,>);
    }
 
    private static object CreateNonDefaultValue(Type requestType, PropertyInfo property, object? defaultValue)
    {
        var propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
        var propertyName = $"{requestType.Name}.{property.Name}";
 
        if (propertyType == typeof(string))
        {
            return propertyName;
        }
 
        if (property.PropertyType == typeof(string[]))
        {
            return new[] { propertyName };
        }
 
        if (propertyType == typeof(bool))
        {
            return defaultValue is bool value ? !value : true;
        }
 
        if (propertyType == typeof(int))
        {
            return defaultValue is 42 ? 43 : 42;
        }
 
        if (propertyType == typeof(JsonElement))
        {
            using var document = JsonDocument.Parse($$"""{ "property": "{{propertyName}}" }""");
            return document.RootElement.Clone();
        }
 
        if (propertyType == typeof(JsonNode))
        {
            return JsonNode.Parse($$"""{ "property": "{{propertyName}}" }""")!;
        }
 
        if (property.PropertyType == typeof(Dictionary<string, string>))
        {
            return new Dictionary<string, string> { ["property"] = propertyName };
        }
 
        throw new NotSupportedException($"{requestType.Name}.{property.Name} has unsupported test value type {property.PropertyType}.");
    }
 
    private static bool PropertyValuesEqual(object? expected, object? actual)
    {
        if (expected is JsonElement expectedJson && actual is JsonElement actualJson)
        {
            return expectedJson.ValueKind == actualJson.ValueKind &&
                   expectedJson.GetRawText() == actualJson.GetRawText();
        }
 
        if (expected is JsonNode expectedNode && actual is JsonNode actualNode)
        {
            return expectedNode.ToJsonString() == actualNode.ToJsonString();
        }
 
        if (expected is Dictionary<string, string> expectedDictionary && actual is Dictionary<string, string> actualDictionary)
        {
            return expectedDictionary.Count == actualDictionary.Count &&
                   expectedDictionary.All(item => actualDictionary.TryGetValue(item.Key, out var actualValue) && item.Value == actualValue);
        }
 
        if (expected is string[] expectedArray && actual is string[] actualArray)
        {
            return expectedArray.SequenceEqual(actualArray);
        }
 
        return Equals(expected, actual);
    }
 
    private static string FormatValue(object? value) =>
        value switch
        {
            null => "<null>",
            JsonElement json => json.GetRawText(),
            JsonNode node => node.ToJsonString(),
            BackchannelTraceContext context => $"{nameof(BackchannelTraceContext)}({context.Baggage.Count} baggage items)",
            _ => value.ToString() ?? string.Empty
        };
 
    private static IConfiguration CreateConfiguration(params (string Key, string? Value)[] values)
    {
        return new ConfigurationBuilder()
            .AddInMemoryCollection(values.Select(value => new KeyValuePair<string, string?>(value.Key, value.Value)))
            .Build();
    }
}