|
// 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;
}
}
|