diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs index 99ed720f33f..860eb1f8441 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs @@ -206,6 +206,12 @@ private static void PreConfigureBuilderOptions( SetDefault(KnownConfigNames.AspNetCoreUrls, "http://localhost:8080"); SetDefaultFallback(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl, "http://localhost:4317"); + // Since the testing builder defaults all dashboard/OTLP URLs to HTTP, also allow + // unsecured transport by default. This prevents OptionsValidationException when the + // user enables the dashboard (DisableDashboard = false) without explicitly setting + // ASPIRE_ALLOW_UNSECURED_TRANSPORT. See https://github.com/microsoft/aspire/issues/17622 + SetDefault(KnownConfigNames.AllowUnsecuredTransport, "true"); + var appHostProjectPath = ResolveProjectPath(entryPointAssembly); if (!string.IsNullOrEmpty(appHostProjectPath) && Directory.Exists(appHostProjectPath)) { diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 9753be2f352..b3efb58fea9 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -138,9 +138,15 @@ void ConfigureKestrel(KestrelServerOptions kestrelOptions) kestrelOptions.Listen(IPAddress.Loopback, port: 0, ConfigureListen); _logger.LogDebug("Resource service endpoint not configured. Listening on {Scheme}://127.0.0.1:.", scheme); } - else if (IsLocalResourceServiceEndpoint(uri)) + else if (IPAddress.TryParse(uri.Host, out var ip) && IPAddress.IsLoopback(ip)) { - // Listen on the requested localhost port. + // Bind to the exact loopback address specified (e.g. 127.0.0.1 or [::1]). + kestrelOptions.Listen(ip, uri.Port, ConfigureListen); + _logger.LogDebug("Resource service endpoint configured: {Uri}", uri); + } + else if (uri.IsLoopback || IsLocalhostOrLocalhostTld(uri)) + { + // For "localhost" or *.localhost hosts, bind to both IPv4 and IPv6 loopback. kestrelOptions.ListenLocalhost(uri.Port, ConfigureListen); _logger.LogDebug("Resource service endpoint configured: {Uri}", uri); } @@ -178,16 +184,8 @@ internal static string ResolveScheme(Uri? configuredUri, bool allowUnsecuredTran return allowUnsecuredTransport ? "http" : "https"; } - /// - /// Determines whether the resource service endpoint is scoped to the local machine. - /// - internal static bool IsLocalResourceServiceEndpoint(Uri uri) + private static bool IsLocalhostOrLocalhostTld(Uri uri) { - if (uri.IsLoopback) - { - return true; - } - var host = uri.Host.EndsWith(".", StringComparison.Ordinal) ? uri.Host[..^1] : uri.Host; diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj index f9fc3a5b311..898ccdd5257 100644 --- a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs index afc3a33b270..da7ea4cc8a9 100644 --- a/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs @@ -8,6 +8,7 @@ using Aspire.Hosting.Utils; using Aspire.TestProject; using Aspire.TestUtilities; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -568,6 +569,69 @@ public async Task StartAsyncAbandonedAfterHang() } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task DashboardEnabledInTestingBuilderShouldWorkWithDynamicPorts() + { + var builder = DistributedApplicationTestingBuilder.Create([], (options, _) => + { + options.DisableDashboard = false; + }); + builder.WithTestAndResourceLogging(output); + + await using var app = await builder.BuildAsync(); + + await app.StartAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + // Wait for the dashboard to become healthy, confirming it is actually running. + await app.ResourceNotifications.WaitForResourceHealthyAsync( + "aspire-dashboard", + CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + + [Theory] + [RequiresFeature(TestFeature.Docker)] + [InlineData("https://127.0.0.1:0")] + [InlineData("https://[::1]:0")] + public async Task LoopbackWithDynamicPorts(string endpointUrl) + { + var builder = DistributedApplicationTestingBuilder.Create([], (opt, _) => + { + opt.DisableDashboard = false; + }); + builder.WithTestAndResourceLogging(output); + + builder.Configuration["ASPNETCORE_URLS"] = endpointUrl; + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = endpointUrl; + builder.Configuration["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = endpointUrl; + + await using var app = await builder.BuildAsync(); + await app.StartAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + // Wait for the dashboard to become healthy, confirming it is actually running. + await app.ResourceNotifications.WaitForResourceHealthyAsync( + "aspire-dashboard", + CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task NonLocalResourceServiceEndpointThrows() + { + var builder = DistributedApplicationTestingBuilder.Create([], (opt, _) => + { + opt.DisableDashboard = false; + }); + builder.WithTestAndResourceLogging(output); + + builder.Configuration["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://example.com:5001"; + + await using var app = await builder.BuildAsync(); + + var ex = await Assert.ThrowsAsync(() => app.StartAsync()); + Assert.Equal("ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL must contain a local loopback address.", ex.Message); + } + private sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs index 84c00dfa4fa..c1dbe8d9627 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs @@ -22,21 +22,4 @@ public void ResolveScheme_ReturnsExpectedScheme(string? uriString, bool allowUns Assert.Equal(expectedScheme, scheme); } - - [Theory] - [InlineData("https://localhost:5001", true)] - [InlineData("https://localhost.:5001", true)] - [InlineData("https://127.0.0.1:5001", true)] - [InlineData("https://[::1]:5001", true)] - [InlineData("https://myapp.dev.localhost:5001", true)] - [InlineData("https://myapp.dev.localhost.:5001", true)] - [InlineData("https://example.com:5001", false)] - [InlineData("https://localhost.example.com:5001", false)] - [InlineData("https://example-localhost:5001", false)] - public void IsLocalResourceServiceEndpoint_ReturnsExpectedResult(string uriString, bool expectedResult) - { - var result = DashboardServiceHost.IsLocalResourceServiceEndpoint(new Uri(uriString)); - - Assert.Equal(expectedResult, result); - } }