File: NativeCSharpClientTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Native.Core.Tests\Aspire.Hosting.Native.Core.Tests.csproj (Aspire.Hosting.Native.Core.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.Reflection;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Aspire.Hosting.Native.Client;
using Aspire.Hosting.Native.Core.Tests.TestServices;
using Aspire.Hosting.RemoteHost;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class NativeCSharpClientTests
{
    [Fact]
    public void Projection_CoversEveryScannedCapabilityAndDtoWithoutRuntimeDependencies()
    {
        var contract = AtsCapabilityScanner.ScanAssemblies([typeof(Api.CompositionSession).Assembly]).ToAtsContext();
        var clientAssembly = typeof(NativeClient).Assembly;
        foreach (var capability in contract.Capabilities)
        {
            var source = contract.Methods[capability.CapabilityId];
            var projectedType = source.IsStatic ? typeof(NativeFactories) : ProjectType(source.DeclaringType!);
            var projectedMethod = projectedType.GetMethod(source.Name + "Async")!;
            Assert.NotNull(projectedMethod);
            var expected = source.GetParameters().Select(parameter => ProjectType(parameter.ParameterType));
            if (source.IsStatic)
            {
                expected = new[] { typeof(NativeClient) }.Concat(expected);
            }
            Assert.Equal(expected.Append(typeof(CancellationToken)), projectedMethod.GetParameters().Select(parameter => parameter.ParameterType));
            var sourceReturn = source.ReturnType == typeof(Task) ? typeof(void) :
                source.ReturnType.IsGenericType && source.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)
                    ? source.ReturnType.GetGenericArguments()[0] : source.ReturnType;
            Assert.Equal(sourceReturn == typeof(void) ? typeof(Task) : typeof(Task<>).MakeGenericType(ProjectType(sourceReturn)),
                projectedMethod.ReturnType);
        }
        foreach (var dto in contract.DtoTypes)
        {
            var source = dto.ClrType!;
            var projected = ProjectType(source);
            Assert.Equal(source.GetProperties().Select(property => (property.Name, ProjectType(property.PropertyType))),
                projected.GetProperties().Select(property => (property.Name, property.PropertyType)));
        }
        Assert.All(clientAssembly.GetReferencedAssemblies(), assembly =>
        {
            Assert.False(assembly.Name is "Aspire.Hosting" or "Aspire.Hosting.Native.Server" or "Aspire.Hosting.Native.Core" or
                "Aspire.Hosting.Native.CodeGeneration" or "Aspire.TypeSystem" or "Aspire.Hosting.Ats.Abstractions");
        });
        Assert.Equal(contract.Capabilities.Count,
            clientAssembly.GetTypes().Where(type => type.IsSubclassOf(typeof(NativeRemoteObject)) || type == typeof(NativeFactories))
                .Sum(type => type.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
                .Count(method => method.Name.EndsWith("Async", StringComparison.Ordinal) && type != typeof(NativeClient))));
 
        Type ProjectType(Type source) => source.IsArray ? ProjectType(source.GetElementType()!).MakeArrayType() :
            source.IsGenericType ? source.GetGenericTypeDefinition().MakeGenericType(source.GetGenericArguments().Select(ProjectType).ToArray()) :
            source.Assembly == typeof(Api.CompositionSession).Assembly
                ? clientAssembly.GetType($"Aspire.Hosting.Native.Client.{source.Name}", throwOnError: true)! : source;
    }
 
    [Fact]
    public async Task SocketClient_ComposesConfigurationsAndRejectsRetiredCapabilities()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System);
        var session = await client.Server.OpenApplicationAsync();
        var composition = await session.StartGenerationAsync();
        var service = await composition.AddResourceAsync("cache", "example/cache");
        var consumer = await composition.AddResourceAsync("consumer", "example/workload");
        await consumer.WaitForAsync(service);
        await service.SetResourceConfigurationAsync(new ResourceConfiguration
        {
            Properties = [new ResourceConfigurationProperty { Name = "image", Value = "redis:8" }]
        });
        var snapshot = await composition.InspectAsync();
        Assert.Collection(snapshot.Resources,
            cache =>
            {
                Assert.Equal("cache", cache.Name);
                Assert.Equal("example/cache", cache.TypeId);
                Assert.Empty(cache.Dependencies);
                Assert.Collection(cache.Configuration, property =>
                {
                    Assert.Equal("image", property.Name);
                    Assert.Equal("redis:8", property.Value);
                });
            },
            app => Assert.Equal([snapshot.Resources[0].ResourceId], app.Dependencies));
        await session.RetireGenerationAsync(composition);
        var error = await Assert.ThrowsAsync<NativeCapabilityException>(() => composition.InspectAsync());
        Assert.Equal("HANDLE_NOT_FOUND", error.Code);
    }
 
    [Fact]
    public async Task SocketClient_ProjectsResourceScopedLogsObservationsAndFailureResults()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System);
        var session = await client.Server.OpenApplicationAsync();
        var composition = await session.StartGenerationAsync();
        var declaration = await composition.AddResourceAsync("service", "example/service");
        var execution = await composition.CreateExecutionAsync();
        var resource = await client.Server.JoinResourceExecutionAsync(await execution.InviteResourceExecutionAsync(declaration));
        var observer = await client.Server.JoinApplicationObserverAsync(await execution.InviteApplicationObserverAsync());
        var configuration = await resource.ReadResourceConfigurationAsync();
        Assert.Equal(configuration.Revision, configuration.AppliedRevision);
        var completed = await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            resource.CompleteResourceConfigurationAsync(configuration.Revision,
                new RuntimeOperationResult { Status = "succeeded", Message = "Configured" }));
        Assert.Equal("OPERATION_REJECTED", completed.Code);
        await resource.AppendResourceLogAsync("stdout", "Projection protocol exercise");
        await resource.PublishObservationAsync(new ResourceObservation { State = "Running", Healthy = true, Urls = [] });
        var observed = await observer.ReadResourceObservationsAsync();
        Assert.Collection(observed.Resources, item =>
        {
            Assert.Equal("service", item.Name);
            Assert.Equal("Running", item.State);
            Assert.True(item.Healthy);
        });
        var logs = await observer.ReadResourceLogsAsync((await declaration.InspectResourceAsync()).ResourceId, 0);
        Assert.Collection(logs.Entries, entry => Assert.Equal("Projection protocol exercise", entry.Message));
        var error = await Assert.ThrowsAsync<NativeCapabilityException>(() => resource.StartContainerWorkloadAsync(new ContainerWorkloadLaunch
        {
            Image = "redis:8", Entrypoint = "", Mounts = [], Arguments = [], Environment = [],
            Endpoints = [new WorkloadEndpointDeclaration
            {
                Name = "tcp", Protocol = "TCP", UriScheme = "redis", TargetPort = 6379, HostPort = 0, EnvironmentVariable = ""
            }]
        }));
        Assert.Equal("NOT_SUPPORTED", error.Code);
        await session.RetireGenerationAsync(composition);
    }
 
    [Fact]
    public async Task SocketClient_RejectsAuthenticationAndCrossConnectionHandles()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
            NativeClient.ConnectAsync(host.Endpoint, "wrong-token", TimeSpan.FromSeconds(10), TimeProvider.System));
        await using var first = await NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System);
        await using var second = await NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System);
        var firstSession = await first.Server.OpenApplicationAsync();
        var firstComposition = await firstSession.StartGenerationAsync();
        var firstResource = await firstComposition.AddResourceAsync("first", "example/service");
        var secondSession = await second.Server.OpenApplicationAsync();
        var secondComposition = await secondSession.StartGenerationAsync();
        var secondResource = await secondComposition.AddResourceAsync("second", "example/service");
        await Assert.ThrowsAsync<ArgumentException>(() => firstResource.WaitForAsync(secondResource));
        await firstSession.RetireGenerationAsync(firstComposition);
        await secondSession.RetireGenerationAsync(secondComposition);
    }
 
    [Fact]
    public async Task SocketClient_CallerCancellationDoesNotDeadlockOtherCapabilities()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System);
        var session = await client.Server.OpenApplicationAsync();
        var composition = await session.StartGenerationAsync();
        var execution = await composition.CreateExecutionAsync();
        var observer = await client.Server.JoinApplicationObserverAsync(await execution.InviteApplicationObserverAsync());
        var observed = await observer.ReadResourceObservationsAsync();
        using var cancellation = new CancellationTokenSource();
        var waiting = observer.WaitResourceObservationsAsync(observed.Version, 500, cancellation.Token);
        await cancellation.CancelAsync();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waiting);
        Assert.Empty((await observer.ReadResourceObservationsAsync()).Resources);
        await session.RetireGenerationAsync(composition);
    }
 
    [Fact]
    public async Task SocketClient_AuthenticationHasAnIndependentBoundedDeadline()
    {
        using var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var clock = new FakeTimeProvider();
        var connecting = NativeClient.ConnectAsync(listener.LocalEndpoint, "test-authentication-token",
            TimeSpan.FromSeconds(10), clock);
        using var accepted = await listener.AcceptSocketAsync().WaitAsync(TimeSpan.FromSeconds(10));
        clock.Advance(TimeSpan.FromSeconds(9));
        Assert.False(connecting.IsCompleted);
        clock.Advance(TimeSpan.FromSeconds(1));
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => connecting.WaitAsync(TimeSpan.FromSeconds(10)));
    }
 
    [Fact]
    public async Task SocketClient_CancelledWaitCanBeDisposedBeforeRemoteCompletion()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System);
        var session = await client.Server.OpenApplicationAsync();
        var composition = await session.StartGenerationAsync();
        var execution = await composition.CreateExecutionAsync();
        var observer = await client.Server.JoinApplicationObserverAsync(await execution.InviteApplicationObserverAsync());
        var observed = await observer.ReadResourceObservationsAsync();
        using var cancellation = new CancellationTokenSource();
        var waiting = observer.WaitResourceObservationsAsync(observed.Version, 30000, cancellation.Token);
        await cancellation.CancelAsync();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waiting);
        await client.DisposeAsync();
    }
 
    [Fact]
    public async Task SocketClient_RejectsNonLocalEndpointsBeforeConnecting()
    {
        await Assert.ThrowsAsync<ArgumentException>(() => NativeClient.ConnectAsync(
            new IPEndPoint(IPAddress.Parse("192.0.2.1"), 1234), "test-token", TimeSpan.FromSeconds(1), TimeProvider.System));
    }
 
    [Theory]
    [InlineData("""{"$error":null}""")]
    [InlineData("""{"$error":"failure"}""")]
    [InlineData("""{"$error":{}}""")]
    [InlineData("""{"$error":{"code":null,"message":"redacted"}}""")]
    [InlineData("""{"$error":{"code":42,"message":"redacted"}}""")]
    [InlineData("""{"$error":{"code":"","message":"redacted"}}""")]
    [InlineData("""{"$error":{"code":"FAILURE","message":null}}""")]
    [InlineData("""{"$error":{"code":"FAILURE","message":[]}}""")]
    [InlineData("""{"$error":{"code":"FAILURE"}}""")]
    [InlineData("""{"$error":{"code":"FAILURE","message":"redacted","extra":"secret"}}""")]
    [InlineData("""{"$error":{"code":"FAILURE","message":"redacted"},"extra":"secret"}""")]
    [InlineData("""{"$error":{"code":"FAILURE","code":"DUPLICATE","message":"redacted"}}""")]
    public void CapabilityResult_RejectsMalformedFailureWithoutLeakingPayload(string payload)
    {
        using var document = JsonDocument.Parse(payload);
        var error = Assert.Throws<InvalidDataException>(() => NativeCapabilityResult.Parse(document.RootElement));
        Assert.Equal("The native capability failure response is malformed.", error.Message);
    }
 
    [Fact]
    public void CapabilityResult_ParsesValidTypedFailure()
    {
        using var document = JsonDocument.Parse("""{"$error":{"code":"HANDLE_NOT_FOUND","message":"The capability lifetime ended."}}""");
        var error = Assert.Throws<NativeCapabilityException>(() => NativeCapabilityResult.Parse(document.RootElement));
        Assert.Equal("HANDLE_NOT_FOUND", error.Code);
        Assert.Equal("The capability lifetime ended.", error.Message);
    }
}