File: NativeRpcAuthoringTests.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.Diagnostics;
using System.Globalization;
using System.Net;
using System.Text.Json;
using Aspire.Hosting.Native.Client;
using Aspire.Hosting.Native.Core.Tests.TestServices;
using Aspire.Hosting.Native.Runtime;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class NativeRpcAuthoringTests
{
    [Fact]
    public async Task GeneratedAuthoringCallsKeepDeclarationsInertAndResolveValuesOnlyAtExecution()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        await using var host = await NativeClientTestHost.StartWithExecutorAsync(executor, new());
        await using var client = await ConnectAsync(host);
        var builder = await client.Server.CreateRpcBuilderAsync();
        var source = await builder.AddContainerAsync("cache", "localhost:5000/team/redis:old");
        await source.WithImageTagAsync("8-alpine");
        await source.WithEndpointAsync(Endpoint("tcp", 6379, "redis"));
        await source.WithArgsAsync(["--save", ""]);
        var password = await builder.AddSecretParameterAsync("password", "native-private-secret");
        var composition = await builder.BuildAsync();
        var connection = await composition.CreateLiteralValueAsync("redis://:");
        connection = await connection.ConcatValueAsync(password);
        connection = await connection.ConcatValueAsync(await composition.CreateLiteralValueAsync("@"));
        var endpoint = await source.GetEndpointAsync("tcp");
        connection = await connection.ConcatValueAsync(await endpoint.PropertyAsync("hostAndPort"));
        await source.WithConnectionStringAsync(connection);
        await source.WithSerializedAnnotationAsync("example.cache/v1", """{"version":1}""");
        await source.WithSerializedAnnotationAsync("example.other/v1", """{"enabled":true}""");
        await source.WithSerializedAnnotationAsync("example.cache/v1", """{"version":2}""");
        Assert.Equal("""{"version":2}""", await source.GetSerializedAnnotationAsync("example.cache/v1"));
        Assert.Equal("""{"enabled":true}""", await source.GetSerializedAnnotationAsync("example.other/v1"));
        Assert.Null(await source.GetSerializedAnnotationAsync("missing"));
        var consumer = await builder.AddExecutableAsync("consumer", Environment.ProcessPath!, AppContext.BaseDirectory);
        await consumer.WithReferenceAsync(source);
        await consumer.WithEnvironmentAsync("ADDRESS", endpoint);
        await consumer.WithEnvironmentAsync("PASSWORD", password);
        RpcEnvironmentEditor? environmentEditor = null;
        await consumer.WithEnvironmentCallbackAsync(async editor =>
        {
            environmentEditor = editor;
            await editor.SetAsync("CALLBACK", "edited");
        });
        await consumer.WithArgsCallbackAsync(async editor =>
        {
            await editor.AddAsync("--endpoint");
            await editor.AddAsync(await endpoint.PropertyAsync("hostAndPort"));
        });
        Assert.Empty(executor.Plans);
        Assert.NotNull(environmentEditor);
        Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            environmentEditor.SetAsync("STALE", "must-not-apply"))).Code);
        var execution = await (await builder.BuildAsync()).StartApplicationAsync();
        await execution.WaitApplicationReadyAsync(15000);
        var container = Assert.Single(executor.Plans.OfType<ContainerWorkload>());
        Assert.Equal("localhost:5000/team/redis:8-alpine", container.Image);
        Assert.Equal(["--save", ""], container.Arguments);
        var executable = Assert.Single(executor.Plans.OfType<ExecutableWorkload>());
        Assert.Equal(["--endpoint", "127.0.0.1:5050"], executable.Arguments);
        Assert.Equal(
            ["ADDRESS=redis://127.0.0.1:5050", "CALLBACK=edited",
                "ConnectionStrings__cache=redis://:native-private-secret@127.0.0.1:5050", "PASSWORD=native-private-secret"],
            executable.Environment.Select(variable => $"{variable.Name}={variable.Value}").Order(StringComparer.Ordinal));
        await builder.RetireAsync();
        Assert.Equal(2, executor.Removed.Count);
        Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            source.GetResourceNameAsync())).Code);
    }
 
    [Fact]
    public async Task ActiveRemoteEditorCallbacksBlockBuildAndAreRevokedOnFailure()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await ConnectAsync(host);
        var builder = await client.Server.CreateRpcBuilderAsync();
        var resource = await builder.AddContainerAsync("service", "example/service:1");
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        RpcEnvironmentEditor? retained = null;
        var editing = resource.WithEnvironmentCallbackAsync(async editor =>
        {
            retained = editor;
            entered.TrySetResult();
            await release.Task;
            throw new InvalidOperationException("private-callback-secret");
        });
        try
        {
            await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
            Assert.Equal("OPERATION_REJECTED", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
                builder.BuildAsync())).Code);
        }
        finally
        {
            release.TrySetResult();
        }
        var failure = await Assert.ThrowsAsync<NativeCapabilityException>(() => editing);
        Assert.Equal("PROVIDER_FAILED", failure.Code);
        Assert.Equal("The provider rejected the invocation.", failure.Message);
        Assert.NotNull(retained);
        Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            retained.SetAsync("STALE", "value"))).Code);
        await builder.BuildAsync();
        await builder.RetireAsync();
    }
 
    [Fact]
    public async Task RetirementDuringCallbackCannotPublishARevivedFluentHandle()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await ConnectAsync(host);
        var builder = await client.Server.CreateRpcBuilderAsync();
        var resource = await builder.AddContainerAsync("service", "example/service:1");
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var editing = resource.WithArgsCallbackAsync(async _ =>
        {
            entered.TrySetResult();
            await release.Task;
        });
        try
        {
            await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
            await builder.RetireAsync();
        }
        finally
        {
            release.TrySetResult();
        }
        Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() => editing)).Code);
    }
 
    [Fact]
    public async Task RemoteEditorDeadlineRevokesAuthorityWithoutClosingTheAuthenticatedConnection()
    {
        var clock = new FakeTimeProvider();
        await using var host = await NativeClientTestHost.StartWithOptionsAsync(new()
        {
            TimeProvider = clock,
            ProviderInvocationTimeout = TimeSpan.FromSeconds(5)
        });
        await using var client = await ConnectAsync(host);
        var builder = await client.Server.CreateRpcBuilderAsync();
        var resource = await builder.AddContainerAsync("service", "example/service:1");
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        RpcArgsEditor? retained = null;
        var editing = resource.WithArgsCallbackAsync(async editor =>
        {
            retained = editor;
            entered.TrySetResult();
            await release.Task;
        });
        try
        {
            await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
            clock.Advance(TimeSpan.FromSeconds(5));
            await Assert.ThrowsAsync<NativeCapabilityException>(() =>
                editing.WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System));
            Assert.NotNull(retained);
            Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
                retained.AddAsync("late"))).Code);
            await builder.BuildAsync();
        }
        finally
        {
            release.TrySetResult();
        }
        await builder.RetireAsync();
    }
 
    [Fact]
    public async Task SymbolicUnionInputsRejectOtherCompositionsAndForeignConnections()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await ConnectAsync(host);
        await using var foreign = await ConnectAsync(host);
        var builder = await client.Server.CreateRpcBuilderAsync();
        var other = await client.Server.CreateRpcBuilderAsync();
        var resource = await builder.AddContainerAsync("service", "example/service:1");
        var otherValue = await other.AddSecretParameterAsync("password", "private-secret");
        Assert.Equal("INVALID_ARGUMENT", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            resource.WithEnvironmentAsync("PASSWORD", otherValue))).Code);
        var foreignResource = await (await foreign.Server.CreateRpcBuilderAsync()).AddContainerAsync("foreign", "example/service:1");
        await Assert.ThrowsAsync<ArgumentException>(() => resource.WithEnvironmentAsync("PASSWORD", foreignResource));
        var arguments = new Dictionary<string, JsonElement>(StringComparer.Ordinal)
        {
            ["context"] = client.GetHandle(resource),
            ["name"] = JsonSerializer.SerializeToElement("PASSWORD"),
            ["value"] = foreign.GetHandle(foreignResource)
        };
        Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            client.InvokeAsync("Aspire.Hosting.Native.Authoring/withEnvironment", arguments, TestContext.Current.CancellationToken))).Code);
        arguments["value"] = JsonSerializer.SerializeToElement(42);
        Assert.Equal("INVALID_ARGUMENT", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            client.InvokeAsync("Aspire.Hosting.Native.Authoring/withEnvironment", arguments, TestContext.Current.CancellationToken))).Code);
        await builder.RetireAsync();
        await other.RetireAsync();
    }
 
    [Fact]
    public async Task VoidEditorCallbacksRejectReturnedValuesAndMutatedWritebackEnvelopes()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await ConnectAsync(host);
        var builder = await client.Server.CreateRpcBuilderAsync();
        var resource = await builder.AddContainerAsync("service", "example/service:1");
        foreach (var payload in new[] { "42", """{"p0":{"$handle":"foreign","$type":"fake"}}""" })
        {
            const string callbackId = "invalid-void-callback";
            RpcEnvironmentEditor? retained = null;
            client.RegisterCallback(callbackId, arguments =>
            {
                retained = new RpcEnvironmentEditor(client, arguments.GetProperty("p0"));
 
                return Task.FromResult(JsonDocument.Parse(payload).RootElement.Clone());
            });
            try
            {
                var failure = await Assert.ThrowsAsync<NativeCapabilityException>(() =>
                    client.InvokeAsync("Aspire.Hosting.Native.Authoring/withEnvironmentCallback",
                        new()
                        {
                            ["context"] = client.GetHandle(resource),
                            ["callback"] = JsonSerializer.SerializeToElement(callbackId)
                        }, TestContext.Current.CancellationToken));
                Assert.Equal("INVALID_ARGUMENT", failure.Code);
                Assert.NotNull(retained);
                Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
                    retained.SetAsync("STALE", "must-not-apply"))).Code);
            }
            finally
            {
                client.UnregisterCallback(callbackId);
            }
        }
        await builder.RetireAsync();
    }
 
    [Fact]
    public async Task ProviderAuthoringCallbacksUseProviderPeerAndBorrowOnlyGenerationScopedEditorAuthority()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var app = await ConnectAsync(host);
        await using var provider = await ConnectAsync(host);
        var metadata = JsonDocument.Parse("""
            {"protocolVersion":2,"capabilities":[{"id":"example.authoring/configure","method":"configure",
              "parameters":[{"name":"resource","type":{"typeId":"Aspire.Hosting.Native.Server/Aspire.Hosting.Native.Authoring.RpcResource","category":"Handle"}}],
              "returnType":{"typeId":"string","category":"Primitive"}}]}
            """).RootElement.Clone();
        RpcEnvironmentEditor? retained = null;
        await provider.RegisterIntegrationHostAsync(
            await app.ExpectIntegrationHostAsync("example.authoring", TestContext.Current.CancellationToken),
            new NativeProviderDefinition(metadata, async (_, _, arguments) =>
            {
                var resource = new RpcResource(provider, arguments.GetProperty("resource"));
                await resource.WithEnvironmentCallbackAsync(async editor =>
                {
                    retained = editor;
                    await editor.SetAsync("FROM_PROVIDER", "edited");
                    await resource.WithSerializedAnnotationAsync("example.provider/v1", """{"configured":true}""");
                });
 
                return JsonSerializer.SerializeToElement("configured");
            }), TestContext.Current.CancellationToken);
        var builder = await app.Server.CreateRpcBuilderAsync();
        var resource = await builder.AddContainerAsync("service", "example/service:1");
        var result = await app.InvokeAsync("example.authoring/configure",
            new() { ["resource"] = app.GetHandle(resource) }, TestContext.Current.CancellationToken);
        Assert.Equal("configured", result.GetString());
        Assert.Equal("""{"configured":true}""", await resource.GetSerializedAnnotationAsync("example.provider/v1"));
        Assert.NotNull(retained);
        Assert.Equal("HANDLE_NOT_FOUND", (await Assert.ThrowsAsync<NativeCapabilityException>(() =>
            retained.SetAsync("STALE", "must-not-apply"))).Code);
        await builder.RetireAsync();
    }
 
    [Fact]
    public async Task GeneratedTypeScriptAuthoringUsesFluentCallsAndRealNestedCallbackRpc()
    {
        var script = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_RPC_AUTHORING_TYPESCRIPT");
        var node = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_SYMBOLIC_TEST_NODE");
        if (script is null || node is null)
        {
            Assert.Skip("Supply the compiled RPC authoring AppHost and Node executable to exercise the generated TS SDK.");
        }
        await using var host = await NativeClientTestHost.StartAsync();
        var start = new ProcessStartInfo(node)
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false
        };
        start.ArgumentList.Add(script);
        start.ArgumentList.Add(Assert.IsType<IPEndPoint>(host.Endpoint).Port.ToString(CultureInfo.InvariantCulture));
        start.Environment["ASPIRE_NATIVE_RPC_TOKEN"] = host.Token;
        var output = await NativeIntegrationAppHostRunner.RunAsync(start, TimeProvider.System);
        Assert.Equal("rpc-authoring-ok", output.Trim());
    }
 
    private static RpcEndpointOptions Endpoint(string name, int port, string scheme) => new()
    {
        Name = name, TargetPort = port, Scheme = scheme, Protocol = null, Port = null, Env = null
    };
 
    private static Task<NativeClient> ConnectAsync(NativeClientTestHost host) =>
        NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System);
}