File: Integration\DashboardClientAuthTests.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.Net;
using System.Threading.Channels;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Model;
using Aspire.ResourceService.Proto.V1;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
using Xunit.Abstractions;
using DashboardServiceBase = Aspire.ResourceService.Proto.V1.DashboardService.DashboardServiceBase;
 
namespace Aspire.Dashboard.Tests.Integration;
 
public sealed class DashboardClientAuthTests
{
    private const string ApiKeyHeaderName = "x-resource-service-api-key";
 
    private readonly ITestOutputHelper _testOutputHelper;
 
    public DashboardClientAuthTests(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task ConnectsToResourceService_Unsecured(bool useHttps)
    {
        var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper);
        await using var server = await CreateResourceServiceServerAsync(loggerFactory, useHttps).DefaultTimeout();
        await using var client = await CreateDashboardClientAsync(loggerFactory, server.Url, authMode: ResourceClientAuthMode.Unsecured).DefaultTimeout();
 
        var call = await server.Calls.ApplicationInformationCallsChannel.Reader.ReadAsync().DefaultTimeout();
 
        Assert.NotNull(call.Request);
        Assert.NotNull(call.RequestHeaders);
        Assert.Null(call.RequestHeaders.Get(ApiKeyHeaderName));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task ConnectsToResourceService_ApiKey(bool useHttps)
    {
        var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper);
        await using var server = await CreateResourceServiceServerAsync(loggerFactory, useHttps).DefaultTimeout();
        await using var client = await CreateDashboardClientAsync(loggerFactory, server.Url, authMode: ResourceClientAuthMode.ApiKey, configureOptions: options => options.ResourceServiceClient.ApiKey = "TestApiKey!").DefaultTimeout();
 
        var call = await server.Calls.ApplicationInformationCallsChannel.Reader.ReadAsync().DefaultTimeout();
 
        Assert.NotNull(call.Request);
        Assert.NotNull(call.RequestHeaders);
        Assert.Equal("TestApiKey!", call.RequestHeaders.GetValue(ApiKeyHeaderName));
    }
 
    private static async Task<ResourceServiceServer> CreateResourceServiceServerAsync(ILoggerFactory loggerFactory, bool useHttps, Action<TestCalls>? configureCalls = null)
    {
        var serverAppBuilder = WebApplication.CreateSlimBuilder();
 
        var testCalls = new TestCalls();
 
        configureCalls?.Invoke(testCalls);
 
        serverAppBuilder.Services.AddGrpc(options => options.EnableDetailedErrors = true);
        serverAppBuilder.Services.AddSingleton(testCalls);
        serverAppBuilder.Services.AddSingleton(loggerFactory);
        serverAppBuilder.WebHost.ConfigureKestrel(ConfigureKestrel);
 
        var serverApp = serverAppBuilder.Build();
 
        serverApp.MapGrpcService<MockDashboardService>();
        serverApp.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client.");
 
        await serverApp.StartAsync();
 
        return new(serverApp, testCalls);
 
        void ConfigureKestrel(KestrelServerOptions kestrelOptions)
        {
            // Listen on a random port.
            kestrelOptions.Listen(IPAddress.Loopback, port: 0, ConfigureListen);
 
            void ConfigureListen(ListenOptions options)
            {
                // Force HTTP/2 for gRPC, so that it works over non-TLS connections
                // which cannot negotiate between HTTP/1.1 and HTTP/2.
                options.Protocols = HttpProtocols.Http2;
 
                if (useHttps)
                {
                    options.UseHttps();
                }
            }
        }
    }
 
    private static async Task<DashboardClient> CreateDashboardClientAsync(
        ILoggerFactory loggerFactory,
        string serverAddress,
        ResourceClientAuthMode authMode = ResourceClientAuthMode.Unsecured,
        Action<DashboardOptions>? configureOptions = null)
    {
        var options = new DashboardOptions
        {
            ResourceServiceClient =
            {
                AuthMode = authMode,
                Url = serverAddress
            }
        };
 
        configureOptions?.Invoke(options);
 
        options.ResourceServiceClient.TryParseOptions(out _);
 
        var client = new DashboardClient(
            loggerFactory: loggerFactory,
            configuration: new ConfigurationManager(),
            dashboardOptions: Options.Create(options),
            dashboardClientStatus: new TestDashboardClientStatus(),
            timeProvider: new BrowserTimeProvider(NullLoggerFactory.Instance),
            knownPropertyLookup: new MockKnownPropertyLookup(),
            configureHttpHandler: handler => handler.SslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, sslPolicyErrors) => true);
 
        var iClient = (IDashboardClient)client;
 
        await iClient.WhenConnected;
 
        return client;
    }
 
    private sealed class ResourceServiceServer(WebApplication serverApp, TestCalls testCalls) : IAsyncDisposable
    {
        public TestCalls Calls { get; } = testCalls;
 
        public string Url => serverApp.Urls.First();
 
        public async ValueTask DisposeAsync()
        {
            await serverApp.StopAsync();
            await serverApp.DisposeAsync();
        }
    }
 
    private sealed class TestCalls
    {
        public Channel<ReceivedCallInfo<ApplicationInformationRequest>> ApplicationInformationCallsChannel { get; } = Channel.CreateUnbounded<ReceivedCallInfo<ApplicationInformationRequest>>();
    }
 
    private sealed class TestDashboardClientStatus : IDashboardClientStatus
    {
        public bool IsEnabled => true;
    }
 
    private sealed class MockDashboardService(TestCalls testCalls) : DashboardServiceBase
    {
        public override Task<ApplicationInformationResponse> GetApplicationInformation(
            ApplicationInformationRequest request,
            ServerCallContext context)
        {
            testCalls.ApplicationInformationCallsChannel.Writer.TryWrite(new ReceivedCallInfo<ApplicationInformationRequest>(request, context.RequestHeaders));
 
            return Task.FromResult(new ApplicationInformationResponse()
            {
                ApplicationName = "Test application"
            });
        }
 
        public override Task WatchResources(
            WatchResourcesRequest request,
            IServerStreamWriter<WatchResourcesUpdate> responseStream,
            ServerCallContext context)
        {
            responseStream.WriteAsync(new WatchResourcesUpdate()
            {
                InitialData = new InitialResourceData()
                {
                    ResourceTypes = { new ResourceType() { UniqueName = "test", DisplayName = "Test" } },
                    Resources = { new Resource() { Name = "resource1", ResourceType = "test", Uid = "resource1", CreatedAt = Timestamp.FromDateTime(DateTime.Now) } }
                }
            });
 
            return Task.CompletedTask;
        }
    }
 
    private sealed class ReceivedCallInfo<T>(T request, Metadata requestHeaders) where T : IMessage
    {
        public T Request => request;
        public Metadata RequestHeaders => requestHeaders;
    }
}