| File: JsonRpcAuthenticationTests.cs | Web Access |
| Project: src\tests\Aspire.Hosting.RemoteHost.Tests\Aspire.Hosting.RemoteHost.Tests.csproj (Aspire.Hosting.RemoteHost.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.IO.Pipes; using System.Net.Sockets; using System.Text.Json; using Aspire.Hosting.RemoteHost.Ats; using Aspire.Hosting.RemoteHost.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using StreamJsonRpc; using Xunit; namespace Aspire.Hosting.RemoteHost.Tests; public sealed class JsonRpcAuthenticationTests { public static TheoryData<string, object?[]> ProtectedMethods => new() { { "cancelToken", ["ct_missing"] }, { "invokeCapability", ["test-capability", null] }, { "getCapabilities", [] }, { "generateCode", ["TypeScript"] }, { "scaffoldAppHost", ["TypeScript", "/tmp/apphost", "AppHost"] }, { "detectAppHostType", ["/tmp/apphost"] }, { "getRuntimeSpec", ["TypeScript"] } }; [Fact] public async Task Ping_DoesNotRequireAuthentication() { await using var server = await RemoteHostTestServer.StartAsync(); await using var client = await server.ConnectAsync(); var result = await client.InvokeAsync<string>("ping"); Assert.Equal("pong", result); } [Theory] [MemberData(nameof(ProtectedMethods))] public async Task ProtectedMethods_RequireAuthentication(string methodName, object?[] arguments) { await using var server = await RemoteHostTestServer.StartAsync(); await using var client = await server.ConnectAsync(); var ex = await Assert.ThrowsAsync<RemoteInvocationException>( () => client.InvokeAsync<JsonElement?>(methodName, arguments)); Assert.Contains("Client must authenticate before invoking AppHost RPC methods.", ex.Message); } [Fact] public async Task FailedAuthentication_ClosesConnection_AndPreventsFurtherCalls() { await using var server = await RemoteHostTestServer.StartAsync(); await using var client = await server.ConnectAsync(); Assert.Equal("pong", await client.InvokeAsync<string>("ping")); await AssertRejectedAuthenticationAsync(client); await RemoteHostTestServer.WaitForDisconnectAsync(client); await Assert.ThrowsAnyAsync<Exception>(() => client.InvokeAsync<string>("ping")); await Assert.ThrowsAnyAsync<Exception>(() => client.InvokeAsync<bool>("cancelToken", ["ct_missing"])); } private static async Task AssertRejectedAuthenticationAsync(JsonRpcClientHandle client) { try { var authenticated = await client.InvokeAsync<bool>("authenticate", ["wrong-token"]); Assert.False(authenticated); } catch (ConnectionLostException) { // The server closes the connection immediately after rejecting the token, so the client may observe // the disconnect before it receives the boolean response. } } private sealed class RemoteHostTestServer : IAsyncDisposable { private const string RemoteAppHostToken = "ASPIRE_REMOTE_APPHOST_TOKEN"; private readonly IHost _host; private readonly string _socketPath; private readonly string? _socketDirectory; private RemoteHostTestServer(IHost host, string socketPath, string? socketDirectory) { _host = host; _socketPath = socketPath; _socketDirectory = socketDirectory; } public static async Task<RemoteHostTestServer> StartAsync() { var socketDirectory = OperatingSystem.IsWindows() ? null : Path.Combine(Path.GetTempPath(), $"arh-{Guid.NewGuid():N}"[..12]); if (socketDirectory is not null) { Directory.CreateDirectory(socketDirectory); } var socketPath = OperatingSystem.IsWindows() ? $"aspire-remotehost-test-{Guid.NewGuid():N}" : Path.Combine(socketDirectory!, "rpc.sock"); var builder = Host.CreateApplicationBuilder(); builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["REMOTE_APP_HOST_SOCKET_PATH"] = socketPath, [RemoteAppHostToken] = "expected-token" }); ConfigureServices(builder.Services); var host = builder.Build(); await host.StartAsync(); return new RemoteHostTestServer(host, socketPath, socketDirectory); } public async Task<JsonRpcClientHandle> ConnectAsync() { var stream = await ConnectToServerAsync(_socketPath, CancellationToken.None); var formatter = new SystemTextJsonFormatter(); var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter); var rpc = new JsonRpc(handler); rpc.StartListening(); return new JsonRpcClientHandle(stream, rpc); } public static async Task WaitForDisconnectAsync(JsonRpcClientHandle client) { var completedTask = await Task.WhenAny(client.Completion, Task.Delay(TimeSpan.FromSeconds(5))); Assert.Same(client.Completion, completedTask); } public async ValueTask DisposeAsync() { await _host.StopAsync(); _host.Dispose(); if (!OperatingSystem.IsWindows() && File.Exists(_socketPath)) { File.Delete(_socketPath); } if (!string.IsNullOrEmpty(_socketDirectory) && Directory.Exists(_socketDirectory)) { Directory.Delete(_socketDirectory, recursive: true); } } private static void ConfigureServices(IServiceCollection services) { services.AddHostedService<JsonRpcServer>(); services.AddSingleton<RemoteHostProfilingTelemetry>(); services.AddSingleton<AssemblyLoader>(); services.AddSingleton<Aspire.Hosting.RemoteHost.AtsContextFactory>(); services.AddSingleton(sp => sp.GetRequiredService<Aspire.Hosting.RemoteHost.AtsContextFactory>().GetContext()); services.AddSingleton<CodeGeneration.CodeGeneratorResolver>(); services.AddScoped<CodeGeneration.CodeGenerationService>(); services.AddSingleton<Language.LanguageSupportResolver>(); services.AddScoped<Language.LanguageService>(); services.AddScoped<JsonRpcAuthenticationState>(); services.AddScoped<HandleRegistry>(); services.AddScoped<CancellationTokenRegistry>(); services.AddScoped<JsonRpcCallbackInvoker>(); services.AddScoped<ICallbackInvoker>(sp => sp.GetRequiredService<JsonRpcCallbackInvoker>()); services.AddScoped<Ats.AtsCallbackProxyFactory>(); services.AddScoped(sp => new Lazy<Ats.AtsCallbackProxyFactory>(() => sp.GetRequiredService<Ats.AtsCallbackProxyFactory>())); services.AddScoped<Ats.AtsMarshaller>(); services.AddScoped<Ats.CapabilityDispatcher>(); services.AddScoped<RemoteAppHostService>(); } private static async Task<Stream> ConnectToServerAsync(string socketPath, CancellationToken cancellationToken) { using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); Exception? lastException = null; while (!linkedCts.Token.IsCancellationRequested) { try { if (OperatingSystem.IsWindows()) { var pipeClient = new NamedPipeClientStream(".", socketPath, PipeDirection.InOut, PipeOptions.Asynchronous); await pipeClient.ConnectAsync(linkedCts.Token); return pipeClient; } var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), linkedCts.Token); return new NetworkStream(socket, ownsSocket: true); } catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or OperationCanceledException) { lastException = ex; if (timeoutCts.IsCancellationRequested) { break; } await Task.Delay(100, cancellationToken); } } throw new TimeoutException($"Timed out connecting to test RPC server '{socketPath}'.", lastException); } } private sealed class JsonRpcClientHandle : IAsyncDisposable { private readonly Stream _stream; private readonly JsonRpc _rpc; public JsonRpcClientHandle(Stream stream, JsonRpc rpc) { _stream = stream; _rpc = rpc; } public Task Completion => _rpc.Completion; public Task<T> InvokeAsync<T>(string methodName, params object?[] arguments) => _rpc.InvokeWithCancellationAsync<T>(methodName, arguments, CancellationToken.None); public async ValueTask DisposeAsync() { _rpc.Dispose(); await _stream.DisposeAsync(); } } }