File: Terminal\TerminalWebSocketProxyEndpointTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.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.Security.Claims;
using System.Text.Encodings.Web;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Terminal;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Terminal;
 
// Endpoint-level coverage for the CSWSH defense. Unit tests on IsAllowedOrigin
// prove the helper works, but only an endpoint test proves the /api/terminal
// route is actually wired through that gate before reaching the resolver. A
// future refactor that moved origin checking elsewhere — or accidentally
// removed it from HandleAsync — would silently reopen CSWSH while leaving the
// IsAllowedOrigin unit tests green. These tests fail in exactly that
// scenario by verifying both the rejection HTTP status AND that the resolver
// is never invoked when the Origin gate rejects the upgrade.
public class TerminalWebSocketProxyEndpointTests
{
    private const string DashboardScheme = "https";
    private const string DashboardHost = "dashboard.example.com";
 
    [Fact]
    public async Task TerminalEndpoint_MissingOrigin_Returns403_AndDoesNotCallResolver()
    {
        var resolver = new TrackingTerminalConnectionResolver();
        using var host = await BuildHostAsync(resolver);
        var client = host.GetTestServer().CreateWebSocketClient();
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
        {
            // No SetRequestHeader("Origin", ...) — TestHost will not synthesise
            // one, so the proxy sees a missing Origin header.
            await client.ConnectAsync(BuildTerminalUri(), CancellationToken.None);
        });
 
        Assert.Contains("403", ex.Message);
        Assert.False(resolver.ResolveCalled, "Resolver must not be invoked when the Origin gate rejects the upgrade.");
    }
 
    [Fact]
    public async Task TerminalEndpoint_DisallowedOrigin_Returns403_AndDoesNotCallResolver()
    {
        var resolver = new TrackingTerminalConnectionResolver();
        using var host = await BuildHostAsync(resolver);
        var client = host.GetTestServer().CreateWebSocketClient();
        client.ConfigureRequest = req =>
        {
            req.Headers["Origin"] = "https://evil.example.com";
        };
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
        {
            await client.ConnectAsync(BuildTerminalUri(), CancellationToken.None);
        });
 
        Assert.Contains("403", ex.Message);
        Assert.False(resolver.ResolveCalled, "Resolver must not be invoked when the Origin gate rejects the upgrade.");
    }
 
    [Fact]
    public async Task TerminalEndpoint_SameOrigin_ProceedsToResolver()
    {
        // Resolver returns null so the endpoint reports the resource as unavailable.
        // We don't care about the response code here — only that the resolver was
        // reached, which proves the origin gate passed and execution flowed into
        // resource resolution.
        var resolver = new TrackingTerminalConnectionResolver();
        using var host = await BuildHostAsync(resolver);
        var client = host.GetTestServer().CreateWebSocketClient();
        client.ConfigureRequest = req =>
        {
            req.Headers["Origin"] = $"{DashboardScheme}://{DashboardHost}";
        };
 
        // Allowed-origin path will still fail to upgrade because the fake
        // resolver returns null (resource not found) — the proxy responds 404,
        // which TestHost's WebSocketClient surfaces as InvalidOperationException
        // from ConnectAsync. The important assertion is that the resolver was
        // reached at all.
        await Assert.ThrowsAsync<InvalidOperationException>(async () =>
        {
            await client.ConnectAsync(BuildTerminalUri(), CancellationToken.None);
        });
 
        Assert.True(resolver.ResolveCalled, "Same-origin requests must proceed past the Origin gate to resource resolution.");
    }
 
    private static Uri BuildTerminalUri()
    {
        // TestHost rewrites Scheme/Host on dispatch; only the path+query matter.
        return new Uri($"{DashboardScheme}://{DashboardHost}/api/terminal?resource=myapp&replica=0");
    }
 
    private static async Task<IHost> BuildHostAsync(ITerminalConnectionResolver resolver)
    {
        return await new HostBuilder()
            .ConfigureWebHost(webBuilder =>
            {
                webBuilder
                    .UseTestServer()
                    .ConfigureServices(services =>
                    {
                        services.AddRouting();
                        services.AddSingleton(resolver);
 
                        // Permissive auth/authorization stack — these tests
                        // target the Origin gate, not RequireAuthorization.
                        services.AddAuthentication(AlwaysAuthenticatedHandler.SchemeName)
                                .AddScheme<AuthenticationSchemeOptions, AlwaysAuthenticatedHandler>(
                                    AlwaysAuthenticatedHandler.SchemeName, _ => { });
                        services.AddAuthorizationBuilder()
                                .AddPolicy(FrontendAuthorizationDefaults.PolicyName,
                                           policy => policy.RequireAuthenticatedUser());
 
                        services.AddLogging();
                    })
                    .Configure(app =>
                    {
                        // Force Scheme/Host to match what BuildTerminalUri sends so
                        // IsAllowedOrigin's same-origin comparison matches the test
                        // origin. TestServer's default Scheme is "http" and Host is
                        // "localhost"; we rewrite to the dashboard's public origin.
                        app.Use(async (ctx, next) =>
                        {
                            ctx.Request.Scheme = DashboardScheme;
                            ctx.Request.Host = new HostString(DashboardHost);
                            await next();
                        });
 
                        app.UseRouting();
                        app.UseAuthentication();
                        app.UseAuthorization();
                        app.UseWebSockets();
 
                        // Endpoints must be mapped against a WebApplication, but
                        // since this host uses Generic Host + UseTestServer we
                        // map the production handler manually with the same
                        // wiring as MapTerminalWebSocket. The point of the
                        // test is to lock down the origin-first ordering inside
                        // HandleAsync regardless of which Map* overload is used.
                        app.UseEndpoints(endpoints =>
                        {
                            endpoints.Map("/api/terminal", async (HttpContext context,
                                                                  ITerminalConnectionResolver r,
                                                                  ILoggerFactory loggerFactory) =>
                            {
                                var logger = loggerFactory.CreateLogger("Aspire.Dashboard.Terminal.TerminalWebSocketProxy");
                                await TerminalWebSocketProxy.HandleAsync(context, r, logger, "test");
                            }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName);
                        });
                    });
            })
            .StartAsync();
    }
 
    private sealed class TrackingTerminalConnectionResolver : ITerminalConnectionResolver
    {
        public bool ResolveCalled { get; private set; }
 
        public Task<Stream?> ConnectAsync(string resourceName, int replicaIndex, CancellationToken cancellationToken)
        {
            ResolveCalled = true;
            return Task.FromResult<Stream?>(null);
        }
    }
 
    private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
    {
        public const string SchemeName = "AlwaysAuthenticated";
 
        public AlwaysAuthenticatedHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
                                          ILoggerFactory logger,
                                          UrlEncoder encoder)
            : base(options, logger, encoder)
        {
        }
 
        protected override Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            var identity = new ClaimsIdentity(SchemeName);
            identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "test-user"));
            var principal = new ClaimsPrincipal(identity);
            var ticket = new AuthenticationTicket(principal, SchemeName);
            return Task.FromResult(AuthenticateResult.Success(ticket));
        }
    }
}